mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-06-30 17:09:38 +02:00
Advance TS port Effect workbench
This commit is contained in:
parent
92dae8c374
commit
3515106670
116 changed files with 12286 additions and 9584 deletions
3
ts/.gitignore
vendored
3
ts/.gitignore
vendored
|
|
@ -2,3 +2,6 @@ node_modules/
|
|||
dist/
|
||||
*.tsbuildinfo
|
||||
.turbo/
|
||||
.playwright/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
|
|
|||
142
ts/EFFECT_ATOM_REFERENCES.md
Normal file
142
ts/EFFECT_ATOM_REFERENCES.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
I have the effect v4 source code cloned in `~/YeeBois/projects/beep-effect/.repos/effect-v4` and the important docs and modules are here:
|
||||
|
||||
- [`@effect/atom-react`](/home/elpresidank/YeeBois/projects/.repos/effect-v4/packages/atom/react)
|
||||
- [`effect/unstable/reactivity`](/home/elpresidank/YeeBois/projects/.repos/effect-v4/packages/effect/src/unstable/reactivity)
|
||||
- [`@effect/atom-react documentation`](/home/elpresidank/YeeBois/projects/.repos/effect-v4/packages/atom/react/docs)
|
||||
- [`effect/unstable/reactivity documentation`](/home/elpresidank/YeeBois/projects/beep-effect/.repos/effect-v4/packages/effect/docs/modules/unstable/reactivity)
|
||||
|
||||
|
||||
Also I have some other repositories & beep-effect modules we can reference for guidance:
|
||||
|
||||
- [`effect/unstable/reactivity/AsyncResult`](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/effect/src/unstable/reactivity/AsyncResult.ts#L1-1113) usage ~/YeeBois/dev/hazel/apps/web/src/lib/auth.tsx#L1-41
|
||||
- [`effect/unstable/reactivity/Atom`](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/effect/src/unstable/reactivity/Atom.ts#L1-2548 ) usage ~/YeeBois/dev/hazel/apps/web/src/atoms/tauri-update-atoms.ts#L1-241
|
||||
- `Atom.runtime` usage (IMPORTANT! don't just use Effect.runPromise, Then we can get telemetry on the browser) /home/elpresidank/YeeBois/dev/ghui/src/services/runtime.ts#L1-62
|
||||
- `runtime.fn` usage /home/elpresidank/YeeBois/dev/ghui/src/ui/pullRequests/atoms.ts/src/ui/pullRequests/atoms.ts#L199-225
|
||||
- Atom.family usage /home/elpresidank/YeeBois/dev/ghui/src/ui/pullRequests/atoms.ts/src/ui/pullRequests/atoms.ts#L180-197
|
||||
- [`effect/unstable/reactivity/AtomHttpApi`](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/effect/src/unstable/reactivity/AtomHttpApi.ts#L1-371) usage ~/YeeBois/projects/dev/hazel/apps/web/src/lib/services/common/link-preview-client.ts#L1-10
|
||||
- [`effect/unstable/reactivity/AtomRef`](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/effect/src/unstable/reactivity/AtomRef.ts#L1-377) usage /home/elpresidank/YeeBois/dev/view-server-smart/packages/client/src/live-client.ts#L1-47
|
||||
- [`effect/unstable/reactivity/AtomRegistry`](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/effect/src/unstable/reactivity/AtomRegistry.ts#L1-1194) usage /home/elpresidank/YeeBois/dev/ghui/src/App.tsx#L1195-1209
|
||||
- [`effect/unstable/reactivity/AtomRpc`](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/effect/src/unstable/reactivity/AtomRpc.ts#L1-319) usage /home/elpresidank/YeeBois/dev/hazel/apps/web/src/lib/services/common/rpc-atom-client.ts#L1-77
|
||||
- [`effect/unstable/reactivity/Hydration`](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/effect/src/unstable/reactivity/Hydration.ts#L1-178) usage https://github.com/zeyuri/effect-start/blob/7b85bdcd8f9055879ac453058c7a3f67d8ff5355/apps/web/src/lib/atom-utils.ts
|
||||
- [`effect/unstable/reactivity/Reactivity`](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/effect/src/unstable/reactivity/Reactivity.ts#L1-379 ) usage https://github.com/JerkyTreats/t3code-omarchy/blob/01f7a858b794706d4ceb792bf0add6424157879f/apps/server/src/persistence/NodeSqliteClient.ts
|
||||
- [`@effect/atom-react` RegistryProvider](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/atom/react/src/RegistryContext.ts#L45-125) usage https://github.com/millionco/expect/blob/39e97500725783490136a8fc7040e6e4dbaafa44/apps/cli/src/program.tsx
|
||||
- [`@effect/atom-react` RegistryContext](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/atom/react/src/RegistryContext.ts#L1-125 ) usage @.context/effect-atom/docs/atom-react/RegistryContext.ts.md#L1-58
|
||||
- [`@effect/atom-react` useAtomInitialValues](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/atom/react/src/Hooks.ts#L75-104) usage ~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/atom/solid/test/index.test.tsx#L87-100
|
||||
- [`@effect/atom-react` useAtomValue](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/atom/react/src/Hooks.ts#L106-136) usage ~/YeeBois/projects/dev/hazel/apps/web/src/lib/auth.tsx#L28
|
||||
- [`@effect/atom-react` useAtomMount](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/atom/react/src/Hooks.ts#L177-201) usage https://github.com/kitlangton/motel/blob/272b18d90fe97e6c7151a900f478ab934036b1a5/web/src/App.tsx
|
||||
- [`@effect/atom-react` useAtom](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/atom/react/src/Hooks.ts#L271-306) usage https://github.com/kitlangton/motel/blob/272b18d90fe97e6c7151a900f478ab934036b1a5/src/App.tsx
|
||||
- [`@effect/atom-react` useAtomSet](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/atom/react/src/Hooks.ts#L203-242) usage https://github.com/HazelChat/hazel/blob/45dd81c272af7c756400c285633ded7dd0b2bf81/apps/web/src/components/theme-provider.tsx
|
||||
- [`@effect/atom-react` useAtomRefresh](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/atom/react/src/Hooks.ts#L244-269 ) usage ~/YeeBois/projects/dev/hazel/apps/web/src/components/profile/profile-picture-upload.tsx#L1-244
|
||||
- [`@effect/atom-react` useAtomSuspense](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/atom/react/src/Hooks.ts#L349-386) usage https://github.com/kitlangton/x-rank/blob/008ad0bf5b07bbfe779b57f9dcd524efeb07cf79/src/App.tsx
|
||||
- [`@effect/atom-react` useAtomSubscribe](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/atom/react/src/Hooks.ts#L388-418) usage https://github.com/pingdotgg/t3code/blob/b3e8c0334b25238e2b55868a87bd6270e234b7de/apps/web/src/rpc/serverState.ts
|
||||
- [`@effect/atom-react` useAtomRef](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/atom/react/src/Hooks.ts#L420-443) usage https://github.com/Hellfrosted/TTSMM-EX/blob/1c23188e01d0b1b370d3c65d5c9f8b3a2231da3c/src/renderer/state/block-lookup-store.ts
|
||||
- [`@effect/atom-react` useAtomRefProp](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/atom/react/src/Hooks.ts#L445-465) usage https://github.com/Makisuo/maple/blob/c71ec342dd112a7b1d783beb480dca4ebea6789e/apps/web/src/lib/effect-atom.ts
|
||||
- [`@effect/atom-react` useAtomRefPropValue](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/atom/react/src/Hooks.ts#L467-489) usage https://github.com/Makisuo/maple/blob/c71ec342dd112a7b1d783beb480dca4ebea6789e/apps/web/src/lib/effect-atom.ts
|
||||
- [`@effect/atom-react` ReactHydration.HydrationBoundary](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/atom/react/src/ReactHydration.ts#L1-124) usage https://github.com/zeyuri/effect-start/blob/7b85bdcd8f9055879ac453058c7a3f67d8ff5355/apps/web/src/routes/todos/index.tsx#L4
|
||||
- [`@effect/atom-react` ScopedAtom](~/YeeBois/projects/beep-effect/.repos/effect-v4/packages/atom/react/src/ScopedAtom.ts#L1-163) usage https://github.com/SandroMaglione/getting-started-xstate-and-effect/blob/cdd59bf2cf1c189f6e7b3aff9e14dd05d727ddcf/.repos/effect-v4/packages/atom/react/src/ScopedAtom.ts
|
||||
|
||||
|
||||
# Notes on modules
|
||||
|
||||
## `effect/unstable/reactivity/AsyncResult`
|
||||
State containers for asynchronous values used by the reactivity APIs.`AsyncResult` records the latest observable state of work that may still beloading, refreshing, retrying, or recovering from failure. The value is one of`Initial`, `Success`, or `Failure`, and every variant also carries a `waiting`flag so callers can keep rendering the current state while newer work is inflight.**Mental model**The variant answers "what do we know right now?", while `waiting` answers "isnewer work currently running?". A success contains the current value and itstimestamp. A failure contains a `Cause` and may also keep the previoussuccess, which lets UI and atom code show stale data while exposing the latestfailure for error displays and retry logic.**Common tasks**- Start with {@link initial}, {@link success}, {@link failure}, or {@link fail}- Convert Effect exits with {@link fromExit} and {@link fromExitWithPrevious}- Mark existing state as loading with {@link waiting} or {@link waitingFrom}- Read values and failures with {@link value}, {@link cause}, {@link error}, {@link getOrElse}, and {@link toExit}- Transform and combine results with {@link map}, {@link flatMap}, and {@link all}- Render all states with {@link match}, {@link matchWithWaiting}, or {@link builder}**Gotchas**- `waiting` is an overlay, not a fourth variant; any variant can be waiting.- {@link value} and {@link getOrElse} can read the previous success stored in a failure, so inspect {@link cause} or {@link error} when stale data and a current success must be distinguished.- {@link matchWithWaiting} handles waiting before variant-specific branches, while {@link match} and {@link matchWithError} expose the underlying variant first.
|
||||
|
||||
## `effect/unstable/reactivity/Atom`
|
||||
Reactive state primitives for values evaluated by an {@link AtomRegistry}.An {@link Atom} describes how to read a value. The registry is the runtimeowner: it evaluates reads, caches results, records dependency edges, runseffects and streams with the configured runtime services, and disposes nodeswhen they are no longer observed.**Mental model**Regular `get(atom)` calls inside a read function create dependencies. When adependency changes or refreshes, dependent atoms are invalidated and re-readon demand. One-shot reads such as `get.once(atom)` read the current valuewithout creating an edge. The same atom can hold different cached values indifferent registries, so stable atom identity matters; use {@link family} foratoms parameterized by input values.**Common tasks**Use {@link readable} or {@link writable} for synchronous state, {@link make}for effects and streams exposed as `AsyncResult`, {@link fn} forcommand-style effects, {@link pull} for pull-based streams, and{@link subscriptionRef} to expose a `SubscriptionRef`. Use {@link kvs},{@link searchParam}, and {@link serializable} when atom values needpersistence, URL state, or server-to-client hydration. Read and mutate atomsfrom Effect code with {@link get}, {@link set}, {@link update},{@link refresh}, and {@link mount}; convert observed values to streams with{@link toStream} or {@link toStreamResult}.**Gotchas**Cache lifetime belongs to the registry, not the atom object. Unobservednon-`keepAlive` atoms can be disposed immediately or after their idle TTL,which also releases finalizers and may rebuild effects, streams, and derivedstate on the next read. Runtime-backed atoms refresh only through theirregistered refresh hooks or explicit `Reactivity` invalidations; reading an`Effect` by itself does not keep external data subscribed.
|
||||
|
||||
## `effect/unstable/reactivity/AtomHttpApi`
|
||||
The `AtomHttpApi` module adapts typed `HttpApi` clients to the unstable atomreactivity runtime. Use it to define a `Context.Service` whose generated HTTPAPI client is available directly and whose endpoints can also be invoked asatoms: `query` creates an atom of `AsyncResult` for reads, while `mutation`creates an `AtomResultFn` for writes.It is intended for applications that want server state to participate in atomcaching, invalidation, and hydration. Queries can be associated with`reactivityKeys` so they refresh when those keys are invalidated, mutations caninvalidate the same keys after the request succeeds, and `timeToLive` controlswhether idle query atoms expire, stay alive for a duration, or are kept alive.Serialization is schema-based and intentionally limited to decoded values.Mutation atoms are serializable only in `"decoded-only"` mode, while queryatoms are serializable only in `"decoded-only"` mode when a stable`serializationKey` is supplied. Choose serialization keys that uniquelyidentify the endpoint request, keep reactivity keys stable across client andserver registries during hydration, and avoid serializing response modes thatexpose raw `HttpClientResponse` values.The service wraps `HttpApiClient.make`, so the same `HttpApi` definition,schemas, base URL, middleware services, and HTTP client layer must be availablewherever the atom runtime is constructed. Use `transformClient` and`transformResponse` for cross-cutting client behavior, and remember thatschema or low-level HTTP client failures are raised as defects while endpointand middleware failures remain typed errors.
|
||||
## `effect/unstable/reactivity/AtomRef`
|
||||
Mutable reactive references for local, in-memory state.`AtomRef` provides small observable state cells that can be read, updated,focused, and subscribed to without going through an `AtomRegistry`. It issuited to form state, local view models, and collections of item referenceswhere callers need direct mutation methods together with change notifications.**Mental model**An `AtomRef` is a value cell with a stable key and a subscriber list.{@link make} creates a mutable cell, `map` derives read-only views, and `prop`focuses on nested object or array properties while preserving mutationhelpers. {@link collection} stores ordered item refs and emits collectionupdates when items are inserted, removed, or changed through their refs.**Common tasks**- Use {@link make} for standalone local state.- Use `ref.set` or `ref.update` to replace the current value.- Use `ref.map` for derived read-only values.- Use `ref.prop` to update nested object fields or array entries.- Use {@link collection} for ordered lists whose items should remain individually mutable.**Gotchas**Notifications are equality-aware: values that are `Equal.equals` to thecurrent value do not notify subscribers, and mapped or property subscriptionsonly emit when their derived value changes. Directly mutating an object orarray stored in a ref does not notify listeners; use `set`, `update`, or aproperty ref instead. `toArray` returns the current raw item values, not theitem refs.
|
||||
## `effect/unstable/reactivity/AtomRegistry`
|
||||
The `AtomRegistry` module provides the runtime cache for unstable reactivityatoms. A registry owns the node graph for a group of atoms, stores currentvalues, records parent and child dependencies while atoms are read, andcoordinates writes, refreshes, subscriptions, stream conversions, and nodedisposal.Use a separate registry for each UI root, request, test, route boundary, orother lifetime that needs isolated atom state. The same atom object can havedifferent cached values in different registries, while serializable atoms usetheir stable serialization key so preloaded values can hydrate a node beforethe first read.**Mental model**- Reading an atom creates or reuses a registry node, evaluates the atom when its value is missing or stale, and records any nested atom reads as dependencies.- Writing a writable atom updates its node through the atom's write function, invalidates dependent nodes, and notifies listeners after batching settles.- Subscriptions and scoped {@link mount} calls keep nodes alive. When the last listener and dependent child disappear, non-`keepAlive` atoms can be removed immediately or after their idle TTL.- Effects and streams started by atoms run with the registry scheduler and are finalized when the node is rebuilt, removed, reset, or disposed.- Disposing a registry clears its nodes and makes later atom access an error.**Common tasks**- Create a registry directly with {@link make}, or provide one to Effect programs with {@link layer} or {@link layerOptions}.- Read and write atom state with the registry instance methods `get`, `set`, `modify`, `update`, and `refresh`.- Keep an atom alive for an Effect scope with {@link mount}; subscribe with `subscribe` when integrating with callback-based UI code.- Convert observed atom values with {@link toStream} and {@link toStreamResult}, or wait for an `AsyncResult` atom with {@link getResult}.- Preload encoded serializable state with `setSerializable` before the matching atom is read.**Quickstart****Example** (Isolated atom state)```tsimport { Atom, AtomRegistry } from "effect/unstable/reactivity"const count = Atom.make(0)const doubled = Atom.make((get) => get(count) * 2)const registry = AtomRegistry.make()registry.set(count, 21)registry.get(doubled)// 42```**Gotchas**- Atom identity matters. Creating a new atom object creates a different node unless the atom is serializable and uses the same serialization key.- Unobserved atoms without `keepAlive` can be removed, so later reads may rebuild derived values, restart effects or streams, and rerun finalizers.- `subscribe` and the instance `mount` method return release callbacks; call them when the external consumer is done. The exported {@link mount} helper ties that release to an Effect scope.- `reset` and `dispose` remove every node in the registry. Use a new registry when a whole lifetime should start from empty state.**See also**
|
||||
## `effect/unstable/reactivity/AtomRpc`
|
||||
The `AtomRpc` module connects typed RPC clients to the atom reactivity
|
||||
runtime. It builds a `Context.Service` that exposes the flattened
|
||||
`RpcClient`, an `AtomRuntime`, mutation helpers, and query helpers for every
|
||||
RPC in an `RpcGroup`.
|
||||
|
||||
Use it when remote read models should be represented as atoms, mutations
|
||||
should refresh affected reads through `Reactivity` keys, or non-streaming
|
||||
query results need serialization metadata for hydration. The RPC `protocol`
|
||||
layer supplies the transport, and may be static or derived from the current
|
||||
atom context, so request headers, transport dependencies, and client
|
||||
middleware remain part of the normal Effect environment.
|
||||
|
||||
Non-streaming queries produce atoms of `AsyncResult` values. Supplying a
|
||||
`serializationKey` marks those query atoms as serializable using codecs
|
||||
derived from the RPC success schema and the combined RPC, middleware, and
|
||||
client error schemas; choose stable, unique keys when dehydrating. Streaming
|
||||
RPCs produce writable pull atoms instead, so callers advance the stream by
|
||||
writing to the atom and should not expect serialization metadata. Query family
|
||||
caching includes the payload, normalized headers, reactivity keys, TTL, and
|
||||
serialization key, so use stable values for those inputs when atom identity
|
||||
matters.
|
||||
## `effect/unstable/reactivity/Hydration`
|
||||
Moves serializable reactivity state between `AtomRegistry` instances.
|
||||
|
||||
The `Hydration` module snapshots atoms marked with `Atom.serializable` and
|
||||
loads those encoded values into another registry before the atoms are read. It
|
||||
is useful for server rendering, browser bootstrapping, route transitions, and
|
||||
other handoffs where a new registry should start from values computed by a
|
||||
previous one.
|
||||
|
||||
**Mental model**
|
||||
|
||||
`dehydrate` walks the source registry and produces `DehydratedAtomValue`
|
||||
records keyed by each atom's serialization key. `hydrate` stores those encoded
|
||||
values in the target registry so the matching atom can decode them with its own
|
||||
serializable codec. Atom identity is not transferred; only the stable key,
|
||||
encoded value, dehydration timestamp, and optional async handoff are.
|
||||
|
||||
**Common tasks**
|
||||
|
||||
Use `dehydrate` before rendering or crossing a route boundary, then call
|
||||
`hydrate` on the registry that will serve the next read. Use `toValues` when
|
||||
code accepts generic `DehydratedAtom` entries but needs the concrete record
|
||||
shape, and use `encodeInitialAs` to choose how `AsyncResult.Initial` values
|
||||
should appear in the snapshot.
|
||||
|
||||
**Gotchas**
|
||||
|
||||
Only serializable atoms are included, and the target registry must contain
|
||||
atoms with matching stable keys and compatible codecs. The optional
|
||||
`resultPromise` used for `AsyncResult.Initial` is a live JavaScript promise; it
|
||||
can be handed to another registry in the same runtime, but it cannot be sent
|
||||
through JSON or across process boundaries.
|
||||
## `effect/unstable/reactivity/Reactivity`
|
||||
The `Reactivity` module provides process-local invalidation for connectingwrites to dependent reads. It does not cache values itself; it tracks keys,registers query handlers, and reruns effects when matching keys areinvalidated so queues, streams, UI subscriptions, and read models can stayfresh after successful writes.**Mental model**A query registers one or more keys, runs once immediately, and publishes eachresult to a queue or stream. Invalidating any registered key schedules thequery to rerun. Mutations wrap an effect and invalidate keys only after itsucceeds. Keys can be a flat array, or a record whose property names act asbroad namespaces and whose ids address individual records.**Common tasks**- Provide the default in-memory service with {@link layer}.- Use {@link query} when callers need a queue of rerun results.- Use {@link stream} when downstream code should consume reruns as a stream.- Wrap writes with {@link mutation}, or call {@link invalidate} directly when invalidation is already part of the workflow.- Use the {@link Reactivity} service directly when many invalidations should be coalesced until a batch exits.**Gotchas**- The default layer is process-local; it does not coordinate invalidations across processes or cluster runners.- Non-primitive keys are matched by their `Hash.hash` value, so prefer stable key values over mutable objects.- If a query fails, its queue or stream fails with the same cause.- Invalidations that arrive while a query is already running coalesce into one follow-up run.**See also**- {@link query}, {@link stream}, {@link mutation}, and {@link invalidate}- {@link layer} and {@link Reactivity}
|
||||
|
||||
## `@effect/atom-react/Hooks`
|
||||
React hooks for reading, writing, mounting, refreshing, and subscribing toEffect atoms from the registry provided by `RegistryContext`.**Common tasks**- Read atom values in React components with {@link useAtomValue}- Read and write writable atoms with {@link useAtom}- Write without subscribing to the value with {@link useAtomSet}- Seed registry-local initial values with {@link useAtomInitialValues}- Integrate `AsyncResult` atoms with React Suspense through {@link useAtomSuspense}- Subscribe to atom changes or derive stable `AtomRef` properties**Gotchas**- Hooks use the current `RegistryContext`, so each provider has an independent atom registry- Writable atoms are mounted by the write-oriented hooks before updates are sent- Suspense support throws promises for initial or waiting `AsyncResult` values and defects for failures unless `includeFailure` is enabled
|
||||
|
||||
## `@effect/atom-react/ReactHydration`
|
||||
React helpers for hydrating Atom registry state that was serialized on theserver or produced by a previous render. This module exposes{@link HydrationBoundary}, a client component that receives dehydrated Atomvalues and applies them to the nearest {@link RegistryContext} beforerendering children when it is safe to do so.**Common use cases**- Reusing Atom values that were collected during server rendering- Restoring client-side Atom state around a routed subtree- Keeping Atom-backed React trees consistent during hydration and transitions**React gotchas**- New Atom values can be hydrated during render so children see them immediately.- Existing Atom values are queued until after commit to avoid updating the current UI with transition data that might later be discarded.- Hydration is idempotent, so repeated or older dehydrated values are safe to pass through the boundary.
|
||||
## `@effect/atom-react/ReactHydration`
|
||||
The `RegistryContext` module provides the React context used by Effect Atom
|
||||
hooks to share an `AtomRegistry` across a component tree. The registry owns
|
||||
atom state, scheduling, and idle cleanup, so components that read or write
|
||||
atoms can coordinate through the same runtime instead of each creating an
|
||||
isolated registry.
|
||||
|
||||
**Common tasks**
|
||||
|
||||
- Use {@link RegistryProvider} to scope atom state to a React subtree
|
||||
- Seed atoms for tests, stories, or server-provided data with `initialValues`
|
||||
- Override scheduling or idle timing for custom rendering environments
|
||||
- Read {@link RegistryContext} when integrating lower-level atom APIs
|
||||
|
||||
**Gotchas**
|
||||
|
||||
- This is a client module because it depends on React runtime hooks and the
|
||||
scheduler package
|
||||
- A provider keeps the registry stable across renders and disposes it shortly
|
||||
after unmount, allowing React remounts to reuse the same registry
|
||||
- Overriding `scheduleTask` changes when atom work is flushed, so it should
|
||||
return a cancellation function compatible with React unmounts
|
||||
## `@effect/atom-react/ScopedAtom`
|
||||
The `ScopedAtom` module provides a small React integration for creating atominstances that are scoped to a component subtree. A scoped atom bundles aReact provider, context, and `use` accessor so each mounted provider owns itsown atom instance instead of sharing a single module-level atom.Use `ScopedAtom` when an atom needs to be isolated per feature, route,component instance, test harness, or provider input. The provider may receivean initial `value` that is passed to the atom factory, making it useful forstate that should be seeded from React props while still being consumed by theatom hooks in descendants.**Gotchas**- `use` must be called under the matching provider or it throws.- The provider creates the atom once for its lifetime; changing the provider `value` prop after mount does not recreate the atom.
|
||||
|
||||
|
||||
---
|
||||
Further more I added this exact prompt to `./ts/`
|
||||
288
ts/bun.lock
288
ts/bun.lock
|
|
@ -5,12 +5,12 @@
|
|||
"": {
|
||||
"name": "trustgraph-ts",
|
||||
"dependencies": {
|
||||
"effect": "4.0.0-beta.65",
|
||||
"effect": "4.0.0-beta.74",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-bun": "4.0.0-beta.65",
|
||||
"@effect/tsgo": "0.6.0",
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"@effect/tsgo": "0.13.0",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/bun": "^1.3.13",
|
||||
"@types/node": "^25.7.0",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260511.1",
|
||||
|
|
@ -27,12 +27,19 @@
|
|||
"name": "@trustgraph/base",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"effect": "4.0.0-beta.65",
|
||||
"@effect/ai-anthropic": "4.0.0-beta.74",
|
||||
"@effect/ai-openai": "4.0.0-beta.74",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.74",
|
||||
"@effect/atom-react": "4.0.0-beta.74",
|
||||
"@effect/openapi-generator": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-browser": "4.0.0-beta.74",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"nats": "^2.29.0",
|
||||
"prom-client": "^15.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6",
|
||||
|
|
@ -45,14 +52,21 @@
|
|||
"tg": "dist/index.js",
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/ai-anthropic": "4.0.0-beta.74",
|
||||
"@effect/ai-openai": "4.0.0-beta.74",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.74",
|
||||
"@effect/atom-react": "4.0.0-beta.74",
|
||||
"@effect/openapi-generator": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-browser": "4.0.0-beta.74",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"@trustgraph/base": "workspace:*",
|
||||
"@trustgraph/client": "workspace:*",
|
||||
"commander": "^13.1.0",
|
||||
"effect": "4.0.0-beta.65",
|
||||
"ws": "^8.18.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/ws": "^8.5.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6",
|
||||
|
|
@ -62,10 +76,10 @@
|
|||
"name": "@trustgraph/client",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"effect": "4.0.0-beta.65",
|
||||
"effect": "4.0.0-beta.74",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"happy-dom": "^20.0.0",
|
||||
|
|
@ -84,13 +98,27 @@
|
|||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"@effect/platform-bun": "4.0.0-beta.65",
|
||||
"@effect/ai-anthropic": "4.0.0-beta.74",
|
||||
"@effect/ai-openai": "4.0.0-beta.74",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.74",
|
||||
"@effect/atom-react": "4.0.0-beta.74",
|
||||
"@effect/openapi-generator": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-browser": "4.0.0-beta.74",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
"@effect/sql-pg": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-node": "4.0.0-beta.74",
|
||||
"@effect/tsgo": "0.13.0",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@fastify/websocket": "^11.0.0",
|
||||
"@mistralai/mistralai": "^1.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"@qdrant/js-client-rest": "^1.13.0",
|
||||
"@trustgraph/base": "workspace:*",
|
||||
"effect": "4.0.0-beta.65",
|
||||
"effect": "4.0.0-beta.74",
|
||||
"falkordb": "^5.0.0",
|
||||
"fastify": "^5.2.0",
|
||||
"ollama": "^0.6.3",
|
||||
|
|
@ -98,7 +126,7 @@
|
|||
"pdfjs-dist": "^5.6.205",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6",
|
||||
|
|
@ -108,14 +136,28 @@
|
|||
"name": "@trustgraph/mcp",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@effect/ai-anthropic": "4.0.0-beta.74",
|
||||
"@effect/ai-openai": "4.0.0-beta.74",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.74",
|
||||
"@effect/atom-react": "4.0.0-beta.74",
|
||||
"@effect/openapi-generator": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-browser": "4.0.0-beta.74",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
"@effect/sql-pg": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-node": "4.0.0-beta.74",
|
||||
"@effect/tsgo": "0.13.0",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@modelcontextprotocol/sdk": "^1.8.0",
|
||||
"@trustgraph/base": "workspace:*",
|
||||
"@trustgraph/client": "workspace:*",
|
||||
"effect": "4.0.0-beta.65",
|
||||
"zod": "^3.23.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6",
|
||||
|
|
@ -125,6 +167,21 @@
|
|||
"name": "@trustgraph/workbench",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@effect/ai-anthropic": "4.0.0-beta.74",
|
||||
"@effect/ai-openai": "4.0.0-beta.74",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.74",
|
||||
"@effect/atom-react": "4.0.0-beta.74",
|
||||
"@effect/openapi-generator": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-browser": "4.0.0-beta.74",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
"@effect/sql-pg": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-node": "4.0.0-beta.74",
|
||||
"@effect/tsgo": "0.13.0",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@tanstack/react-query": "^5.75.0",
|
||||
"@trustgraph/client": "workspace:*",
|
||||
"clsx": "^2.1.0",
|
||||
|
|
@ -138,7 +195,8 @@
|
|||
"zustand": "^5.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
|
|
@ -190,27 +248,49 @@
|
|||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.65", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.65" }, "peerDependencies": { "effect": "^4.0.0-beta.65" } }, "sha512-6BgEjVDibeOgGj0pvYRx+mBLSWVBPlvuqa6o4kuyrRIdgn92nbKrbglEiIRe4sn7yYmeKei4N/kd7fRzN3rEwA=="],
|
||||
"@effect/ai-anthropic": ["@effect/ai-anthropic@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-aGPg//nWMj255E0H9z3HEuqgWSAkutgNwkd44fvn9Es9TKExs3hD0sS8Sa2OJ/G4Otg2MMCiWlZRvzxI9wTpGw=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.65", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.65" } }, "sha512-3rY8F3WLEax6Hj08GI/OvDIH+KqjfxH7RM2bAMfgR75NgRmwDtny1P49PtPkoRjH5dcdtThThtsvE4X9OTZkpQ=="],
|
||||
"@effect/ai-openai": ["@effect/ai-openai@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-cCreNxM2OaFe38DjplTn7f6xi/LqZeEOUymyMa3dTJdi1YUvMg5riq65OlMfhj0CIqWPPFLmURJdIFj2s+gUCA=="],
|
||||
|
||||
"@effect/tsgo": ["@effect/tsgo@0.6.0", "", { "optionalDependencies": { "@effect/tsgo-darwin-arm64": "0.6.0", "@effect/tsgo-darwin-x64": "0.6.0", "@effect/tsgo-linux-arm": "0.6.0", "@effect/tsgo-linux-arm64": "0.6.0", "@effect/tsgo-linux-x64": "0.6.0", "@effect/tsgo-win32-arm64": "0.6.0", "@effect/tsgo-win32-x64": "0.6.0" }, "bin": { "effect-tsgo": "dist/effect-tsgo.js" } }, "sha512-suCUiGQ4Nkuw08kx3HJsS4PDtoc7h9erjTym8D8jdnlERt9RJ8bFfg5TnFwbriyyTZhjZCr7W0WctWyEMayotg=="],
|
||||
"@effect/ai-openrouter": ["@effect/ai-openrouter@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-F1qPX6bA1Kx8ClXz3TamKqTKCdi0VBbRfkv1LJKBwlPhgOiU7Pca6L0ksOQuwQY8E17xS8CsoZxM9Da2pZCYRQ=="],
|
||||
|
||||
"@effect/tsgo-darwin-arm64": ["@effect/tsgo-darwin-arm64@0.6.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mV9GI5wy6pAVssXV8awYCafr/AWhCoO6/xUJD2yv4MWEycP1/k9ZLR2mPvPMXqt51Zs9rkRGaCANkCKcRoxs3w=="],
|
||||
"@effect/atom-react": ["@effect/atom-react@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74", "react": "^19.2.4", "scheduler": "*" } }, "sha512-ZuBSclpU6z4w21n7nfaYn/ou10rAnSmuT9zQPUDz8d2gm617c9I5rlF5TfqZNLhRslLyx9NzvObz+avNToxsIA=="],
|
||||
|
||||
"@effect/tsgo-darwin-x64": ["@effect/tsgo-darwin-x64@0.6.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-B/7V34BZqMqCYbP+TBz6ucTvc49gKhkoUnebELxK6imBymBS7fRkgsFd+trlst62bCblH774T2GCCx7gkyKmZw=="],
|
||||
"@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.74", "", { "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.74", "effect": "^4.0.0-beta.74" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-gVXqLin2yHvDSav7dkruVQvjVtQjPx7YQ1hBGyuQH5KroZr70qxTlIAA4+BvWdOVWZ2UiPYetGN/0Y+aDWCmaA=="],
|
||||
|
||||
"@effect/tsgo-linux-arm": ["@effect/tsgo-linux-arm@0.6.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ovehHAdBbuxQi9eOon012JmcaVkxufWNSVJTpknlD+cC4jawiWDGIr51abkvH72GnTLgyfjjq0e/ibeJ5n9I0Q=="],
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.74", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.74" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-flpyqLPyr+THSe6ZCGRZl6hi+FqxbIXNSkslKGiRJAjbPabam9mSp7R3aC8biIMt6xE4Fd0LNfo4p2GplUkm2Q=="],
|
||||
|
||||
"@effect/tsgo-linux-arm64": ["@effect/tsgo-linux-arm64@0.6.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-xClS3A78/uM18ndxoGJDTxIe5AEbm5kuZ0ERJ/9wuQNRqITYW6ug93QMqsyDkp0VmFXxdfQbt81y4mpeSN1voQ=="],
|
||||
"@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.74", "", { "dependencies": { "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-kHKT+TqWe/XvWm7LiOLkDsC8U5PL8xnBVReNiOrMYvo6zcItp74r6ZPlEJEpXMzh6pQuCcara46IkISaMGEeNA=="],
|
||||
|
||||
"@effect/tsgo-linux-x64": ["@effect/tsgo-linux-x64@0.6.0", "", { "os": "linux", "cpu": "x64" }, "sha512-un+yA+AQShNSKxYJibhgY90c9bNPkjOZr0ecsmDB+S76STKQHOag/KW8G2EwpRg/eqWqn5GV04VEhP6Cq4QFMQ=="],
|
||||
"@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.74", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.74" }, "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-HPozsTKom3v8uGITYX+blua0wVSk/I7ak802FoX13t4zWhHfprT8I/A8VBO4SxmcDYb/izABtHBXBgEmp612MA=="],
|
||||
|
||||
"@effect/tsgo-win32-arm64": ["@effect/tsgo-win32-arm64@0.6.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-5Ymu5FzNHA/YpDJkE67zD/KDPKm81e3BdlsGJ50ZOQI0YDnFlUWtENOjV3ScfW8g17pM3COnBJKJYYhMuek6wA=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.74", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.74", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74", "ioredis": "^5.7.0" } }, "sha512-/W16mKqxvhWINLjufzc0log1sl57exXQfwd+em398/zKCbmU3S7snXTDMN6w0ju2TtgK35qrsoGBXEochij6Sg=="],
|
||||
|
||||
"@effect/tsgo-win32-x64": ["@effect/tsgo-win32-x64@0.6.0", "", { "os": "win32", "cpu": "x64" }, "sha512-vD02OcS3zzRY7/vmGZiJaAttXymicpSY19FdSKHgvT9RvRlffbAFj90DSP6lFIrpZPMB2r18tmI2QrE0ZPzWWw=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.74", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-C6C2hXixNcZXLaFF2u7B/FtOsqpdY7luaPuiGFBJza0P7EnYDkwaT3kB6lv7l/qctmkADc24qOsSCWIKRbC4jg=="],
|
||||
|
||||
"@effect/vitest": ["@effect/vitest@4.0.0-beta.65", "", { "peerDependencies": { "effect": "^4.0.0-beta.65", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-dJdZlQhB+AtMlSgrJ0QiprRhDnGVTcKmPe699+f5qkQjCKauogaAKekWV3xEM1envAMntwqeFUD4QHVnE+cFPg=="],
|
||||
"@effect/sql-pg": ["@effect/sql-pg@4.0.0-beta.74", "", { "dependencies": { "pg": "^8.20.0", "pg-connection-string": "2.12.0", "pg-cursor": "^2.19.0", "pg-pool": "^3.13.0", "pg-types": "^4.1.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-F1keL+vtp+d2HG7LQQ/WhJx4ezCrNy29T0HLbGPPvTeJTIi5/8fe2frh4WF55p6K3oi8jhkfe3fSH2Lcx0RjRw=="],
|
||||
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-RVMRVY7NhSoAp9cAAyy4TT6dt6NNZjOpWeqticoho9HNBukxQSUcu/kjcz4Iq9eoQfXadmepu8kZqtdZULM/fg=="],
|
||||
|
||||
"@effect/sql-sqlite-node": ["@effect/sql-sqlite-node@4.0.0-beta.74", "", { "dependencies": { "better-sqlite3": "^12.9.0" }, "peerDependencies": { "effect": "^4.0.0-beta.74" } }, "sha512-bgKblNG4ky4BjaiHPuyfC9FKF9zygWpCZ8L7d9T4RIq7TKU5o3kmdihL7awpGt5wRAqg1d8nvoM/NJyJNf7bXw=="],
|
||||
|
||||
"@effect/tsgo": ["@effect/tsgo@0.13.0", "", { "optionalDependencies": { "@effect/tsgo-darwin-arm64": "0.13.0", "@effect/tsgo-darwin-x64": "0.13.0", "@effect/tsgo-linux-arm": "0.13.0", "@effect/tsgo-linux-arm64": "0.13.0", "@effect/tsgo-linux-x64": "0.13.0", "@effect/tsgo-win32-arm64": "0.13.0", "@effect/tsgo-win32-x64": "0.13.0" }, "bin": { "effect-tsgo": "dist/effect-tsgo.js" } }, "sha512-oOBoz8iFVhrBpvr0R6vLE01ydPLbyRu1eN8eU97E17T+LUDMgcgm4AqdTu1pUaCIhRAWIZcmRxD+Jvd38940yQ=="],
|
||||
|
||||
"@effect/tsgo-darwin-arm64": ["@effect/tsgo-darwin-arm64@0.13.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DGpDMwmE+fVx+/w7DurQJz1iGPiIp4kUoIZ/iUb0zBdPuhycH5bqm8x4lMEYKdO9D6k4VIDY3zeSoPAs3yqGfQ=="],
|
||||
|
||||
"@effect/tsgo-darwin-x64": ["@effect/tsgo-darwin-x64@0.13.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-KfoQu1SeKUdoegK1M3B9ZqxNtJqZM+Odqlyg48G6K7CQ4tBPlpOwAschVr4h4IBANMEQNFuQADPGy0gSSZwQvQ=="],
|
||||
|
||||
"@effect/tsgo-linux-arm": ["@effect/tsgo-linux-arm@0.13.0", "", { "os": "linux", "cpu": "arm" }, "sha512-txp7VxQIYXBpJo66G77JKoki6ouEJk/HokEIrp+mBHN6BNx8O5h6kjUtLCeFbIiaKuX5UrqHN7Yvx/vj4qLkSQ=="],
|
||||
|
||||
"@effect/tsgo-linux-arm64": ["@effect/tsgo-linux-arm64@0.13.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-9KadPsq9b5sQqh6pRRTr0mNcRVM8Jk9l3Y9SgsmwnfEjxHFoo/NThDQNgfctdBawOCwImQdm/YtC5oj7AwCFrA=="],
|
||||
|
||||
"@effect/tsgo-linux-x64": ["@effect/tsgo-linux-x64@0.13.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Osp1yIFPmibHvLTkVHk86RODr/hP44yXQU8NqyGauFDm93FdFEOn8v96UkcXziST76v0wReKvB/Ng39g7lafSw=="],
|
||||
|
||||
"@effect/tsgo-win32-arm64": ["@effect/tsgo-win32-arm64@0.13.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-c2nUvQyW/iAqYKjn/BrFKt2BxWQGlW8fGdq7MRTLs6cOrwwa4XnqeVsomyqIyeBcXB5s3HilRolF4xnFvJpTKw=="],
|
||||
|
||||
"@effect/tsgo-win32-x64": ["@effect/tsgo-win32-x64@0.13.0", "", { "os": "win32", "cpu": "x64" }, "sha512-c+tQDZ+oOGj1PVgoTa4SswoqbF5t9fMHse0cKh3QMJPxh1nkJ+E7cqAyFcpL8vy2apKZQUB1UbGEAXCQRm1u1Q=="],
|
||||
|
||||
"@effect/vitest": ["@effect/vitest@4.0.0-beta.74", "", { "peerDependencies": { "effect": "^4.0.0-beta.74", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-+SFcjtbboJdWA+cP7JFW7Gp1PL7vj7uqvBnh9xY7JFU8VMsrHN5mblsqE7l7+ZFGpJvBGLgjGrhfe8QZ7f5vgg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
||||
|
||||
|
|
@ -284,6 +364,8 @@
|
|||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "4.12.10" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
|
||||
|
||||
"@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5", "@jridgewell/trace-mapping": "0.3.31" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "0.3.13", "@jridgewell/trace-mapping": "0.3.31" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
|
@ -298,17 +380,17 @@
|
|||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "1.19.12", "ajv": "8.18.0", "ajv-formats": "3.0.1", "content-type": "1.0.5", "cors": "2.8.6", "cross-spawn": "7.0.6", "eventsource": "3.0.7", "eventsource-parser": "3.0.6", "express": "5.2.1", "express-rate-limit": "8.3.2", "hono": "4.12.10", "jose": "6.2.2", "json-schema-typed": "8.0.2", "pkce-challenge": "5.0.1", "raw-body": "3.0.2", "zod-to-json-schema": "3.25.2" }, "peerDependencies": { "zod": "3.25.76" } }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="],
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="],
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="],
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="],
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
|
||||
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.100", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.100", "@napi-rs/canvas-darwin-arm64": "0.1.100", "@napi-rs/canvas-darwin-x64": "0.1.100", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", "@napi-rs/canvas-linux-arm64-musl": "0.1.100", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-musl": "0.1.100", "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", "@napi-rs/canvas-win32-x64-msvc": "0.1.100" } }, "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA=="],
|
||||
|
||||
|
|
@ -336,12 +418,16 @@
|
|||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
||||
|
||||
"@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA=="],
|
||||
|
||||
"@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="],
|
||||
|
||||
"@qdrant/js-client-rest": ["@qdrant/js-client-rest@1.17.0", "", { "dependencies": { "@qdrant/openapi-typescript-fetch": "1.2.6", "undici": "6.24.1" }, "peerDependencies": { "typescript": "5.9.3" } }, "sha512-aZFQeirWVqWAa1a8vJ957LMzcXkFHGbsoRhzc8AkGfg6V0jtK8PlG8/eyyc2xhYsR961FDDx1Tx6nyE0K7lS+A=="],
|
||||
|
||||
"@qdrant/openapi-typescript-fetch": ["@qdrant/openapi-typescript-fetch@1.2.6", "", {}, "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA=="],
|
||||
|
|
@ -558,16 +644,26 @@
|
|||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.15", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ=="],
|
||||
|
||||
"better-sqlite3": ["better-sqlite3@12.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ=="],
|
||||
|
||||
"bezier-js": ["bezier-js@6.1.4", "", {}, "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg=="],
|
||||
|
||||
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
|
||||
|
||||
"bintrees": ["bintrees@1.0.2", "", {}, "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw=="],
|
||||
|
||||
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "3.1.2", "content-type": "1.0.5", "debug": "4.4.3", "http-errors": "2.0.1", "iconv-lite": "0.7.2", "on-finished": "2.4.1", "qs": "6.15.0", "raw-body": "3.0.2", "type-is": "2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "2.10.15", "caniuse-lite": "1.0.30001785", "electron-to-chromium": "1.5.331", "node-releases": "2.0.37", "update-browserslist-db": "1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
|
@ -592,6 +688,8 @@
|
|||
|
||||
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
|
||||
|
||||
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
||||
|
|
@ -660,8 +758,14 @@
|
|||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "2.0.2" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
|
||||
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
|
@ -676,7 +780,7 @@
|
|||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.65", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw=="],
|
||||
"effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.331", "", {}, "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q=="],
|
||||
|
||||
|
|
@ -716,6 +820,8 @@
|
|||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "2.0.0", "body-parser": "2.2.2", "content-disposition": "1.0.1", "content-type": "1.0.5", "cookie": "0.7.2", "cookie-signature": "1.2.2", "debug": "4.4.3", "depd": "2.0.0", "encodeurl": "2.0.0", "escape-html": "1.0.3", "etag": "1.8.1", "finalhandler": "2.1.1", "fresh": "2.0.0", "http-errors": "2.0.1", "merge-descriptors": "2.0.0", "mime-types": "3.0.2", "on-finished": "2.4.1", "once": "1.4.0", "parseurl": "1.3.3", "proxy-addr": "2.0.7", "qs": "6.15.0", "range-parser": "1.2.1", "router": "2.2.0", "send": "1.2.1", "serve-static": "2.2.1", "statuses": "2.0.2", "type-is": "2.0.1", "vary": "1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
|
@ -746,6 +852,8 @@
|
|||
|
||||
"fdir": ["fdir@6.5.0", "", { "optionalDependencies": { "picomatch": "4.0.4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "4.4.3", "encodeurl": "2.0.0", "escape-html": "1.0.3", "on-finished": "2.4.1", "parseurl": "1.3.3", "statuses": "2.0.2" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-querystring": "1.1.2", "safe-regex2": "5.1.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="],
|
||||
|
|
@ -766,6 +874,8 @@
|
|||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
|
@ -780,6 +890,8 @@
|
|||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="],
|
||||
|
||||
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
|
@ -806,16 +918,20 @@
|
|||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": "2.1.2" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"index-array-by": ["index-array-by@1.4.2", "", {}, "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
||||
"ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"ioredis": ["ioredis@5.11.0", "", { "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "redis-parser": "3.0.0", "standard-as-callback": "2.1.0" } }, "sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg=="],
|
||||
|
||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
||||
|
|
@ -958,26 +1074,38 @@
|
|||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"msgpackr": ["msgpackr@1.11.12", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg=="],
|
||||
"msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
|
||||
|
||||
"msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="],
|
||||
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
|
||||
|
||||
"multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
|
||||
|
||||
"nats": ["nats@2.29.3", "", { "dependencies": { "nkeys.js": "1.1.0" } }, "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"nkeys.js": ["nkeys.js@1.1.0", "", { "dependencies": { "tweetnacl": "1.0.3" } }, "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg=="],
|
||||
|
||||
"node-abi": ["node-abi@3.92.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "5.0.0" } }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
|
@ -990,6 +1118,8 @@
|
|||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"obuf": ["obuf@1.1.2", "", {}, "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="],
|
||||
|
|
@ -1018,6 +1148,26 @@
|
|||
|
||||
"pdfjs-dist": ["pdfjs-dist@5.7.284", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.100" } }, "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw=="],
|
||||
|
||||
"pg": ["pg@8.21.0", "", { "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.14.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA=="],
|
||||
|
||||
"pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="],
|
||||
|
||||
"pg-connection-string": ["pg-connection-string@2.12.0", "", {}, "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ=="],
|
||||
|
||||
"pg-cursor": ["pg-cursor@2.20.0", "", { "peerDependencies": { "pg": "^8" } }, "sha512-HP/EbUafheaUOs7DxlG6tda/rhmsX2hCTJJJ+gCnhljGyNEs6pBHddbNuomlW3DqEhP3zYD+GqBWkYnJPIZ4tA=="],
|
||||
|
||||
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
|
||||
|
||||
"pg-numeric": ["pg-numeric@1.0.2", "", {}, "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw=="],
|
||||
|
||||
"pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="],
|
||||
|
||||
"pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="],
|
||||
|
||||
"pg-types": ["pg-types@4.1.0", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg=="],
|
||||
|
||||
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
|
@ -1030,10 +1180,26 @@
|
|||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="],
|
||||
|
||||
"postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw=="],
|
||||
|
||||
"postgres-date": ["postgres-date@2.1.0", "", {}, "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA=="],
|
||||
|
||||
"postgres-interval": ["postgres-interval@3.0.0", "", {}, "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw=="],
|
||||
|
||||
"postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="],
|
||||
|
||||
"preact": ["preact@10.29.1", "", {}, "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg=="],
|
||||
|
||||
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"prom-client": ["prom-client@15.1.3", "", { "dependencies": { "@opentelemetry/api": "1.9.1", "tdigest": "0.1.2" } }, "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g=="],
|
||||
|
|
@ -1044,6 +1210,8 @@
|
|||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||
|
||||
"pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="],
|
||||
|
||||
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
||||
|
|
@ -1054,6 +1222,8 @@
|
|||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.1", "iconv-lite": "0.7.2", "unpipe": "1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "0.27.0" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
|
@ -1074,6 +1244,10 @@
|
|||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
|
||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
||||
|
||||
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "4.0.4", "mdast-util-from-markdown": "2.0.3", "micromark-util-types": "2.0.2", "unified": "11.0.5" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "3.0.4", "@types/mdast": "4.0.4", "mdast-util-to-hast": "13.2.1", "unified": "11.0.5", "vfile": "6.0.3" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
|
|
@ -1128,6 +1302,10 @@
|
|||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
|
||||
|
||||
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
|
@ -1138,6 +1316,8 @@
|
|||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
|
||||
|
|
@ -1148,6 +1328,8 @@
|
|||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "2.1.0", "character-entities-legacy": "3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
|
@ -1158,6 +1340,10 @@
|
|||
|
||||
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||
|
||||
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
|
||||
|
||||
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||
|
||||
"tdigest": ["tdigest@0.1.2", "", { "dependencies": { "bintrees": "1.0.2" } }, "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA=="],
|
||||
|
||||
"thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="],
|
||||
|
|
@ -1188,6 +1374,8 @@
|
|||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "0.27.7", "get-tsconfig": "4.13.7" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||
|
||||
"turbo": ["turbo@2.9.4", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.4", "@turbo/darwin-arm64": "2.9.4", "@turbo/linux-64": "2.9.4", "@turbo/linux-arm64": "2.9.4", "@turbo/windows-64": "2.9.4", "@turbo/windows-arm64": "2.9.4" }, "bin": { "turbo": "bin/turbo" } }, "sha512-wZ/kMcZCuK5oEp7sXSSo/5fzKjP9I2EhoiarZjyCm2Ixk0WxFrC/h0gF3686eHHINoFQOOSWgB/pGfvkR8rkgQ=="],
|
||||
|
||||
"tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="],
|
||||
|
|
@ -1196,7 +1384,7 @@
|
|||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
||||
"undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
|
||||
|
||||
"undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="],
|
||||
|
||||
|
|
@ -1218,7 +1406,7 @@
|
|||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["uuid@13.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="],
|
||||
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
|
|
@ -1248,6 +1436,8 @@
|
|||
|
||||
"ws": ["ws@8.20.0", "", {}, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
|
@ -1266,6 +1456,8 @@
|
|||
|
||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@qdrant/js-client-rest/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
||||
|
||||
"@trustgraph/base/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
|
||||
|
||||
"@trustgraph/client/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
|
||||
|
|
@ -1284,6 +1476,8 @@
|
|||
|
||||
"happy-dom/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
|
||||
|
||||
"ioredis/cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="],
|
||||
|
||||
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
|
||||
|
||||
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
|
@ -1292,8 +1486,16 @@
|
|||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"pg/pg-connection-string": ["pg-connection-string@2.13.0", "", {}, "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="],
|
||||
|
||||
"pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
"vite/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
|
||||
|
||||
"vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
|
@ -1318,6 +1520,14 @@
|
|||
|
||||
"openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||
|
||||
"pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
|
||||
|
||||
"pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
|
||||
|
||||
"pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
||||
|
||||
"vite/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"lint": "bunx --bun turbo lint",
|
||||
"test": "bunx --bun turbo test",
|
||||
"check:tsgo": "tsgo -b tsconfig.json",
|
||||
"workbench:qa": "bun --cwd packages/workbench run qa:browser",
|
||||
"prepare": "effect-tsgo patch",
|
||||
"clean": "turbo clean",
|
||||
"gateway": "bun scripts/run-gateway.ts",
|
||||
|
|
@ -41,9 +42,9 @@
|
|||
"llm:mistral": "bun scripts/run-llm-mistral.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-bun": "4.0.0-beta.65",
|
||||
"@effect/tsgo": "0.6.0",
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"@effect/tsgo": "0.13.0",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/bun": "^1.3.13",
|
||||
"@types/node": "^25.7.0",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260511.1",
|
||||
|
|
@ -52,11 +53,12 @@
|
|||
"pdf-lib": "^1.17.1",
|
||||
"tsx": "^4.21.0",
|
||||
"turbo": "^2.5.0",
|
||||
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect": "4.0.0-beta.65"
|
||||
"effect": "4.0.0-beta.74"
|
||||
},
|
||||
"packageManager": "bun@1.3.13",
|
||||
"workspaces": [
|
||||
|
|
|
|||
|
|
@ -20,12 +20,19 @@
|
|||
"test": "bunx --bun vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect": "4.0.0-beta.65",
|
||||
"@effect/ai-anthropic": "4.0.0-beta.74",
|
||||
"@effect/ai-openai": "4.0.0-beta.74",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.74",
|
||||
"@effect/atom-react": "4.0.0-beta.74",
|
||||
"@effect/openapi-generator": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-browser": "4.0.0-beta.74",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"nats": "^2.29.0",
|
||||
"prom-client": "^15.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
MessagingRuntimeLive,
|
||||
ProducerSpec,
|
||||
PubSub,
|
||||
runFlowProcessorDefinitionScoped,
|
||||
runProcessorScoped,
|
||||
topics,
|
||||
type BackendConsumer,
|
||||
|
|
@ -212,4 +213,45 @@ describe("Effect-native FlowProcessor runtime", () => {
|
|||
expect(backend.closeCount).toBe(1);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"runs flow specs without a FlowProcessor subclass",
|
||||
Effect.fnUntraced(function* () {
|
||||
const backend = new FlowProcessorBackend();
|
||||
const events: Array<string> = [];
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* runFlowProcessorDefinitionScoped({
|
||||
id: "functional-flow-processor-test",
|
||||
pubsub: backend,
|
||||
specifications: [new ProducerSpec<string>("output")],
|
||||
configHandlers: [
|
||||
(_config, version) => Effect.sync(() => {
|
||||
events.push(`handler:${version}`);
|
||||
}),
|
||||
],
|
||||
}).pipe(
|
||||
Effect.provide(MessagingRuntimeLive),
|
||||
Effect.provide(PubSub.layer(backend)),
|
||||
Effect.provide(fastMessagingConfig),
|
||||
Effect.forkChild,
|
||||
);
|
||||
|
||||
yield* waitFor(() => backend.consumerOptions.length === 1, "config subscription");
|
||||
|
||||
backend.pushConfig(1, { default: { topics: { output: "functional-output" } } });
|
||||
yield* waitFor(() => backend.producers.length === 1, "functional flow producer");
|
||||
yield* waitFor(() => backend.configConsumer.acknowledged.length === 1, "functional config ack");
|
||||
|
||||
yield* Fiber.interrupt(fiber);
|
||||
}),
|
||||
);
|
||||
|
||||
expect(backend.producerOptions.map((options) => options.topic)).toEqual(["functional-output"]);
|
||||
expect(events).toEqual(["handler:1"]);
|
||||
expect(backend.configConsumer.closeCount).toBeGreaterThanOrEqual(1);
|
||||
expect(backend.closeCount).toBe(1);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,9 +7,13 @@
|
|||
* Python reference: trustgraph-base/trustgraph/base/flow_processor.py
|
||||
*/
|
||||
|
||||
import { AsyncProcessor, type ProcessorConfig } from "./async-processor.js";
|
||||
import {
|
||||
AsyncProcessor,
|
||||
type EffectConfigHandler,
|
||||
type ProcessorConfig,
|
||||
} from "./async-processor.js";
|
||||
import type { Spec } from "../spec/types.js";
|
||||
import type { BackendConsumer } from "../backend/types.js";
|
||||
import type { BackendConsumer, PubSubBackend } from "../backend/types.js";
|
||||
import { Flow, type FlowDefinition } from "./flow.js";
|
||||
import { topics } from "../schema/topics.js";
|
||||
import {
|
||||
|
|
@ -42,11 +46,241 @@ interface ActiveFlow {
|
|||
readonly scope: Scope.Closeable;
|
||||
}
|
||||
|
||||
export interface FlowProcessorRuntimeOptions<
|
||||
FlowRequirements = never,
|
||||
ConfigHandlerError = never,
|
||||
ConfigHandlerRequirements = never,
|
||||
> {
|
||||
readonly id: string;
|
||||
readonly pubsub: PubSubBackend;
|
||||
readonly specifications: ReadonlyArray<Spec<FlowRequirements>>;
|
||||
readonly configHandlers?: ReadonlyArray<
|
||||
EffectConfigHandler<ConfigHandlerError, ConfigHandlerRequirements>
|
||||
>;
|
||||
readonly isRunning?: () => boolean;
|
||||
}
|
||||
|
||||
const ConfigPushSchema = S.Struct({
|
||||
version: S.Number,
|
||||
config: S.Record(S.String, S.Unknown),
|
||||
});
|
||||
|
||||
export function runFlowProcessorDefinitionScoped<
|
||||
FlowRequirements = never,
|
||||
ConfigHandlerError = never,
|
||||
ConfigHandlerRequirements = never,
|
||||
>(
|
||||
options: FlowProcessorRuntimeOptions<
|
||||
FlowRequirements,
|
||||
ConfigHandlerError,
|
||||
ConfigHandlerRequirements
|
||||
>,
|
||||
): Effect.Effect<
|
||||
void,
|
||||
PubSubError | FlowRuntimeError | ConfigHandlerError,
|
||||
| PubSub
|
||||
| FlowRuntime
|
||||
| ProducerFactory
|
||||
| ConsumerFactory
|
||||
| RequestResponseFactory
|
||||
| Scope.Scope
|
||||
| FlowRequirements
|
||||
| ConfigHandlerRequirements
|
||||
> {
|
||||
const flows = new Map<string, ActiveFlow>();
|
||||
let configConsumer: BackendConsumer<ConfigPush> | null = null;
|
||||
let lastFlowsJson = "";
|
||||
const isRunning = options.isRunning ?? (() => true);
|
||||
|
||||
const closeFlowEffect = (name: string, activeFlow: ActiveFlow): Effect.Effect<void> =>
|
||||
Scope.close(activeFlow.scope, Exit.void).pipe(
|
||||
Effect.tap(() => Effect.log(`[${options.id}] Flow "${name}" stopped`)),
|
||||
);
|
||||
|
||||
const closeAllFlowsEffect = Effect.gen(function* () {
|
||||
const activeFlows = Array.from(flows.entries());
|
||||
for (const [name, activeFlow] of activeFlows) {
|
||||
yield* closeFlowEffect(name, activeFlow);
|
||||
}
|
||||
flows.clear();
|
||||
});
|
||||
|
||||
const closeConfigConsumerEffect = (): Effect.Effect<void> => {
|
||||
const consumer = configConsumer;
|
||||
configConsumer = null;
|
||||
if (consumer === null) {
|
||||
return Effect.void;
|
||||
}
|
||||
return Effect.tryPromise({
|
||||
try: () => consumer.close(),
|
||||
catch: (error) => pubSubError("close:config-push", error),
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError(`[${options.id}] Failed to close config consumer`, {
|
||||
error: error.message,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const startFlowEffect = (
|
||||
name: string,
|
||||
definition: FlowDefinition,
|
||||
): Effect.Effect<
|
||||
ActiveFlow,
|
||||
FlowRuntimeError,
|
||||
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
|
||||
> =>
|
||||
Effect.gen(function* () {
|
||||
const flowRuntime = yield* FlowRuntime;
|
||||
const scope = yield* Scope.make();
|
||||
const flow = new Flow<FlowRequirements>(
|
||||
name,
|
||||
options.id,
|
||||
options.pubsub,
|
||||
definition,
|
||||
options.specifications,
|
||||
);
|
||||
return yield* flowRuntime.run(flow).pipe(
|
||||
Scope.provide(scope),
|
||||
Effect.as({ scope } satisfies ActiveFlow),
|
||||
Effect.catch((error) =>
|
||||
Scope.close(scope, Exit.void).pipe(
|
||||
Effect.flatMap(() => Effect.fail(error)),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const onConfigureFlowsEffect = (
|
||||
config: Record<string, unknown>,
|
||||
_version: number,
|
||||
): Effect.Effect<
|
||||
void,
|
||||
FlowRuntimeError,
|
||||
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
|
||||
> =>
|
||||
Effect.gen(function* () {
|
||||
const flowDefs = config.flows as Record<string, FlowDefinition> | undefined;
|
||||
if (flowDefs === undefined) {
|
||||
yield* Effect.log(`[${options.id}] No flows in config push, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const flowsJson = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(flowDefs).pipe(
|
||||
Effect.catch((error) => Effect.succeed(String(error))),
|
||||
);
|
||||
if (lastFlowsJson.length > 0 && flowsJson === lastFlowsJson && flows.size > 0) {
|
||||
yield* Effect.log(`[${options.id}] Flow definitions unchanged, skipping restart`);
|
||||
return;
|
||||
}
|
||||
lastFlowsJson = flowsJson;
|
||||
|
||||
for (const [name, activeFlow] of flows) {
|
||||
if (!(name in flowDefs)) {
|
||||
yield* Effect.log(`[${options.id}] Stopping removed flow: ${name}`);
|
||||
yield* closeFlowEffect(name, activeFlow);
|
||||
flows.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, defn] of Object.entries(flowDefs)) {
|
||||
if (typeof defn !== "object" || defn === null) {
|
||||
yield* Effect.logWarning(`[${options.id}] Skipping flow "${name}": definition is not an object`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = flows.get(name);
|
||||
if (existing !== undefined) {
|
||||
yield* Effect.log(`[${options.id}] Restarting flow "${name}" with updated config`);
|
||||
yield* closeFlowEffect(name, existing);
|
||||
flows.delete(name);
|
||||
}
|
||||
|
||||
yield* Effect.log(`[${options.id}] Starting flow "${name}"`);
|
||||
const activeFlow = yield* startFlowEffect(name, defn);
|
||||
flows.set(name, activeFlow);
|
||||
yield* Effect.log(`[${options.id}] Flow "${name}" started`);
|
||||
}
|
||||
});
|
||||
|
||||
const processNextConfigPushEffect = (): Effect.Effect<
|
||||
void,
|
||||
never,
|
||||
| FlowRuntime
|
||||
| ProducerFactory
|
||||
| ConsumerFactory
|
||||
| RequestResponseFactory
|
||||
| FlowRequirements
|
||||
| ConfigHandlerRequirements
|
||||
> =>
|
||||
Effect.gen(function* () {
|
||||
const consumer = configConsumer;
|
||||
if (consumer === null) {
|
||||
yield* Effect.sleep(Duration.millis(1000));
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = yield* Effect.tryPromise({
|
||||
try: () => consumer.receive(2000),
|
||||
catch: (error) => pubSubError("receive:config-push", error),
|
||||
});
|
||||
if (msg === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const push = msg.value();
|
||||
yield* Effect.log(`[${options.id}] Received config push version=${push.version}`);
|
||||
|
||||
yield* onConfigureFlowsEffect(push.config, push.version);
|
||||
|
||||
for (const handler of options.configHandlers ?? []) {
|
||||
yield* handler(push.config, push.version);
|
||||
}
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: () => consumer.acknowledge(msg),
|
||||
catch: (error) => pubSubError("acknowledge:config-push", error),
|
||||
});
|
||||
}).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!isRunning()) {
|
||||
return Effect.void;
|
||||
}
|
||||
return Effect.logError(`[${options.id}] Config consumer error`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Effect.sleep(Duration.millis(1000))),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub;
|
||||
|
||||
configConsumer = yield* pubsub.createConsumer<ConfigPush>({
|
||||
topic: topics.configPush,
|
||||
subscription: `${options.id}-config-push`,
|
||||
initialPosition: "earliest",
|
||||
schema: ConfigPushSchema,
|
||||
});
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
closeConfigConsumerEffect().pipe(
|
||||
Effect.flatMap(() => closeAllFlowsEffect),
|
||||
),
|
||||
);
|
||||
|
||||
yield* Effect.log(`[${options.id}] Listening for config pushes on ${topics.configPush}`);
|
||||
|
||||
yield* Effect.whileLoop({
|
||||
while: isRunning,
|
||||
body: processNextConfigPushEffect,
|
||||
step: () => undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export abstract class FlowProcessor<FlowRequirements = never> extends AsyncProcessor<
|
||||
PubSubError | FlowRuntimeError | ProcessorLifecycleError,
|
||||
| PubSub
|
||||
|
|
@ -58,9 +292,6 @@ export abstract class FlowProcessor<FlowRequirements = never> extends AsyncProce
|
|||
| FlowRequirements
|
||||
> {
|
||||
private specifications: Array<Spec<FlowRequirements>> = [];
|
||||
private flows = new Map<string, ActiveFlow>();
|
||||
private configConsumer: BackendConsumer<ConfigPush> | null = null;
|
||||
private lastFlowsJson = "";
|
||||
|
||||
protected constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
|
@ -104,216 +335,24 @@ export abstract class FlowProcessor<FlowRequirements = never> extends AsyncProce
|
|||
| FlowRequirements
|
||||
> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub;
|
||||
|
||||
// Subscribe to config-push topic to receive flow definitions.
|
||||
// Use "earliest" to replay any config pushes that arrived before this service started.
|
||||
processor.configConsumer = yield* pubsub.createConsumer<ConfigPush>({
|
||||
topic: topics.configPush,
|
||||
subscription: `${processor.config.id}-config-push`,
|
||||
initialPosition: "earliest",
|
||||
schema: ConfigPushSchema,
|
||||
});
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
processor.closeConfigConsumerEffect().pipe(
|
||||
Effect.flatMap(() => processor.closeAllFlowsEffect()),
|
||||
),
|
||||
);
|
||||
|
||||
yield* Effect.log(`[${processor.config.id}] Listening for config pushes on ${topics.configPush}`);
|
||||
|
||||
yield* Effect.whileLoop({
|
||||
while: () => processor.running,
|
||||
body: () => processor.processNextConfigPushEffect(),
|
||||
step: () => undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private onConfigureFlowsEffect(
|
||||
config: Record<string, unknown>,
|
||||
_version: number,
|
||||
): Effect.Effect<
|
||||
void,
|
||||
FlowRuntimeError,
|
||||
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
|
||||
> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
const flowDefs = config.flows as Record<string, FlowDefinition> | undefined;
|
||||
if (flowDefs === undefined) {
|
||||
yield* Effect.log(`[${processor.config.id}] No flows in config push, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip flow restart if the flow definitions haven't changed.
|
||||
// This prevents disrupting in-flight requests when non-flow config
|
||||
// sections (prompts, tools, mcp) are updated.
|
||||
const flowsJson = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(flowDefs).pipe(
|
||||
Effect.catch((error) => Effect.succeed(String(error))),
|
||||
);
|
||||
if (processor.lastFlowsJson.length > 0 && flowsJson === processor.lastFlowsJson && processor.flows.size > 0) {
|
||||
yield* Effect.log(`[${processor.config.id}] Flow definitions unchanged, skipping restart`);
|
||||
return;
|
||||
}
|
||||
processor.lastFlowsJson = flowsJson;
|
||||
|
||||
// Stop removed flows
|
||||
for (const [name, activeFlow] of processor.flows) {
|
||||
if (!(name in flowDefs)) {
|
||||
yield* Effect.log(`[${processor.config.id}] Stopping removed flow: ${name}`);
|
||||
yield* processor.closeFlowEffect(name, activeFlow);
|
||||
processor.flows.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Start or update flows
|
||||
for (const [name, defn] of Object.entries(flowDefs)) {
|
||||
// Skip invalid definitions (e.g., stringified JSON)
|
||||
if (typeof defn !== "object" || defn === null) {
|
||||
yield* Effect.logWarning(`[${processor.config.id}] Skipping flow "${name}": definition is not an object`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stop existing flow before (re)starting with new config
|
||||
const existing = processor.flows.get(name);
|
||||
if (existing !== undefined) {
|
||||
yield* Effect.log(`[${processor.config.id}] Restarting flow "${name}" with updated config`);
|
||||
yield* processor.closeFlowEffect(name, existing);
|
||||
processor.flows.delete(name);
|
||||
}
|
||||
|
||||
yield* Effect.log(`[${processor.config.id}] Starting flow "${name}"`);
|
||||
const activeFlow = yield* processor.startFlowEffect(name, defn);
|
||||
processor.flows.set(name, activeFlow);
|
||||
yield* Effect.log(`[${processor.config.id}] Flow "${name}" started`);
|
||||
}
|
||||
const configHandlers = processor.configHandlers.map(
|
||||
(handler): EffectConfigHandler<PubSubError> =>
|
||||
(config, version) =>
|
||||
Effect.tryPromise({
|
||||
try: () => handler(config, version),
|
||||
catch: (error) => pubSubError("config-handler", error),
|
||||
}),
|
||||
);
|
||||
return runFlowProcessorDefinitionScoped({
|
||||
id: processor.config.id,
|
||||
pubsub: processor.pubsub,
|
||||
specifications: processor.specifications,
|
||||
configHandlers,
|
||||
isRunning: () => processor.running,
|
||||
});
|
||||
}
|
||||
|
||||
override stopEffect(): Effect.Effect<void, ProcessorLifecycleError> {
|
||||
return this.closeConfigConsumerEffect().pipe(
|
||||
Effect.flatMap(() => this.closeAllFlowsEffect()),
|
||||
Effect.flatMap(() => super.stopEffect()),
|
||||
);
|
||||
}
|
||||
|
||||
private processNextConfigPushEffect(): Effect.Effect<
|
||||
void,
|
||||
never,
|
||||
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
|
||||
> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
const consumer = processor.configConsumer;
|
||||
if (consumer === null) {
|
||||
yield* Effect.sleep(Duration.millis(1000));
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = yield* Effect.tryPromise({
|
||||
try: () => consumer.receive(2000),
|
||||
catch: (error) => pubSubError("receive:config-push", error),
|
||||
});
|
||||
if (msg === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const push = msg.value();
|
||||
yield* Effect.log(`[${processor.config.id}] Received config push version=${push.version}`);
|
||||
|
||||
yield* processor.onConfigureFlowsEffect(push.config, push.version);
|
||||
|
||||
for (const handler of processor.configHandlers) {
|
||||
yield* Effect.tryPromise({
|
||||
try: () => handler(push.config, push.version),
|
||||
catch: (error) => pubSubError("config-handler", error),
|
||||
});
|
||||
}
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: () => consumer.acknowledge(msg),
|
||||
catch: (error) => pubSubError("acknowledge:config-push", error),
|
||||
});
|
||||
}).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!processor.running) {
|
||||
return Effect.void;
|
||||
}
|
||||
return Effect.logError(`[${processor.config.id}] Config consumer error`, {
|
||||
error: error.message,
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Effect.sleep(Duration.millis(1000))),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private startFlowEffect(
|
||||
name: string,
|
||||
definition: FlowDefinition,
|
||||
): Effect.Effect<
|
||||
ActiveFlow,
|
||||
FlowRuntimeError,
|
||||
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
|
||||
> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
const flowRuntime = yield* FlowRuntime;
|
||||
const scope = yield* Scope.make();
|
||||
const flow = new Flow<FlowRequirements>(
|
||||
name,
|
||||
processor.config.id,
|
||||
processor.pubsub,
|
||||
definition,
|
||||
processor.specifications,
|
||||
);
|
||||
return yield* flowRuntime.run(flow).pipe(
|
||||
Scope.provide(scope),
|
||||
Effect.as({ scope } satisfies ActiveFlow),
|
||||
Effect.catch((error) =>
|
||||
Scope.close(scope, Exit.void).pipe(
|
||||
Effect.flatMap(() => Effect.fail(error)),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private closeFlowEffect(name: string, activeFlow: ActiveFlow): Effect.Effect<void> {
|
||||
return Scope.close(activeFlow.scope, Exit.void).pipe(
|
||||
Effect.tap(() => Effect.log(`[${this.config.id}] Flow "${name}" stopped`)),
|
||||
);
|
||||
}
|
||||
|
||||
private closeAllFlowsEffect(): Effect.Effect<void> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
const flows = Array.from(processor.flows.entries());
|
||||
for (const [name, activeFlow] of flows) {
|
||||
yield* processor.closeFlowEffect(name, activeFlow);
|
||||
}
|
||||
processor.flows.clear();
|
||||
});
|
||||
}
|
||||
|
||||
private closeConfigConsumerEffect(): Effect.Effect<void> {
|
||||
const consumer = this.configConsumer;
|
||||
this.configConsumer = null;
|
||||
if (consumer === null) {
|
||||
return Effect.void;
|
||||
}
|
||||
return Effect.tryPromise({
|
||||
try: () => consumer.close(),
|
||||
catch: (error) => pubSubError("close:config-push", error),
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError(`[${this.config.id}] Failed to close config consumer`, {
|
||||
error: error.message,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return super.stopEffect();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ export {
|
|||
type EffectConfigHandler,
|
||||
type ProcessorConfig,
|
||||
} from "./async-processor.js";
|
||||
export { FlowProcessor } from "./flow-processor.js";
|
||||
export {
|
||||
FlowProcessor,
|
||||
runFlowProcessorDefinitionScoped,
|
||||
type FlowProcessorRuntimeOptions,
|
||||
} from "./flow-processor.js";
|
||||
export {
|
||||
Flow,
|
||||
type FlowConsumer,
|
||||
|
|
@ -18,5 +22,6 @@ export {
|
|||
makeFlowProcessorProgram,
|
||||
makeProcessorProgram,
|
||||
runProcessorScoped,
|
||||
type FlowProcessorProgramOptions,
|
||||
type ProcessorProgramOptions,
|
||||
} from "./program.js";
|
||||
|
|
|
|||
|
|
@ -5,8 +5,13 @@
|
|||
* executable path while the processor internals remain Promise-based.
|
||||
*/
|
||||
|
||||
import { Effect, Scope } from "effect";
|
||||
import { processorLifecycleError, type ProcessorLifecycleError } from "../errors.js";
|
||||
import { Config as EffectConfig, Effect, Layer, Scope } from "effect";
|
||||
import {
|
||||
processorLifecycleError,
|
||||
type FlowRuntimeError,
|
||||
type ProcessorLifecycleError,
|
||||
type PubSubError,
|
||||
} from "../errors.js";
|
||||
import { NatsBackend } from "../backend/nats.js";
|
||||
import { makePubSubService, PubSub } from "../backend/pubsub.js";
|
||||
import {
|
||||
|
|
@ -24,7 +29,13 @@ import {
|
|||
type ProcessorRuntimeConfigOptions,
|
||||
} from "../runtime/config.js";
|
||||
import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js";
|
||||
import type { AsyncProcessor, ProcessorConfig } from "./async-processor.js";
|
||||
import type {
|
||||
AsyncProcessor,
|
||||
EffectConfigHandler,
|
||||
ProcessorConfig,
|
||||
} from "./async-processor.js";
|
||||
import { runFlowProcessorDefinitionScoped } from "./flow-processor.js";
|
||||
import type { Spec } from "../spec/types.js";
|
||||
|
||||
type ProcessorRunError<Processor> = Processor extends AsyncProcessor<infer Error, unknown> ? Error : never;
|
||||
type ProcessorRunRequirements<Processor> = Processor extends AsyncProcessor<unknown, infer Requirements> ? Requirements : never;
|
||||
|
|
@ -40,6 +51,23 @@ export interface ProcessorProgramOptions<
|
|||
readonly loadConfig?: Effect.Effect<Config, Error, Requirements>;
|
||||
}
|
||||
|
||||
export interface FlowProcessorProgramOptions<
|
||||
Config extends ProcessorConfig,
|
||||
Error = never,
|
||||
FlowRequirements = never,
|
||||
LayerRequirements = never,
|
||||
> {
|
||||
readonly id: string;
|
||||
readonly loadConfig?: Effect.Effect<Config, Error, LayerRequirements>;
|
||||
readonly specs: (config: Config) => ReadonlyArray<Spec<FlowRequirements>>;
|
||||
readonly configHandlers?: (
|
||||
config: Config,
|
||||
) => ReadonlyArray<EffectConfigHandler<Error, FlowRequirements>>;
|
||||
readonly layer?: (
|
||||
config: Config,
|
||||
) => Layer.Layer<FlowRequirements, Error, LayerRequirements>;
|
||||
}
|
||||
|
||||
export function runProcessorScoped<
|
||||
Config extends ProcessorConfig,
|
||||
Processor extends AsyncProcessor<unknown, unknown>,
|
||||
|
|
@ -136,4 +164,74 @@ export function makeProcessorProgram<
|
|||
}
|
||||
|
||||
export const makeAsyncProcessorProgram = makeProcessorProgram;
|
||||
export const makeFlowProcessorProgram = makeProcessorProgram;
|
||||
|
||||
export function makeFlowProcessorProgram<
|
||||
Config extends ProcessorConfig,
|
||||
Error = never,
|
||||
FlowRequirements = never,
|
||||
LayerRequirements = never,
|
||||
>(
|
||||
options: FlowProcessorProgramOptions<Config, Error, FlowRequirements, LayerRequirements>,
|
||||
): Effect.Effect<
|
||||
void,
|
||||
Error | EffectConfig.ConfigError | PubSubError | FlowRuntimeError,
|
||||
LayerRequirements
|
||||
> {
|
||||
return Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* (
|
||||
options.loadConfig ??
|
||||
loadProcessorRuntimeConfig(options.id, {
|
||||
manageProcessSignals: false,
|
||||
} satisfies ProcessorRuntimeConfigOptions)
|
||||
);
|
||||
|
||||
const runtimeConfig = {
|
||||
...config,
|
||||
manageProcessSignals: false,
|
||||
} as Config;
|
||||
|
||||
const pubsub = makePubSubService(new NatsBackend(runtimeConfig.pubsubUrl ?? "nats://localhost:4222"));
|
||||
const messagingConfig = yield* loadMessagingRuntimeConfig();
|
||||
yield* Effect.addFinalizer(() =>
|
||||
pubsub.close.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[PubSub] Failed to close processor backend", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const configHandlers = options.configHandlers?.(runtimeConfig);
|
||||
const processorOptions = {
|
||||
id: runtimeConfig.id,
|
||||
pubsub: pubsub.backend,
|
||||
specifications: options.specs(runtimeConfig),
|
||||
...(configHandlers === undefined ? {} : { configHandlers }),
|
||||
};
|
||||
const processorLayer = Layer.effectDiscard(
|
||||
runFlowProcessorDefinitionScoped<FlowRequirements, Error, FlowRequirements>(processorOptions),
|
||||
);
|
||||
const runtimeLayer = Layer.mergeAll(
|
||||
Layer.succeed(PubSub, PubSub.of(pubsub)),
|
||||
Layer.succeed(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsub))),
|
||||
Layer.succeed(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsub, messagingConfig))),
|
||||
Layer.succeed(
|
||||
RequestResponseFactory,
|
||||
RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, messagingConfig)),
|
||||
),
|
||||
Layer.succeed(FlowRuntime, FlowRuntime.of({ run: runFlowRuntimeScoped })),
|
||||
);
|
||||
const dependencyLayer = options.layer?.(runtimeConfig) ??
|
||||
(Layer.empty as unknown as Layer.Layer<FlowRequirements, Error, LayerRequirements>);
|
||||
const providedProcessorLayer = processorLayer.pipe(
|
||||
Layer.provide(dependencyLayer),
|
||||
Layer.provide(runtimeLayer),
|
||||
);
|
||||
|
||||
return yield* Layer.launch(providedProcessorLayer);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,15 +179,16 @@ export const ConfigOperation = S.Union([
|
|||
S.Literal("put"),
|
||||
S.Literal("config"),
|
||||
S.Literal("getvalues"),
|
||||
S.Literal("getvalues-all-ws"),
|
||||
]);
|
||||
export type ConfigOperation = typeof ConfigOperation.Type;
|
||||
|
||||
export const ConfigRequest = S.Struct({
|
||||
operation: ConfigOperation,
|
||||
keys: S.optionalKey(StringArray),
|
||||
values: S.optionalKey(UnknownRecord),
|
||||
type: S.optionalKey(S.String),
|
||||
});
|
||||
export const ConfigRequest = S.StructWithRest(
|
||||
S.Struct({
|
||||
operation: ConfigOperation,
|
||||
}),
|
||||
[UnknownRecord],
|
||||
);
|
||||
export type ConfigRequest = typeof ConfigRequest.Type;
|
||||
|
||||
export const ConfigResponse = S.Struct({
|
||||
|
|
@ -271,7 +272,9 @@ export const DocumentMetadata = S.Struct({
|
|||
user: S.String,
|
||||
tags: StringArray,
|
||||
parentId: S.optionalKey(S.String),
|
||||
documentType: S.String,
|
||||
"parent-id": S.optionalKey(S.String),
|
||||
documentType: S.optionalKey(S.String),
|
||||
"document-type": S.optionalKey(S.String),
|
||||
metadata: OptionalMutableArray(Triple),
|
||||
});
|
||||
export type DocumentMetadata = typeof DocumentMetadata.Type;
|
||||
|
|
@ -279,6 +282,7 @@ export type DocumentMetadata = typeof DocumentMetadata.Type;
|
|||
export const ProcessingMetadata = S.Struct({
|
||||
id: S.String,
|
||||
documentId: S.String,
|
||||
"document-id": S.optionalKey(S.String),
|
||||
time: S.Number,
|
||||
flow: S.String,
|
||||
user: S.String,
|
||||
|
|
@ -291,6 +295,7 @@ export type ProcessingMetadata = typeof ProcessingMetadata.Type;
|
|||
export const LibrarianOperation = S.Literals([
|
||||
"add-document",
|
||||
"remove-document",
|
||||
"update-document",
|
||||
"list-documents",
|
||||
"get-document-metadata",
|
||||
"get-document-content",
|
||||
|
|
@ -299,15 +304,26 @@ export const LibrarianOperation = S.Literals([
|
|||
"add-processing",
|
||||
"remove-processing",
|
||||
"list-processing",
|
||||
"begin-upload",
|
||||
"upload-chunk",
|
||||
"complete-upload",
|
||||
"get-upload-status",
|
||||
"abort-upload",
|
||||
"list-uploads",
|
||||
"stream-document",
|
||||
]);
|
||||
export type LibrarianOperation = typeof LibrarianOperation.Type;
|
||||
|
||||
export const LibrarianRequest = S.Struct({
|
||||
operation: LibrarianOperation,
|
||||
documentId: S.optionalKey(S.String),
|
||||
"document-id": S.optionalKey(S.String),
|
||||
processingId: S.optionalKey(S.String),
|
||||
"processing-id": S.optionalKey(S.String),
|
||||
documentMetadata: S.optionalKey(DocumentMetadata),
|
||||
"document-metadata": S.optionalKey(DocumentMetadata),
|
||||
processingMetadata: S.optionalKey(ProcessingMetadata),
|
||||
"processing-metadata": S.optionalKey(ProcessingMetadata),
|
||||
content: S.optionalKey(S.String),
|
||||
user: S.optionalKey(S.String),
|
||||
collection: S.optionalKey(S.String),
|
||||
|
|
@ -317,9 +333,13 @@ export type LibrarianRequest = typeof LibrarianRequest.Type;
|
|||
export const LibrarianResponse = S.Struct({
|
||||
error: S.optionalKey(TgError),
|
||||
documentMetadata: S.optionalKey(DocumentMetadata),
|
||||
"document-metadata": S.optionalKey(DocumentMetadata),
|
||||
content: S.optionalKey(S.String),
|
||||
documents: OptionalMutableArray(DocumentMetadata),
|
||||
"document-metadatas": OptionalMutableArray(DocumentMetadata),
|
||||
processing: OptionalMutableArray(ProcessingMetadata),
|
||||
"processing-metadata": OptionalMutableArray(ProcessingMetadata),
|
||||
"processing-metadatas": OptionalMutableArray(ProcessingMetadata),
|
||||
});
|
||||
export type LibrarianResponse = typeof LibrarianResponse.Type;
|
||||
|
||||
|
|
@ -330,6 +350,12 @@ export const KnowledgeOperation = S.Literals([
|
|||
"delete-kg-core",
|
||||
"put-kg-core",
|
||||
"load-kg-core",
|
||||
"unload-kg-core",
|
||||
"list-de-cores",
|
||||
"get-de-core",
|
||||
"delete-de-core",
|
||||
"put-de-core",
|
||||
"load-de-core",
|
||||
]);
|
||||
export type KnowledgeOperation = typeof KnowledgeOperation.Type;
|
||||
|
||||
|
|
@ -338,6 +364,14 @@ const GraphEmbedding = S.Struct({
|
|||
vectors: NumberArrays,
|
||||
});
|
||||
|
||||
const DocumentEmbeddingsCore = S.StructWithRest(
|
||||
S.Struct({
|
||||
metadata: S.optionalKey(UnknownRecord),
|
||||
chunks: S.optionalKey(S.Unknown.pipe(S.Array, S.mutable)),
|
||||
}),
|
||||
[UnknownRecord],
|
||||
);
|
||||
|
||||
export const KnowledgeRequest = S.Struct({
|
||||
operation: KnowledgeOperation,
|
||||
user: S.optionalKey(S.String),
|
||||
|
|
@ -346,6 +380,7 @@ export const KnowledgeRequest = S.Struct({
|
|||
collection: S.optionalKey(S.String),
|
||||
triples: OptionalMutableArray(Triple),
|
||||
graphEmbeddings: OptionalMutableArray(GraphEmbedding),
|
||||
documentEmbeddings: S.optionalKey(DocumentEmbeddingsCore),
|
||||
});
|
||||
export type KnowledgeRequest = typeof KnowledgeRequest.Type;
|
||||
|
||||
|
|
@ -355,6 +390,7 @@ export const KnowledgeResponse = S.Struct({
|
|||
eos: S.optionalKey(S.Boolean),
|
||||
triples: OptionalMutableArray(Triple),
|
||||
graphEmbeddings: OptionalMutableArray(GraphEmbedding),
|
||||
documentEmbeddings: S.optionalKey(DocumentEmbeddingsCore),
|
||||
});
|
||||
export type KnowledgeResponse = typeof KnowledgeResponse.Type;
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import type { EmbeddingsRequest, EmbeddingsResponse } from "../schema/messages.j
|
|||
import { ConsumerSpec } from "../spec/consumer-spec.js";
|
||||
import { ParameterSpec } from "../spec/parameter-spec.js";
|
||||
import { ProducerSpec } from "../spec/producer-spec.js";
|
||||
import type { Spec } from "../spec/types.js";
|
||||
|
||||
export interface EmbeddingsServiceShape {
|
||||
readonly embed: (
|
||||
|
|
@ -30,53 +31,55 @@ export class Embeddings extends Context.Service<Embeddings, EmbeddingsServiceSha
|
|||
"@trustgraph/base/services/embeddings-service/Embeddings",
|
||||
) {}
|
||||
|
||||
const onEmbeddingsRequest = Effect.fn("EmbeddingsService.onRequest")(function* (
|
||||
msg: EmbeddingsRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<Embeddings>,
|
||||
): Effect.fn.Return<void, FlowResourceNotFoundError | MessagingDeliveryError, Embeddings> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const responseProducer = yield* flowCtx.flow.producerEffect<EmbeddingsResponse>("embeddings-response");
|
||||
const embeddings = yield* Embeddings;
|
||||
const response = yield* embeddings.embed(msg.text, msg.model).pipe(
|
||||
Effect.map((vectors) => ({ vectors }) satisfies EmbeddingsResponse),
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[EmbeddingsService] Error processing request", {
|
||||
error: errorMessage(error),
|
||||
operation: error.operation,
|
||||
provider: error.provider ?? "unknown",
|
||||
}).pipe(
|
||||
Effect.as({
|
||||
vectors: [],
|
||||
error: {
|
||||
type: "embeddings-error",
|
||||
message: errorMessage(error),
|
||||
},
|
||||
} satisfies EmbeddingsResponse),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
yield* responseProducer.send(requestId, response);
|
||||
});
|
||||
|
||||
export const makeEmbeddingsSpecs = (): ReadonlyArray<Spec<Embeddings>> => [
|
||||
new ConsumerSpec<EmbeddingsRequest, FlowResourceNotFoundError | MessagingDeliveryError, Embeddings>(
|
||||
"embeddings-request",
|
||||
onEmbeddingsRequest,
|
||||
),
|
||||
new ProducerSpec<EmbeddingsResponse>("embeddings-response"),
|
||||
new ParameterSpec("model"),
|
||||
];
|
||||
|
||||
export class EmbeddingsService extends FlowProcessor<Embeddings> {
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<EmbeddingsRequest, FlowResourceNotFoundError | MessagingDeliveryError, Embeddings>(
|
||||
"embeddings-request",
|
||||
this.onRequestEffect.bind(this),
|
||||
),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<EmbeddingsResponse>("embeddings-response"));
|
||||
this.registerSpecification(new ParameterSpec("model"));
|
||||
}
|
||||
|
||||
private onRequestEffect(
|
||||
msg: EmbeddingsRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<Embeddings>,
|
||||
): Effect.Effect<void, FlowResourceNotFoundError | MessagingDeliveryError, Embeddings> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) {
|
||||
return Effect.void;
|
||||
for (const spec of makeEmbeddingsSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const responseProducer = yield* flowCtx.flow.producerEffect<EmbeddingsResponse>("embeddings-response");
|
||||
const embeddings = yield* Embeddings;
|
||||
const response = yield* embeddings.embed(msg.text, msg.model).pipe(
|
||||
Effect.map((vectors) => ({ vectors }) satisfies EmbeddingsResponse),
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[EmbeddingsService] Error processing request", {
|
||||
error: errorMessage(error),
|
||||
operation: error.operation,
|
||||
provider: error.provider ?? "unknown",
|
||||
}).pipe(
|
||||
Effect.as({
|
||||
vectors: [],
|
||||
error: {
|
||||
type: "embeddings-error",
|
||||
message: errorMessage(error),
|
||||
},
|
||||
} satisfies EmbeddingsResponse),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
yield* responseProducer.send(requestId, response);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
export { LlmService } from "./llm-service.js";
|
||||
export {
|
||||
Llm,
|
||||
LlmService,
|
||||
LlmServiceError,
|
||||
makeLlmServiceShape,
|
||||
makeLlmSpecs,
|
||||
type LlmProvider,
|
||||
type LlmServiceShape,
|
||||
} from "./llm-service.js";
|
||||
export {
|
||||
Embeddings,
|
||||
EmbeddingsService,
|
||||
makeEmbeddingsSpecs,
|
||||
type EmbeddingsServiceShape,
|
||||
} from "./embeddings-service.js";
|
||||
|
|
|
|||
|
|
@ -1,125 +1,247 @@
|
|||
/**
|
||||
* Base LLM service — handles message plumbing, subclasses implement the LLM call.
|
||||
* Base LLM capability contract and message-bus adapter.
|
||||
*
|
||||
* Python reference: trustgraph-base/trustgraph/base/llm_service.py
|
||||
*/
|
||||
|
||||
import {FlowProcessor} from "../processor/index.js";
|
||||
import { Context, Effect } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
import {
|
||||
ConsumerSpec, ProducerSpec,
|
||||
ParameterSpec
|
||||
} from "../spec/index.js";
|
||||
import type {ProcessorConfig} from "../processor/index.js";
|
||||
import type {FlowContext} from "../messaging/consumer.js";
|
||||
errorMessage,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
} from "../errors.js";
|
||||
import type { FlowContext } from "../messaging/consumer.js";
|
||||
import { FlowProcessor } from "../processor/flow-processor.js";
|
||||
import type { ProcessorConfig } from "../processor/async-processor.js";
|
||||
import type {
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse,
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse,
|
||||
} from "../schema/messages.js";
|
||||
import type {LlmResult, LlmChunk} from "../schema/index.js";
|
||||
import type { LlmChunk, LlmResult } from "../schema/primitives.js";
|
||||
import { ConsumerSpec } from "../spec/consumer-spec.js";
|
||||
import { ParameterSpec } from "../spec/parameter-spec.js";
|
||||
import { ProducerSpec } from "../spec/producer-spec.js";
|
||||
import type { Spec } from "../spec/types.js";
|
||||
|
||||
export abstract class LlmService extends FlowProcessor {
|
||||
protected constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
export class LlmServiceError extends S.TaggedErrorClass<LlmServiceError>()(
|
||||
"LlmServiceError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<TextCompletionRequest>(
|
||||
"text-completion-request",
|
||||
this.onRequest.bind(this),
|
||||
),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<TextCompletionResponse>("text-completion-response"));
|
||||
this.registerSpecification(new ParameterSpec("model"));
|
||||
this.registerSpecification(new ParameterSpec("temperature"));
|
||||
}
|
||||
|
||||
private async onRequest(
|
||||
msg: TextCompletionRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = flowCtx.flow.producer<TextCompletionResponse>("text-completion-response");
|
||||
|
||||
try {
|
||||
if (msg.streaming === true && this.supportsStreaming()) {
|
||||
for await (const chunk of this.generateContentStream(
|
||||
msg.system,
|
||||
msg.prompt,
|
||||
msg.model,
|
||||
msg.temperature,
|
||||
)) {
|
||||
const response = {
|
||||
response: chunk.text,
|
||||
...(chunk.model !== undefined ? { model: chunk.model } : {}),
|
||||
...(chunk.inToken !== null ? { inToken: chunk.inToken } : {}),
|
||||
...(chunk.outToken !== null ? { outToken: chunk.outToken } : {}),
|
||||
endOfStream: chunk.isFinal,
|
||||
};
|
||||
await responseProducer.send(
|
||||
requestId,
|
||||
response
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const result = await this.generateContent(
|
||||
msg.system,
|
||||
msg.prompt,
|
||||
msg.model,
|
||||
msg.temperature,
|
||||
);
|
||||
const response = {
|
||||
response: result.text,
|
||||
...(result.model !== undefined ? { model: result.model } : {}),
|
||||
...(result.inToken !== undefined ? { inToken: result.inToken } : {}),
|
||||
...(result.outToken !== undefined ? { outToken: result.outToken } : {}),
|
||||
endOfStream: true,
|
||||
};
|
||||
|
||||
await responseProducer.send(
|
||||
requestId,
|
||||
response
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[LlmService] Error processing request:`,
|
||||
err
|
||||
);
|
||||
|
||||
const message = err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
await responseProducer.send(
|
||||
requestId,
|
||||
{
|
||||
response: "",
|
||||
error: {
|
||||
type: "llm-error",
|
||||
message
|
||||
},
|
||||
endOfStream: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract generateContent(
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
): Promise<LlmResult>;
|
||||
|
||||
abstract generateContentStream(
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
): AsyncGenerator<LlmChunk>;
|
||||
|
||||
supportsStreaming(): boolean {
|
||||
return false;
|
||||
}
|
||||
export interface LlmProvider {
|
||||
readonly generateContent: (
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
) => Promise<LlmResult>;
|
||||
readonly generateContentStream: (
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
) => AsyncGenerator<LlmChunk>;
|
||||
readonly supportsStreaming: () => boolean;
|
||||
}
|
||||
|
||||
export interface LlmServiceShape {
|
||||
readonly generateContent: (
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
) => Effect.Effect<LlmResult, LlmServiceError>;
|
||||
readonly generateContentStream: (
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
) => AsyncGenerator<LlmChunk>;
|
||||
readonly supportsStreaming: () => boolean;
|
||||
}
|
||||
|
||||
export class Llm extends Context.Service<Llm, LlmServiceShape>()(
|
||||
"@trustgraph/base/services/llm-service/Llm",
|
||||
) {}
|
||||
|
||||
const llmServiceError = (operation: string, cause: unknown) =>
|
||||
new LlmServiceError({
|
||||
operation,
|
||||
message: errorMessage(cause),
|
||||
});
|
||||
|
||||
export const makeLlmServiceShape = (provider: LlmProvider): LlmServiceShape => ({
|
||||
generateContent: Effect.fn("Llm.generateContent")((
|
||||
system,
|
||||
prompt,
|
||||
model,
|
||||
temperature,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => provider.generateContent(system, prompt, model, temperature),
|
||||
catch: (cause) => llmServiceError("generate-content", cause),
|
||||
}),
|
||||
),
|
||||
generateContentStream: (
|
||||
system,
|
||||
prompt,
|
||||
model,
|
||||
temperature,
|
||||
) => provider.generateContentStream(system, prompt, model, temperature),
|
||||
supportsStreaming: () => provider.supportsStreaming(),
|
||||
});
|
||||
|
||||
type LlmHandlerError =
|
||||
| FlowResourceNotFoundError
|
||||
| MessagingDeliveryError;
|
||||
|
||||
const resultToResponse = (result: LlmResult): TextCompletionResponse => ({
|
||||
response: result.text,
|
||||
model: result.model,
|
||||
inToken: result.inToken,
|
||||
outToken: result.outToken,
|
||||
endOfStream: true,
|
||||
});
|
||||
|
||||
const chunkToResponse = (chunk: LlmChunk): TextCompletionResponse => ({
|
||||
response: chunk.text,
|
||||
model: chunk.model,
|
||||
...(chunk.inToken !== null ? { inToken: chunk.inToken } : {}),
|
||||
...(chunk.outToken !== null ? { outToken: chunk.outToken } : {}),
|
||||
endOfStream: chunk.isFinal,
|
||||
});
|
||||
|
||||
const llmErrorResponse = (error: LlmServiceError): TextCompletionResponse => ({
|
||||
response: "",
|
||||
error: {
|
||||
type: "llm-error",
|
||||
message: error.message,
|
||||
},
|
||||
endOfStream: true,
|
||||
});
|
||||
|
||||
const sendStreamingResponse = Effect.fn("LlmService.sendStreamingResponse")(function* (
|
||||
llm: LlmServiceShape,
|
||||
requestId: string,
|
||||
msg: TextCompletionRequest,
|
||||
responseProducer: {
|
||||
readonly send: (
|
||||
id: string,
|
||||
message: TextCompletionResponse,
|
||||
) => Effect.Effect<void, MessagingDeliveryError>;
|
||||
},
|
||||
) {
|
||||
const context = yield* Effect.context<never>();
|
||||
yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
for await (const chunk of llm.generateContentStream(
|
||||
msg.system,
|
||||
msg.prompt,
|
||||
msg.model,
|
||||
msg.temperature,
|
||||
)) {
|
||||
await Effect.runPromiseWith(context)(
|
||||
responseProducer.send(requestId, chunkToResponse(chunk)),
|
||||
);
|
||||
}
|
||||
},
|
||||
catch: (cause) => llmServiceError("generate-content-stream", cause),
|
||||
});
|
||||
});
|
||||
|
||||
const onLlmRequest = Effect.fn("LlmService.onRequest")(function* (
|
||||
msg: TextCompletionRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<Llm>,
|
||||
): Effect.fn.Return<void, LlmHandlerError, Llm> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = yield* flowCtx.flow.producerEffect<TextCompletionResponse>(
|
||||
"text-completion-response",
|
||||
);
|
||||
const llm = yield* Llm;
|
||||
|
||||
if (msg.streaming === true && llm.supportsStreaming()) {
|
||||
yield* sendStreamingResponse(llm, requestId, msg, responseProducer).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[LlmService] Error processing streaming request", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
responseProducer.send(requestId, llmErrorResponse(error)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = yield* llm.generateContent(
|
||||
msg.system,
|
||||
msg.prompt,
|
||||
msg.model,
|
||||
msg.temperature,
|
||||
).pipe(
|
||||
Effect.map(resultToResponse),
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[LlmService] Error processing request", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}).pipe(
|
||||
Effect.as(llmErrorResponse(error)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
yield* responseProducer.send(requestId, response);
|
||||
});
|
||||
|
||||
export const makeLlmSpecs = (): ReadonlyArray<Spec<Llm>> => [
|
||||
new ConsumerSpec<TextCompletionRequest, LlmHandlerError, Llm>(
|
||||
"text-completion-request",
|
||||
onLlmRequest,
|
||||
),
|
||||
new ProducerSpec<TextCompletionResponse>("text-completion-response"),
|
||||
new ParameterSpec("model"),
|
||||
new ParameterSpec("temperature"),
|
||||
];
|
||||
|
||||
export abstract class LlmService extends FlowProcessor<Llm> implements LlmProvider {
|
||||
protected constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
for (const spec of makeLlmSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
}
|
||||
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(Llm, Llm.of(makeLlmServiceShape(this))),
|
||||
);
|
||||
}
|
||||
|
||||
abstract generateContent(
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
): Promise<LlmResult>;
|
||||
|
||||
abstract generateContentStream(
|
||||
system: string,
|
||||
prompt: string,
|
||||
model?: string,
|
||||
temperature?: number,
|
||||
): AsyncGenerator<LlmChunk>;
|
||||
|
||||
supportsStreaming(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,18 @@
|
|||
"@trustgraph/base": "workspace:*",
|
||||
"@trustgraph/client": "workspace:*",
|
||||
"commander": "^13.1.0",
|
||||
"effect": "4.0.0-beta.65",
|
||||
"@effect/ai-anthropic": "4.0.0-beta.74",
|
||||
"@effect/ai-openai": "4.0.0-beta.74",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.74",
|
||||
"@effect/atom-react": "4.0.0-beta.74",
|
||||
"@effect/openapi-generator": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-browser": "4.0.0-beta.74",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/ws": "^8.5.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6"
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ program
|
|||
.name("tg")
|
||||
.description("TrustGraph CLI — interact with TrustGraph services")
|
||||
.version("0.1.0")
|
||||
.option("-g, --gateway <url>", "Gateway WebSocket URL", "ws://localhost:8088/api/v1/socket")
|
||||
.option("-g, --gateway <url>", "Gateway WebSocket URL", "ws://localhost:8088/api/v1/rpc")
|
||||
.option("-u, --user <id>", "User identifier", "cli")
|
||||
.option("-t, --token <token>", "Authentication token")
|
||||
.option("-f, --flow <id>", "Flow ID", "default");
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"test": "bunx --bun vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect": "4.0.0-beta.65"
|
||||
"effect": "4.0.0-beta.74"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.0.0"
|
||||
|
|
@ -23,9 +23,10 @@
|
|||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6",
|
||||
"happy-dom": "^20.0.0"
|
||||
|
|
|
|||
|
|
@ -1,285 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { ServiceCallMulti } from "../socket/service-call-multi";
|
||||
|
||||
// Mock WebSocket constants
|
||||
vi.stubGlobal("WebSocket", {
|
||||
OPEN: 1,
|
||||
CONNECTING: 0,
|
||||
CLOSING: 2,
|
||||
CLOSED: 3,
|
||||
});
|
||||
|
||||
// Mock Socket interface
|
||||
const mockSocket = {
|
||||
inflight: {} as Record<string, unknown>,
|
||||
ws: {
|
||||
send: vi.fn(),
|
||||
readyState: 1, // WebSocket.OPEN
|
||||
},
|
||||
reopen: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock setTimeout and clearTimeout
|
||||
const mockSetTimeout = vi.fn();
|
||||
const mockClearTimeout = vi.fn();
|
||||
|
||||
vi.stubGlobal("setTimeout", mockSetTimeout);
|
||||
vi.stubGlobal("clearTimeout", mockClearTimeout);
|
||||
|
||||
describe("ServiceCallMulti", () => {
|
||||
let mockSuccess: ReturnType<typeof vi.fn>;
|
||||
let mockError: ReturnType<typeof vi.fn>;
|
||||
let mockReceiver: ReturnType<typeof vi.fn>;
|
||||
let serviceCallMulti: ServiceCallMulti;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSuccess = vi.fn();
|
||||
mockError = vi.fn();
|
||||
mockReceiver = vi.fn();
|
||||
mockSocket.inflight = {} as Record<string, unknown>;
|
||||
mockSocket.ws = {
|
||||
send: vi.fn(),
|
||||
readyState: 1, // WebSocket.OPEN
|
||||
};
|
||||
mockSocket.reopen.mockClear();
|
||||
|
||||
serviceCallMulti = new ServiceCallMulti(
|
||||
"test-mid",
|
||||
{ id: "test-id", service: "test-service", request: { test: "data" } },
|
||||
mockSuccess,
|
||||
mockError,
|
||||
5000, // 5 second timeout
|
||||
3, // 3 retries
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockSocket as any,
|
||||
mockReceiver,
|
||||
);
|
||||
});
|
||||
|
||||
it("should initialize with correct properties", () => {
|
||||
expect(serviceCallMulti.mid).toBe("test-mid");
|
||||
expect(serviceCallMulti.timeout).toBe(5000);
|
||||
expect(serviceCallMulti.retries).toBe(3);
|
||||
expect(serviceCallMulti.complete).toBe(false);
|
||||
expect(serviceCallMulti.socket).toBe(mockSocket);
|
||||
expect(serviceCallMulti.receiver).toBe(mockReceiver);
|
||||
});
|
||||
|
||||
it("should register itself in socket inflight when started", () => {
|
||||
serviceCallMulti.start();
|
||||
|
||||
expect(mockSocket.inflight["test-mid"]).toBe(serviceCallMulti);
|
||||
});
|
||||
|
||||
it("should send message on successful attempt", () => {
|
||||
serviceCallMulti.start();
|
||||
|
||||
expect(mockSocket.ws.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
id: "test-id",
|
||||
service: "test-service",
|
||||
request: { test: "data" },
|
||||
}),
|
||||
);
|
||||
expect(mockSetTimeout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle response when receiver returns true (completion)", () => {
|
||||
mockReceiver.mockReturnValue(true); // Signal completion
|
||||
const response = { result: "success" };
|
||||
|
||||
serviceCallMulti.start();
|
||||
serviceCallMulti.onReceived(response);
|
||||
|
||||
expect(mockReceiver).toHaveBeenCalledWith(response);
|
||||
expect(serviceCallMulti.complete).toBe(true);
|
||||
expect(mockSuccess).toHaveBeenCalledWith(response);
|
||||
expect(mockClearTimeout).toHaveBeenCalled();
|
||||
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle response when receiver returns false (continue)", () => {
|
||||
mockReceiver.mockReturnValue(false); // Signal to continue
|
||||
const response = { partial: "data" };
|
||||
|
||||
serviceCallMulti.start();
|
||||
serviceCallMulti.onReceived(response);
|
||||
|
||||
expect(mockReceiver).toHaveBeenCalledWith(response);
|
||||
expect(serviceCallMulti.complete).toBe(false);
|
||||
expect(mockSuccess).not.toHaveBeenCalled();
|
||||
expect(mockClearTimeout).not.toHaveBeenCalled();
|
||||
expect(mockSocket.inflight["test-mid"]).toBe(serviceCallMulti);
|
||||
});
|
||||
|
||||
it("should handle timeout and retry", () => {
|
||||
serviceCallMulti.start();
|
||||
|
||||
// Initial retries should be 3, but start() calls attempt() which decrements to 2
|
||||
expect(serviceCallMulti.retries).toBe(2);
|
||||
|
||||
// Simulate timeout
|
||||
serviceCallMulti.onTimeout();
|
||||
|
||||
expect(mockClearTimeout).toHaveBeenCalled();
|
||||
expect(serviceCallMulti.retries).toBe(1); // Should decrement from 2 to 1
|
||||
});
|
||||
|
||||
it("should exhaust retries and call error callback", () => {
|
||||
// Set retries to 0 to force immediate failure
|
||||
serviceCallMulti.retries = 0;
|
||||
|
||||
serviceCallMulti.start();
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith("Ran out of retries");
|
||||
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle WebSocket send failure", () => {
|
||||
mockSocket.ws.send.mockImplementation(() => {
|
||||
throw new Error("Connection failed");
|
||||
});
|
||||
|
||||
serviceCallMulti.start();
|
||||
|
||||
expect(mockSocket.reopen).toHaveBeenCalled();
|
||||
|
||||
// With exponential backoff, the delay should be calculated as:
|
||||
// SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - retries) + random
|
||||
// Since retries is decremented to 2 after start(), it's 3 - 2 = 1
|
||||
// So base delay is 2000 * 2^1 = 4000, plus random up to 1000
|
||||
// The delay should be between 4000 and 5000ms (capped at 30000)
|
||||
const callArgs = mockSetTimeout.mock.calls[0];
|
||||
expect(callArgs[0]).toEqual(expect.any(Function));
|
||||
expect(callArgs[1]).toBeGreaterThanOrEqual(4000);
|
||||
expect(callArgs[1]).toBeLessThanOrEqual(5000);
|
||||
});
|
||||
|
||||
it("should handle missing WebSocket connection", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mockSocket as any).ws = null;
|
||||
|
||||
serviceCallMulti.start();
|
||||
|
||||
// Should trigger reopen and schedule with exponential backoff
|
||||
expect(mockSocket.reopen).toHaveBeenCalled();
|
||||
|
||||
// Same calculation as above - base delay 4000ms + random up to 1000ms
|
||||
const callArgs = mockSetTimeout.mock.calls[0];
|
||||
expect(callArgs[0]).toEqual(expect.any(Function));
|
||||
expect(callArgs[1]).toBeGreaterThanOrEqual(4000);
|
||||
expect(callArgs[1]).toBeLessThanOrEqual(5000);
|
||||
});
|
||||
|
||||
it("should not process response if already complete", () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
serviceCallMulti.complete = true;
|
||||
serviceCallMulti.onReceived({ result: "test" });
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"test-mid",
|
||||
"should not happen, request is already complete",
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not timeout if already complete", () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
serviceCallMulti.complete = true;
|
||||
serviceCallMulti.onTimeout();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"test-mid",
|
||||
"timeout should not happen, request is already complete",
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not attempt if already complete", () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
serviceCallMulti.complete = true;
|
||||
serviceCallMulti.attempt();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"test-mid",
|
||||
"attempt should not be called, request is already complete",
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should handle streaming responses correctly", () => {
|
||||
mockReceiver
|
||||
.mockReturnValueOnce(false) // First response - continue
|
||||
.mockReturnValueOnce(false) // Second response - continue
|
||||
.mockReturnValueOnce(true); // Third response - complete
|
||||
|
||||
serviceCallMulti.start();
|
||||
|
||||
// First response
|
||||
serviceCallMulti.onReceived({ chunk: 1 });
|
||||
expect(serviceCallMulti.complete).toBe(false);
|
||||
expect(mockSuccess).not.toHaveBeenCalled();
|
||||
|
||||
// Second response
|
||||
serviceCallMulti.onReceived({ chunk: 2 });
|
||||
expect(serviceCallMulti.complete).toBe(false);
|
||||
expect(mockSuccess).not.toHaveBeenCalled();
|
||||
|
||||
// Third response (final)
|
||||
serviceCallMulti.onReceived({ chunk: 3, final: true });
|
||||
expect(serviceCallMulti.complete).toBe(true);
|
||||
expect(mockSuccess).toHaveBeenCalledWith({ chunk: 3, final: true });
|
||||
});
|
||||
|
||||
it("should handle receiver function errors gracefully", () => {
|
||||
mockReceiver.mockImplementation(() => {
|
||||
throw new Error("Receiver error");
|
||||
});
|
||||
|
||||
serviceCallMulti.start();
|
||||
|
||||
expect(() => {
|
||||
serviceCallMulti.onReceived({ test: "data" });
|
||||
}).toThrow("Receiver error");
|
||||
});
|
||||
|
||||
it("should handle multiple timeout scenarios", () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
serviceCallMulti.start();
|
||||
|
||||
// After start, retries should be 2 (decremented from 3)
|
||||
expect(serviceCallMulti.retries).toBe(2);
|
||||
|
||||
// First timeout
|
||||
serviceCallMulti.onTimeout();
|
||||
expect(serviceCallMulti.retries).toBe(1);
|
||||
|
||||
// Second timeout
|
||||
serviceCallMulti.onTimeout();
|
||||
expect(serviceCallMulti.retries).toBe(0);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should clean up properly when receiver signals completion", () => {
|
||||
mockReceiver.mockReturnValue(true);
|
||||
|
||||
serviceCallMulti.start();
|
||||
|
||||
const response = { final: true };
|
||||
serviceCallMulti.onReceived(response);
|
||||
|
||||
expect(serviceCallMulti.complete).toBe(true);
|
||||
expect(mockClearTimeout).toHaveBeenCalled();
|
||||
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
|
||||
expect(mockSuccess).toHaveBeenCalledWith(response);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { ServiceCall } from "../socket/service-call";
|
||||
|
||||
// Mock WebSocket constants
|
||||
vi.stubGlobal("WebSocket", {
|
||||
OPEN: 1,
|
||||
CONNECTING: 0,
|
||||
CLOSING: 2,
|
||||
CLOSED: 3,
|
||||
});
|
||||
|
||||
// Mock Socket interface
|
||||
const mockSocket = {
|
||||
inflight: {} as Record<string, unknown>,
|
||||
ws: {
|
||||
send: vi.fn(),
|
||||
readyState: 1, // WebSocket.OPEN
|
||||
},
|
||||
reopen: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock setTimeout and clearTimeout
|
||||
const mockSetTimeout = vi.fn();
|
||||
const mockClearTimeout = vi.fn();
|
||||
|
||||
vi.stubGlobal("setTimeout", mockSetTimeout);
|
||||
vi.stubGlobal("clearTimeout", mockClearTimeout);
|
||||
|
||||
describe("ServiceCall", () => {
|
||||
let mockSuccess: ReturnType<typeof vi.fn>;
|
||||
let mockError: ReturnType<typeof vi.fn>;
|
||||
let serviceCall: ServiceCall;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSuccess = vi.fn();
|
||||
mockError = vi.fn();
|
||||
mockSocket.inflight = {} as Record<string, unknown>;
|
||||
mockSocket.ws = {
|
||||
send: vi.fn(),
|
||||
readyState: 1, // WebSocket.OPEN
|
||||
};
|
||||
mockSocket.reopen.mockClear();
|
||||
|
||||
serviceCall = new ServiceCall(
|
||||
"test-mid",
|
||||
{ id: "test-id", service: "test-service", request: { test: "data" } },
|
||||
mockSuccess,
|
||||
mockError,
|
||||
5000, // 5 second timeout
|
||||
3, // 3 retries
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockSocket as any,
|
||||
);
|
||||
});
|
||||
|
||||
it("should initialize with correct properties", () => {
|
||||
expect(serviceCall.mid).toBe("test-mid");
|
||||
expect(serviceCall.timeout).toBe(5000);
|
||||
expect(serviceCall.retries).toBe(3);
|
||||
expect(serviceCall.complete).toBe(false);
|
||||
expect(serviceCall.socket).toBe(mockSocket);
|
||||
});
|
||||
|
||||
it("should register itself in socket inflight when started", () => {
|
||||
serviceCall.start();
|
||||
|
||||
expect(mockSocket.inflight["test-mid"]).toBe(serviceCall);
|
||||
});
|
||||
|
||||
it("should send message on successful attempt", () => {
|
||||
serviceCall.start();
|
||||
|
||||
expect(mockSocket.ws.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
id: "test-id",
|
||||
service: "test-service",
|
||||
request: { test: "data" },
|
||||
}),
|
||||
);
|
||||
expect(mockSetTimeout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle successful response", () => {
|
||||
const responseData = { result: "success" };
|
||||
const message = { response: responseData };
|
||||
|
||||
serviceCall.start();
|
||||
serviceCall.onReceived(message);
|
||||
|
||||
expect(serviceCall.complete).toBe(true);
|
||||
expect(mockSuccess).toHaveBeenCalledWith(responseData);
|
||||
expect(mockClearTimeout).toHaveBeenCalled();
|
||||
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle timeout and retry", () => {
|
||||
serviceCall.start();
|
||||
|
||||
// Initial retries should be 3, but start() calls attempt() which decrements to 2
|
||||
expect(serviceCall.retries).toBe(2);
|
||||
|
||||
// Simulate timeout
|
||||
serviceCall.onTimeout();
|
||||
|
||||
expect(mockClearTimeout).toHaveBeenCalled();
|
||||
expect(serviceCall.retries).toBe(1); // Should decrement from 2 to 1
|
||||
});
|
||||
|
||||
it("should exhaust retries and call error callback", () => {
|
||||
// Set retries to 0 to force immediate failure
|
||||
serviceCall.retries = 0;
|
||||
|
||||
serviceCall.start();
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith("Ran out of retries");
|
||||
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle WebSocket send failure", () => {
|
||||
mockSocket.ws.send.mockImplementation(() => {
|
||||
throw new Error("Connection failed");
|
||||
});
|
||||
|
||||
serviceCall.start();
|
||||
|
||||
// Should NOT call reopen anymore - BaseApi handles reconnection
|
||||
expect(mockSocket.reopen).not.toHaveBeenCalled();
|
||||
|
||||
// With exponential backoff, the delay should be calculated as:
|
||||
// SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - retries) + random
|
||||
// Since retries is decremented to 2 after start(), it's 3 - 2 = 1
|
||||
// So base delay is 2000 * 2^1 = 4000, plus random up to 1000
|
||||
// The delay should be between 4000 and 5000ms (capped at 30000)
|
||||
const callArgs = mockSetTimeout.mock.calls[0];
|
||||
expect(callArgs[0]).toEqual(expect.any(Function));
|
||||
expect(callArgs[1]).toBeGreaterThanOrEqual(4000);
|
||||
expect(callArgs[1]).toBeLessThanOrEqual(5000);
|
||||
});
|
||||
|
||||
it("should handle missing WebSocket connection", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mockSocket as any).ws = null;
|
||||
|
||||
serviceCall.start();
|
||||
|
||||
// Should NOT trigger reopen - just wait for BaseApi to reconnect
|
||||
expect(mockSocket.reopen).not.toHaveBeenCalled();
|
||||
|
||||
// Same calculation as above - base delay 4000ms + random up to 1000ms
|
||||
const callArgs = mockSetTimeout.mock.calls[0];
|
||||
expect(callArgs[0]).toEqual(expect.any(Function));
|
||||
expect(callArgs[1]).toBeGreaterThanOrEqual(4000);
|
||||
expect(callArgs[1]).toBeLessThanOrEqual(5000);
|
||||
});
|
||||
|
||||
it("should not process response if already complete", () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
serviceCall.complete = true;
|
||||
serviceCall.onReceived({ result: "test" });
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"test-mid",
|
||||
"should not happen, request is already complete",
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not timeout if already complete", () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
serviceCall.complete = true;
|
||||
serviceCall.onTimeout();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"test-mid",
|
||||
"timeout should not happen, request is already complete",
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not attempt if already complete", () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
serviceCall.complete = true;
|
||||
serviceCall.attempt();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"test-mid",
|
||||
"attempt should not be called, request is already complete",
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should handle multiple retries correctly", () => {
|
||||
mockSocket.ws.send.mockImplementation(() => {
|
||||
throw new Error("Connection failed");
|
||||
});
|
||||
|
||||
serviceCall.start();
|
||||
|
||||
// Should have decremented retries and scheduled a retry
|
||||
expect(serviceCall.retries).toBe(2);
|
||||
// Should NOT call reopen - BaseApi handles reconnection
|
||||
expect(mockSocket.reopen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should clean up properly on successful response", () => {
|
||||
serviceCall.start();
|
||||
|
||||
const responseData = { success: true };
|
||||
const message = { response: responseData };
|
||||
serviceCall.onReceived(message);
|
||||
|
||||
expect(serviceCall.complete).toBe(true);
|
||||
expect(mockClearTimeout).toHaveBeenCalled();
|
||||
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
|
||||
expect(mockSuccess).toHaveBeenCalledWith(responseData);
|
||||
});
|
||||
|
||||
it("should handle edge case of negative retries", () => {
|
||||
serviceCall.retries = -1;
|
||||
|
||||
serviceCall.attempt();
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith("Ran out of retries");
|
||||
});
|
||||
|
||||
it("should bind timeout callbacks correctly", () => {
|
||||
serviceCall.start();
|
||||
|
||||
// Verify that setTimeout was called with a bound function
|
||||
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 5000);
|
||||
});
|
||||
});
|
||||
195
ts/packages/client/src/__tests__/workbench-contracts.test.ts
Normal file
195
ts/packages/client/src/__tests__/workbench-contracts.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
BaseApi,
|
||||
ConfigApi,
|
||||
KnowledgeApi,
|
||||
LibrarianApi,
|
||||
} from "../socket/trustgraph-socket";
|
||||
|
||||
function makeApi() {
|
||||
const makeRequest = vi.fn();
|
||||
const base = {
|
||||
user: "alice",
|
||||
makeRequest,
|
||||
} as unknown as BaseApi;
|
||||
return { base, makeRequest };
|
||||
}
|
||||
|
||||
describe("workbench API contracts", () => {
|
||||
describe("ConfigApi", () => {
|
||||
it("returns Python-style getvalues entries", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
makeRequest.mockResolvedValue({
|
||||
values: [{ type: "prompt", key: "welcome", value: "hello" }],
|
||||
});
|
||||
|
||||
const result = await new ConfigApi(base).getValues("prompt");
|
||||
|
||||
expect(makeRequest).toHaveBeenCalledWith(
|
||||
"config",
|
||||
{ operation: "getvalues", type: "prompt" },
|
||||
60000,
|
||||
);
|
||||
expect(result).toEqual([{ type: "prompt", key: "welcome", value: "hello" }]);
|
||||
});
|
||||
|
||||
it("parses token-cost values stored as config JSON strings", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
makeRequest.mockResolvedValue({
|
||||
values: [
|
||||
{
|
||||
type: "token-cost",
|
||||
key: "gpt-test",
|
||||
value: JSON.stringify({ input_price: 0.1, output_price: 0.2 }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await new ConfigApi(base).getTokenCosts();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ model: "gpt-test", input_price: 0.1, output_price: 0.2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes and deletes config using Python-style key/value arrays", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
makeRequest.mockResolvedValue({});
|
||||
const config = new ConfigApi(base);
|
||||
|
||||
await config.putConfig([{ type: "tool", key: "search", value: "{}" }]);
|
||||
await config.deleteConfig({ type: "tool", key: "search" });
|
||||
|
||||
expect(makeRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"config",
|
||||
{
|
||||
operation: "put",
|
||||
values: [{ type: "tool", key: "search", value: "{}" }],
|
||||
},
|
||||
60000,
|
||||
);
|
||||
expect(makeRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"config",
|
||||
{
|
||||
operation: "delete",
|
||||
keys: [{ type: "tool", key: "search" }],
|
||||
},
|
||||
30000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LibrarianApi", () => {
|
||||
it("reads Python-style document and processing list responses", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
const document = { id: "doc-1", title: "Document" };
|
||||
const processing = { id: "proc-1", "document-id": "doc-1" };
|
||||
const librarian = new LibrarianApi(base);
|
||||
|
||||
makeRequest
|
||||
.mockResolvedValueOnce({ "document-metadatas": [document] })
|
||||
.mockResolvedValueOnce({ "processing-metadatas": [processing] });
|
||||
|
||||
await expect(librarian.getDocuments()).resolves.toEqual([document]);
|
||||
await expect(librarian.getProcessing()).resolves.toEqual([processing]);
|
||||
});
|
||||
|
||||
it("sends both kebab-case and camel-case document identifiers", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
const document = { id: "doc-1", title: "Document" };
|
||||
makeRequest.mockResolvedValue({ "document-metadata": document });
|
||||
|
||||
const result = await new LibrarianApi(base).getDocumentMetadata("doc-1");
|
||||
|
||||
expect(makeRequest).toHaveBeenCalledWith(
|
||||
"librarian",
|
||||
{
|
||||
operation: "get-document-metadata",
|
||||
"document-id": "doc-1",
|
||||
documentId: "doc-1",
|
||||
user: "alice",
|
||||
},
|
||||
30000,
|
||||
);
|
||||
expect(result).toEqual(document);
|
||||
});
|
||||
|
||||
it("uploads documents with Python and TypeScript metadata aliases", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
makeRequest.mockResolvedValue({});
|
||||
|
||||
await new LibrarianApi(base).loadDocument(
|
||||
"SGVsbG8=",
|
||||
"text/plain",
|
||||
"Hello",
|
||||
"comment",
|
||||
["tag"],
|
||||
"doc-1",
|
||||
);
|
||||
|
||||
const request = makeRequest.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(request["document-metadata"]).toMatchObject({
|
||||
id: "doc-1",
|
||||
kind: "text/plain",
|
||||
title: "Hello",
|
||||
user: "alice",
|
||||
"document-type": "source",
|
||||
documentType: "source",
|
||||
});
|
||||
expect(request.documentMetadata).toEqual(request["document-metadata"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KnowledgeApi", () => {
|
||||
it("lists and loads document embedding cores", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
const knowledge = new KnowledgeApi(base);
|
||||
|
||||
makeRequest
|
||||
.mockResolvedValueOnce({ ids: ["de-core"] })
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await expect(knowledge.getDocumentEmbeddingCores()).resolves.toEqual(["de-core"]);
|
||||
await knowledge.loadDeCore("de-core", "default", "library");
|
||||
|
||||
expect(makeRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"knowledge",
|
||||
{ operation: "list-de-cores", user: "alice" },
|
||||
60000,
|
||||
);
|
||||
expect(makeRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"knowledge",
|
||||
{
|
||||
operation: "load-de-core",
|
||||
id: "de-core",
|
||||
flow: "default",
|
||||
user: "alice",
|
||||
collection: "library",
|
||||
},
|
||||
30000,
|
||||
);
|
||||
});
|
||||
|
||||
it("unloads knowledge graph cores from a flow", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
makeRequest.mockResolvedValue({});
|
||||
|
||||
await new KnowledgeApi(base).unloadKgCore("kg-core", "default");
|
||||
|
||||
expect(makeRequest).toHaveBeenCalledWith(
|
||||
"knowledge",
|
||||
{
|
||||
operation: "unload-kg-core",
|
||||
id: "kg-core",
|
||||
flow: "default",
|
||||
user: "alice",
|
||||
},
|
||||
30000,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ export * from "./models/namespaces.js";
|
|||
|
||||
// Export socket client
|
||||
export * from "./socket/trustgraph-socket.js";
|
||||
export * from "./rpc/contract.js";
|
||||
|
||||
// Export WebSocket adapter (isomorphic helpers and types)
|
||||
export * from "./socket/websocket-adapter.js";
|
||||
|
|
|
|||
|
|
@ -280,12 +280,16 @@ export interface DocumentMetadata {
|
|||
metadata?: Triple[];
|
||||
user?: string;
|
||||
tags?: string[];
|
||||
parentId?: string;
|
||||
documentType?: string;
|
||||
"parent-id"?: string;
|
||||
"document-type"?: string;
|
||||
}
|
||||
|
||||
export interface ProcessingMetadata {
|
||||
id?: string;
|
||||
"document-id"?: string;
|
||||
documentId?: string;
|
||||
time?: number;
|
||||
flow?: string;
|
||||
user?: string;
|
||||
|
|
@ -295,7 +299,9 @@ export interface ProcessingMetadata {
|
|||
|
||||
export interface LibraryRequest {
|
||||
operation: string;
|
||||
documentId?: string;
|
||||
"document-id"?: string;
|
||||
processingId?: string;
|
||||
"processing-id"?: string;
|
||||
"document-metadata"?: DocumentMetadata;
|
||||
documentMetadata?: DocumentMetadata;
|
||||
|
|
@ -309,12 +315,15 @@ export interface LibraryRequest {
|
|||
}
|
||||
|
||||
export interface LibraryResponse {
|
||||
error: Error;
|
||||
error?: Error;
|
||||
"document-metadata"?: DocumentMetadata;
|
||||
documentMetadata?: DocumentMetadata;
|
||||
content?: string;
|
||||
"document-metadatas"?: DocumentMetadata[];
|
||||
documents?: DocumentMetadata[];
|
||||
"processing-metadata"?: ProcessingMetadata;
|
||||
"processing-metadatas"?: ProcessingMetadata[];
|
||||
processing?: ProcessingMetadata[];
|
||||
}
|
||||
|
||||
export interface KnowledgeRequest {
|
||||
|
|
@ -325,6 +334,9 @@ export interface KnowledgeRequest {
|
|||
collection?: string;
|
||||
triples?: Triple[];
|
||||
"graph-embeddings"?: GraphEmbeddings;
|
||||
graphEmbeddings?: GraphEmbeddings;
|
||||
"document-embeddings"?: unknown;
|
||||
documentEmbeddings?: unknown;
|
||||
}
|
||||
|
||||
export interface KnowledgeResponse {
|
||||
|
|
@ -333,6 +345,9 @@ export interface KnowledgeResponse {
|
|||
eos?: boolean;
|
||||
triples?: Triple[];
|
||||
"graph-embeddings"?: GraphEmbeddings;
|
||||
graphEmbeddings?: GraphEmbeddings;
|
||||
"document-embeddings"?: unknown;
|
||||
documentEmbeddings?: unknown;
|
||||
}
|
||||
|
||||
export interface FlowRequest {
|
||||
|
|
|
|||
35
ts/packages/client/src/rpc/contract.ts
Normal file
35
ts/packages/client/src/rpc/contract.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Schema as S } from "effect";
|
||||
import * as Rpc from "effect/unstable/rpc/Rpc";
|
||||
import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
|
||||
|
||||
export class DispatchPayload extends S.Class<DispatchPayload>("DispatchPayload")({
|
||||
scope: S.Literals(["global", "flow"]),
|
||||
service: S.String,
|
||||
flow: S.optionalKey(S.String),
|
||||
request: S.Record(S.String, S.Unknown),
|
||||
}) {}
|
||||
|
||||
export class DispatchStreamChunk extends S.Class<DispatchStreamChunk>("DispatchStreamChunk")({
|
||||
response: S.Unknown,
|
||||
complete: S.Boolean,
|
||||
}) {}
|
||||
|
||||
export class DispatchError extends S.ErrorClass<DispatchError>("DispatchError")({
|
||||
_tag: S.tag("DispatchError"),
|
||||
message: S.String,
|
||||
}) {}
|
||||
|
||||
export class Dispatch extends Rpc.make("Dispatch", {
|
||||
payload: DispatchPayload,
|
||||
success: S.Unknown,
|
||||
error: DispatchError,
|
||||
}) {}
|
||||
|
||||
export class DispatchStream extends Rpc.make("DispatchStream", {
|
||||
payload: DispatchPayload,
|
||||
success: DispatchStreamChunk,
|
||||
error: DispatchError,
|
||||
stream: true,
|
||||
}) {}
|
||||
|
||||
export const TrustGraphRpcs = RpcGroup.make(Dispatch, DispatchStream);
|
||||
192
ts/packages/client/src/socket/effect-rpc-client.ts
Normal file
192
ts/packages/client/src/socket/effect-rpc-client.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { Context, Data, Effect, Exit, Layer, Scope, Stream } from "effect";
|
||||
import type * as RpcGroup from "effect/unstable/rpc/RpcGroup";
|
||||
import * as RpcClient from "effect/unstable/rpc/RpcClient";
|
||||
import type { RpcClientError } from "effect/unstable/rpc/RpcClientError";
|
||||
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
||||
import * as Socket from "effect/unstable/socket/Socket";
|
||||
import { DispatchPayload, DispatchError, TrustGraphRpcs, type DispatchStreamChunk } from "../rpc/contract.js";
|
||||
|
||||
type TrustGraphRpcClient = RpcClient.RpcClient<
|
||||
RpcGroup.Rpcs<typeof TrustGraphRpcs>,
|
||||
RpcClientError
|
||||
>;
|
||||
|
||||
class TrustGraphRpcClientService extends Context.Service<
|
||||
TrustGraphRpcClientService,
|
||||
TrustGraphRpcClient
|
||||
>()("@trustgraph/client/socket/effect-rpc-client/TrustGraphRpcClientService") {}
|
||||
|
||||
export type RpcConnectionStatus = "connecting" | "connected" | "failed" | "closed";
|
||||
|
||||
export interface RpcConnectionState {
|
||||
status: RpcConnectionStatus;
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface DispatchInput {
|
||||
scope: "global" | "flow";
|
||||
service: string;
|
||||
flow?: string;
|
||||
request: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class EffectRpcClient {
|
||||
private readonly url: string;
|
||||
private readonly onConnect: (() => void) | undefined;
|
||||
private readonly onDisconnect: (() => void) | undefined;
|
||||
private readonly scopePromise: Promise<Scope.Scope>;
|
||||
private readonly clientPromise: Promise<TrustGraphRpcClient>;
|
||||
private readonly listeners = new Set<(state: RpcConnectionState) => void>();
|
||||
private state: RpcConnectionState = { status: "connecting" };
|
||||
private closed = false;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
onConnect?: () => void,
|
||||
onDisconnect?: () => void,
|
||||
) {
|
||||
this.url = url;
|
||||
this.onConnect = onConnect;
|
||||
this.onDisconnect = onDisconnect;
|
||||
this.scopePromise = Effect.runPromise(Scope.make());
|
||||
this.clientPromise = this.scopePromise.then((scope) =>
|
||||
Effect.runPromise(this.makeClient().pipe(Scope.provide(scope))),
|
||||
);
|
||||
this.clientPromise.catch((cause) => {
|
||||
this.setState({
|
||||
status: "failed",
|
||||
lastError: errorMessage(cause),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(listener: (state: RpcConnectionState) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.state);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async dispatch(input: DispatchInput): Promise<unknown> {
|
||||
const client = await this.clientPromise;
|
||||
return await Effect.runPromise(client.Dispatch(new DispatchPayload(input)));
|
||||
}
|
||||
|
||||
async dispatchStream(
|
||||
input: DispatchInput,
|
||||
receiver: (chunk: DispatchStreamChunk) => boolean,
|
||||
): Promise<DispatchStreamChunk | undefined> {
|
||||
const client = await this.clientPromise;
|
||||
let last: DispatchStreamChunk | undefined;
|
||||
await Effect.runPromise(
|
||||
client.DispatchStream(new DispatchPayload(input)).pipe(
|
||||
Stream.runForEach((chunk) =>
|
||||
Effect.suspend(() => {
|
||||
last = chunk;
|
||||
if (receiver(chunk)) return Effect.fail(new StopStreaming());
|
||||
return Effect.void;
|
||||
}),
|
||||
),
|
||||
Effect.catchIf(
|
||||
(cause): cause is StopStreaming => cause instanceof StopStreaming,
|
||||
() => Effect.void,
|
||||
),
|
||||
),
|
||||
);
|
||||
return last;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.setState({ status: "closed" });
|
||||
const scope = await this.scopePromise;
|
||||
await Effect.runPromise(Scope.close(scope, Exit.void));
|
||||
}
|
||||
|
||||
private makeClient(): Effect.Effect<TrustGraphRpcClient, never, Scope.Scope> {
|
||||
const socketLayer = Layer.effect(
|
||||
Socket.Socket,
|
||||
Socket.makeWebSocket(this.url, {
|
||||
closeCodeIsError: (code) => code !== 1000,
|
||||
openTimeout: "10 seconds",
|
||||
}),
|
||||
).pipe(Layer.provide(webSocketConstructorLayer));
|
||||
|
||||
const hooksLayer = Layer.succeed(
|
||||
RpcClient.ConnectionHooks,
|
||||
RpcClient.ConnectionHooks.of({
|
||||
onConnect: Effect.sync(() => {
|
||||
this.setState({ status: "connected" });
|
||||
this.onConnect?.();
|
||||
}),
|
||||
onDisconnect: Effect.sync(() => {
|
||||
if (!this.closed) {
|
||||
this.setState({
|
||||
status: "connecting",
|
||||
lastError: "Disconnected from gateway",
|
||||
});
|
||||
}
|
||||
this.onDisconnect?.();
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const protocolLayer = RpcClient.layerProtocolSocket({
|
||||
retryTransientErrors: true,
|
||||
}).pipe(
|
||||
Layer.provide(socketLayer),
|
||||
Layer.provide(RpcSerialization.layerNdjson),
|
||||
Layer.provide(hooksLayer),
|
||||
);
|
||||
|
||||
const clientLayer = Layer.effect(
|
||||
TrustGraphRpcClientService,
|
||||
RpcClient.make(TrustGraphRpcs),
|
||||
).pipe(Layer.provide(protocolLayer));
|
||||
|
||||
return Effect.map(
|
||||
Layer.build(clientLayer),
|
||||
(context) => Context.get(context, TrustGraphRpcClientService),
|
||||
);
|
||||
}
|
||||
|
||||
private setState(state: RpcConnectionState): void {
|
||||
this.state = state;
|
||||
for (const listener of this.listeners) {
|
||||
listener(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class StopStreaming extends Data.TaggedError("StopStreaming")<{}> {}
|
||||
|
||||
const webSocketConstructorLayer: Layer.Layer<Socket.WebSocketConstructor> = Layer.effect(
|
||||
Socket.WebSocketConstructor,
|
||||
Effect.promise(async () => {
|
||||
if (typeof globalThis !== "undefined" && "WebSocket" in globalThis) {
|
||||
return (url, protocols) => new globalThis.WebSocket(url, protocols);
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = await import("ws");
|
||||
const WS = mod.WebSocket;
|
||||
return (url, protocols) => new WS(url, protocols) as unknown as globalThis.WebSocket;
|
||||
} catch (cause) {
|
||||
throw new DispatchError({
|
||||
message: `WebSocket is not available: ${errorMessage(cause)}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
function errorMessage(cause: unknown): string {
|
||||
if (cause instanceof Error) return cause.message;
|
||||
if (typeof cause === "string") return cause;
|
||||
if (cause !== null && typeof cause === "object" && "message" in cause) {
|
||||
const message = (cause as { message?: unknown }).message;
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return String(cause);
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
import type { RequestMessage } from "../models/messages.js";
|
||||
import { WS_OPEN, WS_CONNECTING, type IsomorphicWebSocket } from "./websocket-adapter.js";
|
||||
|
||||
// Constant defining the delay before attempting to reconnect a WebSocket
|
||||
// (2 seconds)
|
||||
export const SOCKET_RECONNECTION_TIMEOUT = 2000;
|
||||
|
||||
// Forward declare Socket type to avoid circular dependency
|
||||
// Using a minimal interface that matches what BaseApi provides
|
||||
interface Socket {
|
||||
ws: IsomorphicWebSocket | null | undefined;
|
||||
inflight: {
|
||||
[key: string]: {
|
||||
onReceived: (resp: object) => void;
|
||||
retryNow: () => void;
|
||||
error: (err: object | string) => void;
|
||||
};
|
||||
};
|
||||
reopen: () => void;
|
||||
getNextId?: () => string;
|
||||
user?: string;
|
||||
}
|
||||
|
||||
export class ServiceCallMulti {
|
||||
constructor(
|
||||
mid: string,
|
||||
msg: RequestMessage,
|
||||
success: (resp: unknown) => void,
|
||||
error: (err: object | string) => void,
|
||||
timeout: number,
|
||||
retries: number,
|
||||
socket: Socket,
|
||||
receiver: (resp: unknown) => boolean,
|
||||
) {
|
||||
this.mid = mid;
|
||||
this.msg = msg;
|
||||
this.success = success;
|
||||
this.error = error;
|
||||
this.timeout = timeout;
|
||||
this.retries = retries;
|
||||
this.socket = socket;
|
||||
this.complete = false;
|
||||
this.receiver = receiver;
|
||||
}
|
||||
|
||||
mid: string;
|
||||
msg: RequestMessage;
|
||||
success: (resp: unknown) => void;
|
||||
error: (err: object | string) => void;
|
||||
receiver: (resp: unknown) => boolean;
|
||||
timeoutId: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
timeout: number;
|
||||
retries: number;
|
||||
socket: Socket;
|
||||
complete: boolean;
|
||||
|
||||
start() {
|
||||
this.socket.inflight[this.mid] = this;
|
||||
this.attempt();
|
||||
}
|
||||
|
||||
onReceived(resp: object) {
|
||||
if (this.complete == true)
|
||||
console.log(this.mid, "should not happen, request is already complete");
|
||||
|
||||
const fin = this.receiver(resp);
|
||||
|
||||
if (fin) {
|
||||
this.complete = true;
|
||||
|
||||
// console.log("Received for", this.mid);
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = undefined;
|
||||
delete this.socket.inflight[this.mid];
|
||||
this.success(resp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when socket connects - immediately retry if we were waiting
|
||||
*/
|
||||
retryNow() {
|
||||
if (this.complete) return;
|
||||
|
||||
// Clear any pending backoff timer
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = undefined;
|
||||
|
||||
// Restore retry count since we didn't actually fail
|
||||
this.retries++;
|
||||
|
||||
// Attempt immediately
|
||||
this.attempt();
|
||||
}
|
||||
|
||||
onTimeout() {
|
||||
if (this.complete == true)
|
||||
console.log(
|
||||
this.mid,
|
||||
"timeout should not happen, request is already complete",
|
||||
);
|
||||
|
||||
console.log("Request", this.mid, "timed out");
|
||||
clearTimeout(this.timeoutId);
|
||||
this.attempt();
|
||||
}
|
||||
|
||||
attempt() {
|
||||
// console.log("attempt:", this.mid);
|
||||
|
||||
if (this.complete == true)
|
||||
console.log(
|
||||
this.mid,
|
||||
"attempt should not be called, request is already complete",
|
||||
);
|
||||
|
||||
this.retries--;
|
||||
|
||||
if (this.retries < 0) {
|
||||
console.log("Request", this.mid, "ran out of retries");
|
||||
|
||||
clearTimeout(this.timeoutId);
|
||||
delete this.socket.inflight[this.mid];
|
||||
|
||||
this.error("Ran out of retries");
|
||||
return; // Exit early - no more attempts
|
||||
}
|
||||
|
||||
// Check if WebSocket connection is available and ready
|
||||
if (this.socket.ws !== null && this.socket.ws !== undefined && this.socket.ws.readyState === WS_OPEN) {
|
||||
try {
|
||||
this.socket.ws.send(JSON.stringify(this.msg));
|
||||
this.timeoutId = setTimeout(this.onTimeout.bind(this), this.timeout);
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
console.log("Error:", e);
|
||||
console.log("Message send failure, retry...");
|
||||
|
||||
// Calculate backoff delay with jitter
|
||||
const backoffDelay = Math.min(
|
||||
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) +
|
||||
Math.random() * 1000,
|
||||
30000, // Max 30 seconds
|
||||
);
|
||||
|
||||
this.timeoutId = setTimeout(this.attempt.bind(this), backoffDelay);
|
||||
|
||||
console.log("Reopen...");
|
||||
// Attempt to reopen the WebSocket connection
|
||||
this.socket.reopen();
|
||||
}
|
||||
} else {
|
||||
// No WebSocket connection available or not ready
|
||||
// Check if socket is connecting
|
||||
if (
|
||||
this.socket.ws !== null &&
|
||||
this.socket.ws !== undefined &&
|
||||
this.socket.ws.readyState === WS_CONNECTING
|
||||
) {
|
||||
// Wait a bit longer for connection to establish
|
||||
setTimeout(this.attempt.bind(this), 500);
|
||||
} else {
|
||||
// Socket is closed or closing, trigger reopen
|
||||
console.log("Socket not ready, reopening...");
|
||||
this.socket.reopen();
|
||||
|
||||
// Calculate backoff delay
|
||||
const backoffDelay = Math.min(
|
||||
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) +
|
||||
Math.random() * 1000,
|
||||
30000,
|
||||
);
|
||||
|
||||
setTimeout(this.attempt.bind(this), backoffDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
import type { RequestMessage } from "../models/messages.js";
|
||||
import { WS_OPEN, type IsomorphicWebSocket } from "./websocket-adapter.js";
|
||||
|
||||
// Constant defining the delay before attempting to reconnect a WebSocket
|
||||
// (2 seconds)
|
||||
export const SOCKET_RECONNECTION_TIMEOUT = 2000;
|
||||
|
||||
// Forward declare Socket type to avoid circular dependency
|
||||
// Using a minimal interface that matches what BaseApi provides
|
||||
interface Socket {
|
||||
ws: IsomorphicWebSocket | null | undefined;
|
||||
inflight: {
|
||||
[key: string]: {
|
||||
onReceived: (resp: object) => void;
|
||||
retryNow: () => void;
|
||||
error: (err: object | string) => void;
|
||||
};
|
||||
};
|
||||
reopen: () => void;
|
||||
getNextId?: () => string;
|
||||
user?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceCall represents a single request/response cycle over a WebSocket
|
||||
* connection with built-in retry logic, timeout handling, and completion
|
||||
* tracking.
|
||||
*
|
||||
* This class manages the lifecycle of a service call including:
|
||||
* - Sending the initial request
|
||||
* - Handling timeouts and retries
|
||||
* - Managing completion state
|
||||
* - Cleaning up resources
|
||||
*/
|
||||
export class ServiceCall {
|
||||
constructor(
|
||||
mid: string, // Message ID - unique identifier for this request
|
||||
msg: RequestMessage, // The actual message/request to send
|
||||
success: (resp: unknown) => void, // Callback function called on
|
||||
// successful response
|
||||
error: (err: object | string) => void, // Callback function called on error/failure
|
||||
timeout: number, // Timeout duration in milliseconds
|
||||
retries: number, // Number of retry attempts allowed
|
||||
socket: Socket, // WebSocket instance to send the message through
|
||||
) {
|
||||
this.mid = mid;
|
||||
this.msg = msg;
|
||||
this.success = success;
|
||||
this.error = error;
|
||||
this.timeout = timeout;
|
||||
this.retries = retries;
|
||||
this.socket = socket;
|
||||
this.complete = false; // Track if this request has completed
|
||||
}
|
||||
|
||||
// Properties
|
||||
mid: string; // Message identifier
|
||||
msg: RequestMessage; // The request message
|
||||
success: (resp: unknown) => void; // Success callback
|
||||
error: (err: object | string) => void; // Error callback
|
||||
timeoutId: ReturnType<typeof setTimeout> | undefined = undefined; // Reference to the active timeout timer
|
||||
timeout: number; // Timeout duration in milliseconds
|
||||
retries: number; // Remaining retry attempts
|
||||
socket: Socket; // WebSocket connection reference
|
||||
complete: boolean; // Flag indicating if request is complete
|
||||
|
||||
/**
|
||||
* Initiates the service call by registering it with the socket's inflight
|
||||
* requests and making the first attempt to send the message
|
||||
*/
|
||||
start() {
|
||||
// Register this request as "in-flight" so responses can be matched to it
|
||||
this.socket.inflight[this.mid] = this;
|
||||
// Make the first attempt to send the message
|
||||
this.attempt();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a response is received for this request
|
||||
* Handles cleanup and calls the success or error callback based on response
|
||||
*
|
||||
* @param resp - The response object received from the server
|
||||
*/
|
||||
onReceived(resp: object) {
|
||||
// Guard: ignore duplicate responses after completion
|
||||
if (this.complete) {
|
||||
console.log(this.mid, "should not happen, request is already complete");
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as complete to prevent duplicate processing
|
||||
this.complete = true;
|
||||
|
||||
// Clean up timeout timer
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = undefined;
|
||||
|
||||
// Remove from inflight requests tracker
|
||||
delete this.socket.inflight[this.mid];
|
||||
|
||||
// Check if the response contains an error (error can be directly in resp or nested under response)
|
||||
let errorToHandle: unknown = null;
|
||||
|
||||
// Check for direct error in response
|
||||
if (resp !== null && typeof resp === "object" && "error" in resp) {
|
||||
errorToHandle = (resp as Record<string, unknown>).error;
|
||||
}
|
||||
// Check for nested error under response property
|
||||
else if (resp !== null && typeof resp === "object" && "response" in resp) {
|
||||
const response = (resp as Record<string, unknown>).response;
|
||||
if (response !== null && typeof response === "object" && "error" in response) {
|
||||
errorToHandle = (response as Record<string, unknown>).error;
|
||||
}
|
||||
}
|
||||
|
||||
if (errorToHandle !== null && errorToHandle !== undefined) {
|
||||
// Response contains an error - call error callback
|
||||
const errorObj = errorToHandle as Record<string, unknown>;
|
||||
const errorMessage =
|
||||
(typeof errorObj.message === "string" ? errorObj.message : null) ||
|
||||
(typeof errorObj.type === "string" ? errorObj.type : null) ||
|
||||
"Unknown error";
|
||||
console.log(
|
||||
"ServiceCall: API error detected in response:",
|
||||
errorMessage,
|
||||
"Full error:",
|
||||
errorToHandle,
|
||||
);
|
||||
this.error(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the response field from the message object
|
||||
// The resp parameter is the full message: {id, response, complete}
|
||||
// We need to pass just the response field to the success callback
|
||||
const responseData = (resp as { response?: unknown }).response;
|
||||
this.success(responseData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when socket connects - immediately retry if we were waiting
|
||||
*/
|
||||
retryNow() {
|
||||
if (this.complete) return;
|
||||
|
||||
// Clear any pending backoff timer
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = undefined;
|
||||
|
||||
// Restore retry count since we didn't actually fail
|
||||
this.retries++;
|
||||
|
||||
// Attempt immediately
|
||||
this.attempt();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the request times out
|
||||
* Triggers another attempt if retries are available
|
||||
*/
|
||||
onTimeout() {
|
||||
// Guard: ignore timeout after completion
|
||||
if (this.complete) {
|
||||
console.log(
|
||||
this.mid,
|
||||
"timeout should not happen, request is already complete",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Request", this.mid, "timed out");
|
||||
|
||||
// Clear the current timeout
|
||||
clearTimeout(this.timeoutId);
|
||||
|
||||
// Try again (this will check retry count)
|
||||
this.attempt();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates exponential backoff delay with jitter
|
||||
* @returns backoff delay in milliseconds
|
||||
*/
|
||||
calculateBackoff() {
|
||||
return Math.min(
|
||||
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) +
|
||||
Math.random() * 1000,
|
||||
30000, // Max 30 seconds
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core retry logic - attempts to send the message over the WebSocket
|
||||
* Handles retries and waits for BaseApi to handle reconnection
|
||||
*/
|
||||
attempt() {
|
||||
// Guard: don't retry completed requests
|
||||
if (this.complete) {
|
||||
console.log(
|
||||
this.mid,
|
||||
"attempt should not be called, request is already complete",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decrement retry counter
|
||||
this.retries--;
|
||||
|
||||
// Check if we've exhausted all retries
|
||||
if (this.retries < 0) {
|
||||
console.log("Request", this.mid, "ran out of retries");
|
||||
|
||||
// Clean up and call error callback
|
||||
clearTimeout(this.timeoutId);
|
||||
delete this.socket.inflight[this.mid];
|
||||
this.error("Ran out of retries");
|
||||
return; // Exit early - no more attempts
|
||||
}
|
||||
|
||||
// Check if WebSocket connection is available and ready
|
||||
if (this.socket.ws !== null && this.socket.ws !== undefined && this.socket.ws.readyState === WS_OPEN) {
|
||||
try {
|
||||
// Attempt to send the message as JSON
|
||||
this.socket.ws.send(JSON.stringify(this.msg));
|
||||
|
||||
// Set up timeout for this attempt
|
||||
this.timeoutId = setTimeout(this.onTimeout.bind(this), this.timeout);
|
||||
|
||||
return; // Success - message sent, waiting for response or timeout
|
||||
} catch (e) {
|
||||
// Handle send failure - wait for BaseApi to handle reconnection
|
||||
console.log("Error:", e);
|
||||
console.log(
|
||||
"Message send failure, waiting for socket reconnection...",
|
||||
);
|
||||
|
||||
// Schedule retry with backoff - let BaseApi handle the reconnection
|
||||
this.timeoutId = setTimeout(
|
||||
this.attempt.bind(this),
|
||||
this.calculateBackoff(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No WebSocket connection available or not ready
|
||||
// Let BaseApi handle reconnection, just wait and retry
|
||||
console.log("Request", this.mid, "waiting for socket reconnection...");
|
||||
|
||||
// Use consistent backoff for all waiting scenarios
|
||||
setTimeout(this.attempt.bind(this), this.calculateBackoff());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,7 @@
|
|||
// Import core types and classes for the TrustGraph API
|
||||
import type { Term, Triple } from "../models/Triple.js";
|
||||
import { ServiceCallMulti } from "./service-call-multi.js";
|
||||
import { ServiceCall } from "./service-call.js";
|
||||
import {
|
||||
getWebSocketConstructor,
|
||||
getDefaultSocketUrl,
|
||||
getRandomValues,
|
||||
WS_CONNECTING,
|
||||
WS_OPEN,
|
||||
WS_CLOSED,
|
||||
type IsomorphicWebSocket,
|
||||
type WsMessageEvent,
|
||||
type WsCloseEvent,
|
||||
type WsEvent,
|
||||
} from "./websocket-adapter.js";
|
||||
import { EffectRpcClient, type DispatchInput, type RpcConnectionState } from "./effect-rpc-client.js";
|
||||
import { getDefaultSocketUrl, getRandomValues } from "./websocket-adapter.js";
|
||||
|
||||
// Import all message types for different services
|
||||
import type {
|
||||
|
|
@ -51,7 +39,6 @@ import type {
|
|||
PromptRequest,
|
||||
PromptResponse,
|
||||
// ProcessingMetadata,
|
||||
RequestMessage,
|
||||
ResponseError,
|
||||
StructuredQueryRequest,
|
||||
StructuredQueryResponse,
|
||||
|
|
@ -107,8 +94,6 @@ export interface ExplainEvent {
|
|||
}
|
||||
|
||||
// Configuration constants
|
||||
const SOCKET_RECONNECTION_TIMEOUT = 2000; // 2 seconds between reconnection
|
||||
// attempts
|
||||
const SOCKET_URL = getDefaultSocketUrl(); // WebSocket endpoint path (isomorphic)
|
||||
|
||||
function isNonEmptyString(value: string | undefined): value is string {
|
||||
|
|
@ -165,6 +150,38 @@ function throwIfResponseError(error: ResponseError | undefined): void {
|
|||
}
|
||||
}
|
||||
|
||||
interface ConfigValueEntry {
|
||||
workspace?: string;
|
||||
type?: string;
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
function asConfigValues(response: unknown): ConfigValueEntry[] {
|
||||
if (response === null || typeof response !== "object") return [];
|
||||
const values = (response as { values?: unknown }).values;
|
||||
if (!Array.isArray(values)) return [];
|
||||
return values.flatMap((value) => {
|
||||
if (value === null || typeof value !== "object") return [];
|
||||
const item = value as Record<string, unknown>;
|
||||
const key = item.key;
|
||||
if (typeof key !== "string") return [];
|
||||
const entry: ConfigValueEntry = { key, value: item.value };
|
||||
if (typeof item.workspace === "string") entry.workspace = item.workspace;
|
||||
if (typeof item.type === "string") entry.type = item.type;
|
||||
return [entry];
|
||||
});
|
||||
}
|
||||
|
||||
function parseConfigJson(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket interface defining all available operations for the TrustGraph API
|
||||
* This provides a unified interface for various AI/ML and knowledge graph
|
||||
|
|
@ -297,22 +314,17 @@ export interface ConnectionState {
|
|||
}
|
||||
|
||||
export class BaseApi {
|
||||
ws: IsomorphicWebSocket | undefined = undefined; // WebSocket connection instance
|
||||
tag: string; // Unique client identifier
|
||||
id: number; // Counter for generating unique message IDs
|
||||
token: string | undefined; // Optional authentication token
|
||||
user: string; // User identifier for API requests
|
||||
socketUrl: string; // WebSocket URL
|
||||
inflight: { [key: string]: ServiceCall | ServiceCallMulti } = {}; // Track active requests by
|
||||
// message ID
|
||||
reconnectAttempts: number = 0; // Track reconnection attempts
|
||||
maxReconnectAttempts: number = 10; // Maximum reconnection attempts
|
||||
reconnectTimer: number | undefined = undefined; // Timer for reconnection attempts
|
||||
reconnectionState: "idle" | "reconnecting" | "failed" = "idle"; // Connection state
|
||||
private readonly rpc: EffectRpcClient;
|
||||
|
||||
// Connection state tracking for UI
|
||||
private connectionStateListeners: ((state: ConnectionState) => void)[] = [];
|
||||
private lastError: string | undefined = undefined;
|
||||
private rpcState: RpcConnectionState = { status: "connecting" };
|
||||
|
||||
constructor(user: string, token?: string, socketUrl?: string) {
|
||||
this.tag = makeid(16); // Generate unique client tag
|
||||
|
|
@ -320,6 +332,12 @@ export class BaseApi {
|
|||
this.token = token; // Store authentication token
|
||||
this.user = user; // Store user identifier
|
||||
this.socketUrl = withDefault(socketUrl, SOCKET_URL); // Use provided URL or default
|
||||
this.rpc = new EffectRpcClient(this.socketUrlWithToken());
|
||||
this.rpc.subscribe((state) => {
|
||||
this.rpcState = state;
|
||||
this.lastError = state.lastError;
|
||||
this.notifyStateChange();
|
||||
});
|
||||
|
||||
console.log(
|
||||
"SOCKET: opening socket...",
|
||||
|
|
@ -327,8 +345,6 @@ export class BaseApi {
|
|||
"user:",
|
||||
user,
|
||||
);
|
||||
this.openSocket(); // Establish WebSocket connection
|
||||
console.log("SOCKET: socket opened");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -353,25 +369,7 @@ export class BaseApi {
|
|||
*/
|
||||
private getConnectionState(): ConnectionState {
|
||||
const hasApiKey = isNonEmptyString(this.token);
|
||||
|
||||
// Determine status based on WebSocket state and reconnection state
|
||||
let status: ConnectionState["status"];
|
||||
|
||||
if (this.ws === undefined || this.ws.readyState === WS_CLOSED) {
|
||||
if (this.reconnectionState === "failed") {
|
||||
status = "failed";
|
||||
} else if (this.reconnectionState === "reconnecting") {
|
||||
status = "reconnecting";
|
||||
} else {
|
||||
status = "connecting";
|
||||
}
|
||||
} else if (this.ws.readyState === WS_CONNECTING) {
|
||||
status = "connecting";
|
||||
} else if (this.ws.readyState === WS_OPEN) {
|
||||
status = hasApiKey ? "authenticated" : "unauthenticated";
|
||||
} else {
|
||||
status = "connecting";
|
||||
}
|
||||
const status = this.connectionStatusFromRpc(hasApiKey);
|
||||
|
||||
const state: ConnectionState = {
|
||||
status,
|
||||
|
|
@ -381,12 +379,6 @@ export class BaseApi {
|
|||
state.lastError = this.lastError;
|
||||
}
|
||||
|
||||
// Add reconnection details if applicable
|
||||
if (status === "reconnecting") {
|
||||
state.reconnectAttempt = this.reconnectAttempts;
|
||||
state.maxAttempts = this.maxReconnectAttempts;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
|
|
@ -404,208 +396,13 @@ export class BaseApi {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes WebSocket connection and sets up event handlers
|
||||
*/
|
||||
openSocket() {
|
||||
// Don't create multiple connections
|
||||
if (
|
||||
this.ws !== undefined &&
|
||||
(this.ws.readyState === WS_CONNECTING ||
|
||||
this.ws.readyState === WS_OPEN)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up old socket if exists
|
||||
if (this.ws !== undefined) {
|
||||
this.ws.removeEventListener("message", this.onMessage);
|
||||
this.ws.removeEventListener("close", this.onClose);
|
||||
this.ws.removeEventListener("open", this.onOpen);
|
||||
this.ws.removeEventListener("error", this.onError);
|
||||
this.ws = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build WebSocket URL with optional token parameter
|
||||
const wsUrl = isNonEmptyString(this.token)
|
||||
? `${this.socketUrl}?token=${this.token}`
|
||||
: this.socketUrl;
|
||||
console.log(
|
||||
"SOCKET: connecting to",
|
||||
wsUrl.replace(/token=[^&]*/, "token=***"),
|
||||
);
|
||||
const WS = getWebSocketConstructor();
|
||||
this.ws = new WS(wsUrl);
|
||||
} catch (e) {
|
||||
console.error("[socket creation error]", e);
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Bind event handlers to maintain proper 'this' context
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onOpen = this.onOpen.bind(this);
|
||||
this.onError = this.onError.bind(this);
|
||||
|
||||
// Attach event listeners
|
||||
this.ws.addEventListener("message", this.onMessage);
|
||||
this.ws.addEventListener("close", this.onClose);
|
||||
this.ws.addEventListener("open", this.onOpen);
|
||||
this.ws.addEventListener("error", this.onError);
|
||||
}
|
||||
|
||||
// Handle incoming messages from server
|
||||
onMessage(message: WsMessageEvent) {
|
||||
if (message.data === undefined || message.data === null || message.data === "") return;
|
||||
|
||||
try {
|
||||
const obj: unknown = JSON.parse(String(message.data));
|
||||
|
||||
// Skip messages without ID (can't route them)
|
||||
if (obj === null || typeof obj !== "object" || !("id" in obj)) return;
|
||||
const id = (obj as { id?: unknown }).id;
|
||||
if (typeof id !== "string" || id.length === 0) return;
|
||||
|
||||
// Route response to the corresponding inflight request
|
||||
const call = this.inflight[id];
|
||||
if (call !== undefined) {
|
||||
// Pass the whole message object so receiver can access 'complete' flag
|
||||
call.onReceived(obj);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[socket message parse error]", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle connection closure - automatically attempt reconnection
|
||||
onClose(event: WsCloseEvent) {
|
||||
console.log("[socket close]", event.code, event.reason);
|
||||
this.lastError = `Connection closed: ${event.reason.length > 0 ? event.reason : "Unknown reason"}`;
|
||||
this.ws = undefined;
|
||||
this.notifyStateChange();
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
// Handle successful connection
|
||||
onOpen(_event: WsEvent) {
|
||||
console.log("[socket open]");
|
||||
this.reconnectAttempts = 0; // Reset reconnection attempts on success
|
||||
this.reconnectionState = "idle"; // Reset connection state
|
||||
this.lastError = undefined; // Clear any previous errors
|
||||
|
||||
// Clear any pending reconnect timer
|
||||
if (this.reconnectTimer !== undefined) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
}
|
||||
|
||||
// Notify UI of successful connection
|
||||
this.notifyStateChange();
|
||||
|
||||
// Immediately retry any pending requests that were waiting for connection
|
||||
for (const mid in this.inflight) {
|
||||
this.inflight[mid].retryNow();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle socket errors
|
||||
onError(event: WsEvent) {
|
||||
console.error("[socket error]", event);
|
||||
this.lastError = "Connection error occurred";
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a reconnection attempt with exponential backoff
|
||||
*/
|
||||
scheduleReconnect() {
|
||||
// Prevent concurrent reconnection attempts
|
||||
if (this.reconnectionState === "reconnecting") {
|
||||
console.log("[socket] Reconnection already in progress, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't schedule if already scheduled
|
||||
if (this.reconnectTimer !== undefined) return;
|
||||
|
||||
this.reconnectionState = "reconnecting";
|
||||
this.reconnectAttempts++;
|
||||
this.notifyStateChange(); // Notify UI of reconnection attempt
|
||||
|
||||
if (this.reconnectAttempts > this.maxReconnectAttempts) {
|
||||
console.error("[socket] Max reconnection attempts reached");
|
||||
this.reconnectionState = "failed";
|
||||
this.lastError = "Max reconnection attempts exceeded";
|
||||
this.notifyStateChange();
|
||||
// Notify all pending requests of the failure
|
||||
for (const mid in this.inflight) {
|
||||
this.inflight[mid].error(new Error("WebSocket connection failed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate exponential backoff with jitter
|
||||
const backoffDelay = Math.min(
|
||||
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, this.reconnectAttempts - 1) +
|
||||
Math.random() * 1000,
|
||||
30000, // Max 30 seconds
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[socket] Reconnecting in ${backoffDelay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
|
||||
);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = undefined;
|
||||
this.reopen();
|
||||
}, backoffDelay) as unknown as number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopens the WebSocket connection (used after connection failures)
|
||||
*/
|
||||
reopen() {
|
||||
console.log("[socket reopen]");
|
||||
// Check if we're already connected or connecting
|
||||
if (
|
||||
this.ws !== undefined &&
|
||||
(this.ws.readyState === WS_OPEN ||
|
||||
this.ws.readyState === WS_CONNECTING)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.openSocket();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the WebSocket connection and cleans up
|
||||
*/
|
||||
close() {
|
||||
// Clear reconnection timer
|
||||
if (this.reconnectTimer !== undefined) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
}
|
||||
|
||||
// Clean up WebSocket
|
||||
if (this.ws !== undefined) {
|
||||
// Remove event listeners to prevent memory leaks
|
||||
this.ws.removeEventListener("message", this.onMessage);
|
||||
this.ws.removeEventListener("close", this.onClose);
|
||||
this.ws.removeEventListener("open", this.onOpen);
|
||||
this.ws.removeEventListener("error", this.onError);
|
||||
|
||||
this.ws.close();
|
||||
this.ws = undefined;
|
||||
}
|
||||
|
||||
// Clear any remaining inflight requests
|
||||
for (const mid in this.inflight) {
|
||||
this.inflight[mid].error(new Error("Socket closed"));
|
||||
}
|
||||
this.inflight = {};
|
||||
this.rpc.close().catch((err) => {
|
||||
console.error("[socket close error]", err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -630,42 +427,11 @@ export class BaseApi {
|
|||
makeRequest<RequestType extends object, ResponseType>(
|
||||
service: string,
|
||||
request: RequestType,
|
||||
timeout?: number,
|
||||
retries?: number,
|
||||
_timeout?: number,
|
||||
_retries?: number,
|
||||
flow?: string,
|
||||
) {
|
||||
const mid = this.getNextId();
|
||||
|
||||
// Set default values
|
||||
if (timeout === undefined) timeout = 10000;
|
||||
if (retries === undefined) retries = 3;
|
||||
|
||||
// Construct the request message
|
||||
const msg: RequestMessage = {
|
||||
id: mid,
|
||||
service: service,
|
||||
request: request,
|
||||
};
|
||||
|
||||
// Add flow identifier if provided
|
||||
if (isNonEmptyString(flow)) msg.flow = flow;
|
||||
|
||||
// Return a Promise that will be resolved/rejected by the ServiceCall
|
||||
return new Promise<ResponseType>((resolve, reject) => {
|
||||
const call = new ServiceCall(
|
||||
mid,
|
||||
msg,
|
||||
resolve as (resp: unknown) => void,
|
||||
reject as (err: object | string) => void,
|
||||
timeout,
|
||||
retries,
|
||||
this,
|
||||
);
|
||||
|
||||
call.start();
|
||||
// Commented out debug logging: console.log("-->", msg);
|
||||
}).then((obj) => {
|
||||
// Commented out success logging: console.log("Success for", mid);
|
||||
return this.rpc.dispatch(this.dispatchInput(service, request, flow)).then((obj) => {
|
||||
return obj as ResponseType;
|
||||
});
|
||||
}
|
||||
|
|
@ -678,38 +444,12 @@ export class BaseApi {
|
|||
service: string,
|
||||
request: RequestType,
|
||||
receiver: (resp: unknown) => boolean, // Callback to handle each response chunk
|
||||
timeout?: number,
|
||||
retries?: number,
|
||||
_timeout?: number,
|
||||
_retries?: number,
|
||||
flow?: string,
|
||||
) {
|
||||
const mid = this.getNextId();
|
||||
|
||||
// Set defaults
|
||||
if (timeout === undefined) timeout = 10000;
|
||||
if (retries === undefined) retries = 3;
|
||||
|
||||
// Construct request message
|
||||
const msg: RequestMessage = {
|
||||
id: mid,
|
||||
service: service,
|
||||
request: request,
|
||||
};
|
||||
|
||||
if (isNonEmptyString(flow)) msg.flow = flow;
|
||||
|
||||
return new Promise<ResponseType>((resolve, reject) => {
|
||||
const call = new ServiceCallMulti(
|
||||
mid,
|
||||
msg,
|
||||
resolve as (resp: unknown) => void,
|
||||
reject as (err: object | string) => void,
|
||||
timeout,
|
||||
retries,
|
||||
this,
|
||||
receiver,
|
||||
);
|
||||
|
||||
call.start();
|
||||
return this.rpc.dispatchStream(this.dispatchInput(service, request, flow), (chunk) => {
|
||||
return receiver({ response: chunk.response, complete: chunk.complete });
|
||||
}).then((obj) => {
|
||||
return obj as ResponseType;
|
||||
});
|
||||
|
|
@ -737,6 +477,45 @@ export class BaseApi {
|
|||
);
|
||||
}
|
||||
|
||||
private connectionStatusFromRpc(hasApiKey: boolean): ConnectionState["status"] {
|
||||
switch (this.rpcState.status) {
|
||||
case "connected":
|
||||
return hasApiKey ? "authenticated" : "unauthenticated";
|
||||
case "failed":
|
||||
return "failed";
|
||||
case "closed":
|
||||
return "failed";
|
||||
case "connecting":
|
||||
return this.lastError === undefined ? "connecting" : "reconnecting";
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchInput<RequestType extends object>(
|
||||
service: string,
|
||||
request: RequestType,
|
||||
flow?: string,
|
||||
): DispatchInput {
|
||||
if (isNonEmptyString(flow)) {
|
||||
return {
|
||||
scope: "flow",
|
||||
service,
|
||||
flow,
|
||||
request: request as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
return {
|
||||
scope: "global",
|
||||
service,
|
||||
request: request as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
private socketUrlWithToken(): string {
|
||||
if (!isNonEmptyString(this.token)) return this.socketUrl;
|
||||
const separator = this.socketUrl.includes("?") ? "&" : "?";
|
||||
return `${this.socketUrl}${separator}token=${encodeURIComponent(this.token)}`;
|
||||
}
|
||||
|
||||
// Factory methods for creating specialized API instances
|
||||
librarian() {
|
||||
return new LibrarianApi(this);
|
||||
|
|
@ -787,7 +566,7 @@ export class LibrarianApi {
|
|||
},
|
||||
60000, // 60 second timeout for potentially large lists
|
||||
)
|
||||
.then((r) => r["document-metadatas"] ?? []);
|
||||
.then((r) => r["document-metadatas"] ?? r.documents ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -803,7 +582,7 @@ export class LibrarianApi {
|
|||
},
|
||||
60000,
|
||||
)
|
||||
.then((r) => r["processing-metadata"] ?? []);
|
||||
.then((r) => r["processing-metadatas"] ?? r.processing ?? r["processing-metadata"] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -818,6 +597,7 @@ export class LibrarianApi {
|
|||
{
|
||||
operation: "get-document-metadata",
|
||||
"document-id": documentId,
|
||||
documentId,
|
||||
user: this.api.user,
|
||||
},
|
||||
30000,
|
||||
|
|
@ -851,6 +631,8 @@ export class LibrarianApi {
|
|||
comments,
|
||||
user: this.api.user,
|
||||
tags,
|
||||
"document-type": "source",
|
||||
documentType: "source",
|
||||
};
|
||||
if (id !== undefined) {
|
||||
documentMetadata.id = id;
|
||||
|
|
@ -863,6 +645,7 @@ export class LibrarianApi {
|
|||
"librarian",
|
||||
{
|
||||
operation: "add-document",
|
||||
"document-metadata": documentMetadata,
|
||||
documentMetadata,
|
||||
content: document,
|
||||
},
|
||||
|
|
@ -879,6 +662,7 @@ export class LibrarianApi {
|
|||
{
|
||||
operation: "remove-document",
|
||||
"document-id": id,
|
||||
documentId: id,
|
||||
user: this.api.user,
|
||||
collection: withDefault(collection, "default"),
|
||||
},
|
||||
|
|
@ -908,6 +692,7 @@ export class LibrarianApi {
|
|||
"processing-metadata": {
|
||||
id: id,
|
||||
"document-id": doc_id,
|
||||
documentId: doc_id,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
flow: flow,
|
||||
user: this.api.user,
|
||||
|
|
@ -935,6 +720,7 @@ export class LibrarianApi {
|
|||
): Promise<BeginUploadResponse> {
|
||||
const request: BeginUploadRequest = {
|
||||
operation: "begin-upload",
|
||||
"document-metadata": metadata,
|
||||
documentMetadata: metadata,
|
||||
"total-size": totalSize,
|
||||
};
|
||||
|
|
@ -1200,32 +986,17 @@ export class FlowsApi {
|
|||
}
|
||||
|
||||
/**
|
||||
* Updates configuration values. Items are grouped by `type` (the namespace);
|
||||
* one put request is issued per distinct type.
|
||||
* Updates configuration values using the Python-compatible values array.
|
||||
*/
|
||||
putConfig(items: { type: string; key: string; value: string }[]) {
|
||||
const byType = new Map<string, Record<string, unknown>>();
|
||||
for (const item of items) {
|
||||
let group = byType.get(item.type);
|
||||
if (group === undefined) {
|
||||
group = {};
|
||||
byType.set(item.type, group);
|
||||
}
|
||||
group[item.key] = item.value;
|
||||
}
|
||||
return Promise.all(
|
||||
[...byType.entries()].map(([type, values]) =>
|
||||
this.api.makeRequest<ConfigRequest, ConfigResponse>(
|
||||
"config",
|
||||
{
|
||||
operation: "put",
|
||||
keys: [type],
|
||||
values,
|
||||
},
|
||||
60000,
|
||||
),
|
||||
),
|
||||
).then((responses) => responses[responses.length - 1]);
|
||||
return this.api.makeRequest<ConfigRequest, ConfigResponse>(
|
||||
"config",
|
||||
{
|
||||
operation: "put",
|
||||
values: items,
|
||||
},
|
||||
60000,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1233,13 +1004,13 @@ export class FlowsApi {
|
|||
*/
|
||||
deleteConfig(target: { type: string; key: string }) {
|
||||
return this.api.makeRequest<ConfigRequest, ConfigResponse>(
|
||||
"config",
|
||||
{
|
||||
operation: "delete",
|
||||
keys: [target.type, target.key],
|
||||
},
|
||||
30000,
|
||||
);
|
||||
"config",
|
||||
{
|
||||
operation: "delete",
|
||||
keys: [target],
|
||||
},
|
||||
30000,
|
||||
);
|
||||
}
|
||||
|
||||
// Prompt management - specialized config operations for AI prompts
|
||||
|
|
@ -2154,32 +1925,17 @@ export class ConfigApi {
|
|||
}
|
||||
|
||||
/**
|
||||
* Updates configuration values. Items are grouped by `type` (the namespace);
|
||||
* one put request is issued per distinct type.
|
||||
* Updates configuration values using the Python-compatible values array.
|
||||
*/
|
||||
putConfig(items: { type: string; key: string; value: string }[]) {
|
||||
const byType = new Map<string, Record<string, unknown>>();
|
||||
for (const item of items) {
|
||||
let group = byType.get(item.type);
|
||||
if (group === undefined) {
|
||||
group = {};
|
||||
byType.set(item.type, group);
|
||||
}
|
||||
group[item.key] = item.value;
|
||||
}
|
||||
return Promise.all(
|
||||
[...byType.entries()].map(([type, values]) =>
|
||||
this.api.makeRequest<ConfigRequest, ConfigResponse>(
|
||||
"config",
|
||||
{
|
||||
operation: "put",
|
||||
keys: [type],
|
||||
values,
|
||||
},
|
||||
60000,
|
||||
),
|
||||
),
|
||||
).then((responses) => responses[responses.length - 1]);
|
||||
return this.api.makeRequest<ConfigRequest, ConfigResponse>(
|
||||
"config",
|
||||
{
|
||||
operation: "put",
|
||||
values: items,
|
||||
},
|
||||
60000,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2187,13 +1943,13 @@ export class ConfigApi {
|
|||
*/
|
||||
deleteConfig(target: { type: string; key: string }) {
|
||||
return this.api.makeRequest<ConfigRequest, ConfigResponse>(
|
||||
"config",
|
||||
{
|
||||
operation: "delete",
|
||||
keys: [target.type, target.key],
|
||||
},
|
||||
30000,
|
||||
);
|
||||
"config",
|
||||
{
|
||||
operation: "delete",
|
||||
keys: [target],
|
||||
},
|
||||
30000,
|
||||
);
|
||||
}
|
||||
|
||||
// Specialized prompt management methods
|
||||
|
|
@ -2267,7 +2023,7 @@ export class ConfigApi {
|
|||
},
|
||||
60000,
|
||||
)
|
||||
.then((r) => (r as RowsQueryResponse).values);
|
||||
.then((r) => asConfigValues(r));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2285,12 +2041,10 @@ export class ConfigApi {
|
|||
60000,
|
||||
)
|
||||
.then((r) => {
|
||||
// Parse JSON values and restructure data
|
||||
const response = r as RowsQueryResponse;
|
||||
return (response.values ?? []).map((x: unknown) => {
|
||||
const item = x as Record<string, string>;
|
||||
return { key: item.key, value: JSON.parse(item.value) };
|
||||
});
|
||||
return asConfigValues(r).map((item) => ({
|
||||
key: item.key,
|
||||
value: parseConfigJson(item.value),
|
||||
}));
|
||||
})
|
||||
.then((r) =>
|
||||
// Transform to more usable format
|
||||
|
|
@ -2334,6 +2088,19 @@ export class KnowledgeApi {
|
|||
.then((r) => r.ids ?? []);
|
||||
}
|
||||
|
||||
getDocumentEmbeddingCores() {
|
||||
return this.api
|
||||
.makeRequest<FlowRequest, FlowResponse>(
|
||||
"knowledge",
|
||||
{
|
||||
operation: "list-de-cores",
|
||||
user: this.api.user,
|
||||
},
|
||||
60000,
|
||||
)
|
||||
.then((r) => r.ids ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a knowledge graph core
|
||||
*/
|
||||
|
|
@ -2367,6 +2134,45 @@ export class KnowledgeApi {
|
|||
);
|
||||
}
|
||||
|
||||
unloadKgCore(id: string, flow: string) {
|
||||
return this.api.makeRequest<LibraryRequest, LibraryResponse>(
|
||||
"knowledge",
|
||||
{
|
||||
operation: "unload-kg-core",
|
||||
id,
|
||||
flow,
|
||||
user: this.api.user,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
}
|
||||
|
||||
deleteDeCore(id: string) {
|
||||
return this.api.makeRequest<LibraryRequest, LibraryResponse>(
|
||||
"knowledge",
|
||||
{
|
||||
operation: "delete-de-core",
|
||||
id,
|
||||
user: this.api.user,
|
||||
},
|
||||
30000,
|
||||
);
|
||||
}
|
||||
|
||||
loadDeCore(id: string, flow: string, collection?: string) {
|
||||
return this.api.makeRequest<LibraryRequest, LibraryResponse>(
|
||||
"knowledge",
|
||||
{
|
||||
operation: "load-de-core",
|
||||
id,
|
||||
flow,
|
||||
user: this.api.user,
|
||||
collection: withDefault(collection, "default"),
|
||||
},
|
||||
30000,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a knowledge graph core with streaming data
|
||||
* Uses multi-request pattern for large datasets
|
||||
|
|
@ -2512,7 +2318,7 @@ export class CollectionManagementApi {
|
|||
* This is the main entry point for using the TrustGraph API
|
||||
* @param user - User identifier for API requests
|
||||
* @param token - Optional authentication token for secure connections
|
||||
* @param socketUrl - Optional WebSocket URL (defaults to /api/socket for browser, provide full URL for Node.js)
|
||||
* @param socketUrl - Optional WebSocket URL (defaults to /api/v1/rpc for browser, provide full URL for Node.js)
|
||||
*/
|
||||
export const createTrustGraphSocket = (
|
||||
user: string,
|
||||
|
|
|
|||
|
|
@ -97,16 +97,16 @@ export function getWebSocketConstructor(): IsomorphicWebSocketConstructor {
|
|||
/**
|
||||
* Returns the default WebSocket URL for the current environment.
|
||||
*
|
||||
* - Browser: returns the relative path `"/api/socket"` (resolved by the
|
||||
* - Browser: returns the relative path `"/api/v1/rpc"` (resolved by the
|
||||
* browser against the current page origin).
|
||||
* - Node.js: returns a full URL `"ws://localhost:8088/api/v1/socket"` since
|
||||
* - Node.js: returns a full URL `"ws://localhost:8088/api/v1/rpc"` since
|
||||
* relative URLs are not meaningful outside a browser.
|
||||
*/
|
||||
export function getDefaultSocketUrl(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
return "/api/socket";
|
||||
return "/api/v1/rpc";
|
||||
}
|
||||
return "ws://localhost:8088/api/v1/socket";
|
||||
return "ws://localhost:8088/api/v1/rpc";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"@effect/platform-bun": "4.0.0-beta.65",
|
||||
"@fastify/websocket": "^11.0.0",
|
||||
"@qdrant/js-client-rest": "^1.13.0",
|
||||
"@trustgraph/base": "workspace:*",
|
||||
|
|
@ -20,13 +19,28 @@
|
|||
"fastify": "^5.2.0",
|
||||
"ollama": "^0.6.3",
|
||||
"@mistralai/mistralai": "^1.0.0",
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
"@effect/ai-anthropic": "4.0.0-beta.74",
|
||||
"@effect/ai-openai": "4.0.0-beta.74",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.74",
|
||||
"@effect/atom-react": "4.0.0-beta.74",
|
||||
"@effect/openapi-generator": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-browser": "4.0.0-beta.74",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"@effect/tsgo": "0.13.0",
|
||||
"@effect/sql-pg": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-node": "4.0.0-beta.74",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"effect": "4.0.0-beta.65",
|
||||
"effect": "4.0.0-beta.74",
|
||||
"openai": "^4.85.0",
|
||||
"pdfjs-dist": "^5.6.205"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6"
|
||||
|
|
|
|||
131
ts/packages/flow/src/__tests__/retrieval-rag.test.ts
Normal file
131
ts/packages/flow/src/__tests__/retrieval-rag.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { describe, expect, it } from "@effect/vitest";
|
||||
import { Effect } from "effect";
|
||||
import type {
|
||||
DocumentEmbeddingsRequest,
|
||||
DocumentEmbeddingsResponse,
|
||||
EmbeddingsRequest,
|
||||
EmbeddingsResponse,
|
||||
FlowRequestor,
|
||||
GraphEmbeddingsRequest,
|
||||
GraphEmbeddingsResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse,
|
||||
TriplesQueryRequest,
|
||||
TriplesQueryResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeDocumentRagEngine, type DocumentRagClients } from "../retrieval/document-rag.js";
|
||||
import { makeGraphRagEngine, type GraphRagClients } from "../retrieval/graph-rag.js";
|
||||
|
||||
const requestor = <TReq, TRes>(
|
||||
handler: (request: TReq) => TRes | Promise<TRes>,
|
||||
): FlowRequestor<TReq, TRes> => ({
|
||||
request: async (request) => handler(request),
|
||||
stop: async () => undefined,
|
||||
});
|
||||
|
||||
describe("RAG engines", () => {
|
||||
it.effect(
|
||||
"runs Graph RAG without per-request service objects",
|
||||
Effect.fnUntraced(function* () {
|
||||
const prompts: Array<PromptRequest> = [];
|
||||
const triplesRequests: Array<TriplesQueryRequest> = [];
|
||||
let synthesisContext = "";
|
||||
|
||||
const clients: GraphRagClients = {
|
||||
prompt: requestor<PromptRequest, PromptResponse>((request) => {
|
||||
prompts.push(request);
|
||||
if (request.name === "extract-concepts") {
|
||||
return { system: "extract-system", prompt: "extract-prompt" };
|
||||
}
|
||||
synthesisContext = String(request.variables?.context ?? "");
|
||||
return { system: "synth-system", prompt: "synth-prompt" };
|
||||
}),
|
||||
llm: requestor<TextCompletionRequest, TextCompletionResponse>((request) => {
|
||||
if (request.prompt === "extract-prompt") {
|
||||
return { response: "alpha\nbeta", endOfStream: true };
|
||||
}
|
||||
return { response: `answer:${request.prompt}`, endOfStream: true };
|
||||
}),
|
||||
embeddings: requestor<EmbeddingsRequest, EmbeddingsResponse>((request) => {
|
||||
expect(request.text).toEqual(["alpha", "beta"]);
|
||||
return { vectors: [[1], [2]] };
|
||||
}),
|
||||
graphEmbeddings: requestor<GraphEmbeddingsRequest, GraphEmbeddingsResponse>((request) => {
|
||||
expect(request.collection).toBe("project");
|
||||
return {
|
||||
entities: [{ type: "IRI", iri: "https://example.test/entity/a" }],
|
||||
};
|
||||
}),
|
||||
triples: requestor<TriplesQueryRequest, TriplesQueryResponse>((request) => {
|
||||
triplesRequests.push(request);
|
||||
return {
|
||||
triples: [
|
||||
{
|
||||
s: { type: "IRI", iri: "https://example.test/entity/a" },
|
||||
p: { type: "IRI", iri: "https://example.test/relation" },
|
||||
o: { type: "LITERAL", value: "related value" },
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const engine = makeGraphRagEngine();
|
||||
const result = yield* engine.query(
|
||||
clients,
|
||||
"who is related?",
|
||||
{ collection: "project" },
|
||||
{ maxPathLength: 1 },
|
||||
);
|
||||
|
||||
expect(result.answer).toBe("answer:synth-prompt");
|
||||
expect(result.subgraph).toHaveLength(1);
|
||||
expect(prompts.map((prompt) => prompt.name)).toEqual([
|
||||
"extract-concepts",
|
||||
"graph-rag-synthesize",
|
||||
]);
|
||||
expect(triplesRequests).toHaveLength(1);
|
||||
expect(synthesisContext).toContain("https://example.test/entity/a");
|
||||
expect(synthesisContext).toContain("related value");
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"builds Document RAG synthesis context from returned chunks",
|
||||
Effect.fnUntraced(function* () {
|
||||
let synthesisContext = "";
|
||||
const clients: DocumentRagClients = {
|
||||
embeddings: requestor<EmbeddingsRequest, EmbeddingsResponse>((request) => {
|
||||
expect(request.text).toEqual(["explain docs"]);
|
||||
return { vectors: [[0.1, 0.2]] };
|
||||
}),
|
||||
docEmbeddings: requestor<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>((request) => {
|
||||
expect(request.collection).toBe("docs");
|
||||
return {
|
||||
chunks: [
|
||||
{ chunkId: "1", score: 0.9, content: "first chunk" },
|
||||
{ chunkId: "2", score: 0.8, content: "" },
|
||||
{ chunkId: "3", score: 0.7, content: "second chunk" },
|
||||
],
|
||||
};
|
||||
}),
|
||||
prompt: requestor<PromptRequest, PromptResponse>((request) => {
|
||||
synthesisContext = String(request.variables?.context ?? "");
|
||||
return { system: "doc-system", prompt: "doc-prompt" };
|
||||
}),
|
||||
llm: requestor<TextCompletionRequest, TextCompletionResponse>((request) => ({
|
||||
response: `doc-answer:${request.prompt}`,
|
||||
endOfStream: true,
|
||||
})),
|
||||
};
|
||||
|
||||
const engine = makeDocumentRagEngine();
|
||||
const response = yield* engine.query(clients, "explain docs", { collection: "docs" });
|
||||
|
||||
expect(response).toBe("doc-answer:doc-prompt");
|
||||
expect(synthesisContext).toBe("first chunk\n\n---\n\nsecond chunk");
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -1 +1 @@
|
|||
export { McpToolService } from "./service.js";
|
||||
export { McpToolService, run } from "./service.js";
|
||||
|
|
|
|||
|
|
@ -17,152 +17,312 @@ import {
|
|||
FlowProcessor,
|
||||
ConsumerSpec,
|
||||
ProducerSpec,
|
||||
makeFlowProcessorProgram,
|
||||
errorMessage,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type ToolRequest,
|
||||
type ToolResponse,
|
||||
type EffectConfigHandler,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer, Ref } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
interface McpServiceConfig {
|
||||
url: string;
|
||||
"remote-name"?: string;
|
||||
"auth-token"?: string;
|
||||
const McpServiceConfig = S.Struct({
|
||||
url: S.String,
|
||||
"remote-name": S.optionalKey(S.String),
|
||||
"auth-token": S.optionalKey(S.String),
|
||||
});
|
||||
type McpServiceConfig = typeof McpServiceConfig.Type;
|
||||
|
||||
const decodeRawMcpConfig = S.decodeUnknownOption(S.Record(S.String, S.String));
|
||||
const decodeMcpServiceConfig = S.decodeUnknownOption(McpServiceConfig.pipe(S.fromJsonString));
|
||||
const decodeToolParameters = S.decodeUnknownOption(S.Record(S.String, S.Unknown).pipe(S.fromJsonString));
|
||||
const encodeJson = S.encodeUnknownOption(S.UnknownFromJsonString);
|
||||
|
||||
export class McpToolError extends S.TaggedErrorClass<McpToolError>()(
|
||||
"McpToolError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
tool: S.optionalKey(S.String),
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface McpToolRuntimeService {
|
||||
readonly configure: (
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
) => Effect.Effect<void>;
|
||||
readonly invokeTool: (
|
||||
name: string,
|
||||
parameters: Record<string, unknown>,
|
||||
) => Effect.Effect<string | unknown, McpToolError>;
|
||||
}
|
||||
|
||||
export class McpToolService extends FlowProcessor {
|
||||
private mcpServices: Record<string, McpServiceConfig> = {};
|
||||
export class McpToolRuntime extends Context.Service<
|
||||
McpToolRuntime,
|
||||
McpToolRuntimeService
|
||||
>()("@trustgraph/flow/agent/mcp-tool/service/McpToolRuntime") {}
|
||||
|
||||
const mcpToolError = (
|
||||
operation: string,
|
||||
cause: unknown,
|
||||
tool?: string,
|
||||
): McpToolError =>
|
||||
new McpToolError({
|
||||
operation,
|
||||
message: errorMessage(cause),
|
||||
...(tool === undefined ? {} : { tool }),
|
||||
});
|
||||
|
||||
const closeTransport = (
|
||||
transport: StreamableHTTPClientTransport,
|
||||
tool: string,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => transport.close(),
|
||||
catch: (cause) => mcpToolError("close-transport", cause, tool),
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[McpToolService] Failed to close MCP transport", {
|
||||
error: error.message,
|
||||
tool: error.tool ?? tool,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const loadMcpServices = Effect.fn("McpToolRuntime.loadMcpServices")(function* (
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
) {
|
||||
yield* Effect.log(`[McpToolService] Got config version ${version}`);
|
||||
|
||||
if (!("mcp" in config) || typeof config.mcp !== "object" || config.mcp === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rawConfig = decodeRawMcpConfig(config.mcp);
|
||||
if (O.isNone(rawConfig)) {
|
||||
yield* Effect.logError("[McpToolService] MCP config must be an object of JSON strings");
|
||||
return {};
|
||||
}
|
||||
|
||||
const services: Record<string, McpServiceConfig> = {};
|
||||
for (const [name, value] of Object.entries(rawConfig.value)) {
|
||||
const decoded = decodeMcpServiceConfig(value);
|
||||
if (O.isNone(decoded)) {
|
||||
yield* Effect.logError(`[McpToolService] Failed to parse MCP config for ${name}`);
|
||||
continue;
|
||||
}
|
||||
services[name] = decoded.value;
|
||||
yield* Effect.log(`[McpToolService] Registered MCP service: ${name}`);
|
||||
}
|
||||
|
||||
yield* Effect.log(
|
||||
`[McpToolService] ${Object.keys(services).length} MCP services configured`,
|
||||
);
|
||||
|
||||
return services;
|
||||
});
|
||||
|
||||
const invokeConfiguredTool = Effect.fn("McpToolRuntime.invokeTool")(function* (
|
||||
services: Record<string, McpServiceConfig>,
|
||||
name: string,
|
||||
parameters: Record<string, unknown>,
|
||||
) {
|
||||
if (!(name in services)) {
|
||||
return yield* mcpToolError("lookup-service", `MCP service "${name}" not known`, name);
|
||||
}
|
||||
|
||||
const svcConfig = services[name];
|
||||
if (svcConfig.url.length === 0) {
|
||||
return yield* mcpToolError("validate-service", `MCP service "${name}" URL not defined`, name);
|
||||
}
|
||||
|
||||
const remoteName = svcConfig["remote-name"] ?? name;
|
||||
const headers: Record<string, string> = {};
|
||||
if (svcConfig["auth-token"] !== undefined && svcConfig["auth-token"].length > 0) {
|
||||
headers.Authorization = `Bearer ${svcConfig["auth-token"]}`;
|
||||
}
|
||||
|
||||
yield* Effect.log(`[McpToolService] Invoking ${remoteName} at ${svcConfig.url}`);
|
||||
|
||||
const url = yield* Effect.try({
|
||||
try: () => new URL(svcConfig.url),
|
||||
catch: (cause) => mcpToolError("validate-url", cause, name),
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
url,
|
||||
{ requestInit: { headers } },
|
||||
);
|
||||
const client = new Client({ name: "trustgraph-mcp-client", version: "1.0.0" });
|
||||
|
||||
const result = yield* Effect.acquireUseRelease(
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
await client.connect(transport as unknown as Parameters<Client["connect"]>[0]);
|
||||
return client;
|
||||
},
|
||||
catch: (cause) => mcpToolError("connect", cause, name),
|
||||
}),
|
||||
(connectedClient) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
connectedClient.callTool({
|
||||
name: remoteName,
|
||||
arguments: parameters,
|
||||
}),
|
||||
catch: (cause) => mcpToolError("call-tool", cause, name),
|
||||
}),
|
||||
() => closeTransport(transport, name),
|
||||
);
|
||||
|
||||
if (result.structuredContent !== undefined && result.structuredContent !== null) {
|
||||
return result.structuredContent;
|
||||
}
|
||||
|
||||
if (result.content !== undefined && Array.isArray(result.content)) {
|
||||
return result.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
return "No content";
|
||||
});
|
||||
|
||||
export const makeMcpToolRuntime = Effect.gen(function* () {
|
||||
const servicesRef = yield* Ref.make<Record<string, McpServiceConfig>>({});
|
||||
|
||||
return McpToolRuntime.of({
|
||||
configure: Effect.fn("McpToolRuntime.configure")(function* (config, version) {
|
||||
const services = yield* loadMcpServices(config, version);
|
||||
yield* Ref.set(servicesRef, services);
|
||||
}),
|
||||
invokeTool: Effect.fn("McpToolRuntime.invokeToolFromRef")(function* (name, parameters) {
|
||||
const services = yield* Ref.get(servicesRef);
|
||||
return yield* invokeConfiguredTool(services, name, parameters);
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
export const McpToolRuntimeLive = Layer.effect(McpToolRuntime, makeMcpToolRuntime);
|
||||
|
||||
const onMcpConfig = Effect.fn("McpToolService.onConfig")(function* (
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
) {
|
||||
const runtime = yield* McpToolRuntime;
|
||||
yield* runtime.configure(config, version);
|
||||
});
|
||||
|
||||
type McpToolHandlerError =
|
||||
| FlowResourceNotFoundError
|
||||
| MessagingDeliveryError;
|
||||
|
||||
const parametersFromJson = (
|
||||
name: string,
|
||||
parameters: string,
|
||||
): Effect.Effect<Record<string, unknown>, McpToolError> => {
|
||||
if (parameters.length === 0) return Effect.succeed({});
|
||||
|
||||
const decoded = decodeToolParameters(parameters);
|
||||
if (O.isNone(decoded)) {
|
||||
return Effect.fail(mcpToolError("decode-parameters", "Tool parameters must be a JSON object", name));
|
||||
}
|
||||
return Effect.succeed(decoded.value);
|
||||
};
|
||||
|
||||
const onMcpToolRequest = Effect.fn("McpToolService.onRequest")(function* (
|
||||
msg: ToolRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<McpToolRuntime>,
|
||||
): Effect.fn.Return<void, McpToolHandlerError, McpToolRuntime> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = yield* flowCtx.flow.producerEffect<ToolResponse>("mcp-tool-response");
|
||||
const runtime = yield* McpToolRuntime;
|
||||
|
||||
const result = yield* parametersFromJson(msg.name, msg.parameters).pipe(
|
||||
Effect.flatMap((parameters) => runtime.invokeTool(msg.name, parameters)),
|
||||
Effect.catch((error) =>
|
||||
Effect.logError(`[McpToolService] Error invoking tool ${msg.name}`, {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
responseProducer.send(requestId, {
|
||||
error: { type: "tool-error", message: error.message },
|
||||
}),
|
||||
),
|
||||
Effect.as(undefined),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result === undefined) return;
|
||||
|
||||
if (typeof result === "string") {
|
||||
yield* responseProducer.send(requestId, { text: result });
|
||||
return;
|
||||
}
|
||||
|
||||
const encoded = encodeJson(result);
|
||||
yield* responseProducer.send(requestId, {
|
||||
object: O.isSome(encoded) ? encoded.value : String(result),
|
||||
});
|
||||
});
|
||||
|
||||
export const makeMcpToolSpecs = (): ReadonlyArray<Spec<McpToolRuntime>> => [
|
||||
new ConsumerSpec<ToolRequest, McpToolHandlerError, McpToolRuntime>(
|
||||
"mcp-tool-request",
|
||||
onMcpToolRequest,
|
||||
),
|
||||
new ProducerSpec<ToolResponse>("mcp-tool-response"),
|
||||
];
|
||||
|
||||
export const makeMcpToolConfigHandlers = (): ReadonlyArray<
|
||||
EffectConfigHandler<never, McpToolRuntime>
|
||||
> => [onMcpConfig];
|
||||
|
||||
export class McpToolService extends FlowProcessor<McpToolRuntime> {
|
||||
private readonly runtime = Effect.runSync(makeMcpToolRuntime);
|
||||
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<ToolRequest>("mcp-tool-request", this.onRequest.bind(this)),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<ToolResponse>("mcp-tool-response"));
|
||||
|
||||
this.registerConfigHandler(this.onMcpConfig.bind(this));
|
||||
}
|
||||
|
||||
private async onMcpConfig(
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
): Promise<void> {
|
||||
console.log(`[McpToolService] Got config version ${version}`);
|
||||
|
||||
if (!("mcp" in config) || typeof config.mcp !== "object" || config.mcp === null) {
|
||||
this.mcpServices = {};
|
||||
return;
|
||||
for (const spec of makeMcpToolSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
const mcpConfig = config.mcp as Record<string, string>;
|
||||
this.mcpServices = {};
|
||||
|
||||
for (const [name, value] of Object.entries(mcpConfig)) {
|
||||
try {
|
||||
this.mcpServices[name] = JSON.parse(value) as McpServiceConfig;
|
||||
console.log(`[McpToolService] Registered MCP service: ${name}`);
|
||||
} catch (err) {
|
||||
console.error(`[McpToolService] Failed to parse MCP config for ${name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[McpToolService] ${Object.keys(this.mcpServices).length} MCP services configured`,
|
||||
this.registerConfigHandler((config, version) =>
|
||||
Effect.runPromise(onMcpConfig(config, version).pipe(
|
||||
Effect.provideService(McpToolRuntime, this.runtime),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
private async onRequest(
|
||||
msg: ToolRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = flowCtx.flow.producer<ToolResponse>("mcp-tool-response");
|
||||
|
||||
try {
|
||||
const result = await this.invokeTool(
|
||||
msg.name,
|
||||
msg.parameters !== undefined && msg.parameters.length > 0
|
||||
? JSON.parse(msg.parameters) as Record<string, unknown>
|
||||
: {},
|
||||
);
|
||||
|
||||
if (typeof result === "string") {
|
||||
await responseProducer.send(requestId, { text: result });
|
||||
} else {
|
||||
await responseProducer.send(requestId, { object: JSON.stringify(result) });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[McpToolService] Error invoking tool ${msg.name}:`, err);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await responseProducer.send(requestId, {
|
||||
error: { type: "tool-error", message },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async invokeTool(
|
||||
name: string,
|
||||
parameters: Record<string, unknown>,
|
||||
): Promise<string | unknown> {
|
||||
if (!(name in this.mcpServices)) {
|
||||
throw new Error(`MCP service "${name}" not known`);
|
||||
}
|
||||
|
||||
const svcConfig = this.mcpServices[name];
|
||||
if (svcConfig.url.length === 0) {
|
||||
throw new Error(`MCP service "${name}" URL not defined`);
|
||||
}
|
||||
|
||||
const remoteName = svcConfig["remote-name"] ?? name;
|
||||
|
||||
// Build headers with optional bearer token
|
||||
const headers: Record<string, string> = {};
|
||||
if (svcConfig["auth-token"] !== undefined && svcConfig["auth-token"].length > 0) {
|
||||
headers["Authorization"] = `Bearer ${svcConfig["auth-token"]}`;
|
||||
}
|
||||
|
||||
console.log(`[McpToolService] Invoking ${remoteName} at ${svcConfig.url}`);
|
||||
|
||||
// Connect to streamable HTTP MCP server
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(svcConfig.url),
|
||||
{ requestInit: { headers } },
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(McpToolRuntime, this.runtime),
|
||||
);
|
||||
|
||||
const client = new Client({ name: "trustgraph-mcp-client", version: "1.0.0" });
|
||||
|
||||
try {
|
||||
await client.connect(transport as unknown as Parameters<Client["connect"]>[0]);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: remoteName,
|
||||
arguments: parameters,
|
||||
});
|
||||
|
||||
// Extract response — prefer structured content, fall back to text
|
||||
if (result.structuredContent !== undefined && result.structuredContent !== null) {
|
||||
return result.structuredContent;
|
||||
}
|
||||
|
||||
if (result.content !== undefined && Array.isArray(result.content)) {
|
||||
return result.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
return "No content";
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig, never, McpToolRuntime>({
|
||||
id: "mcp-tool",
|
||||
make: (config) => new McpToolService(config),
|
||||
specs: () => makeMcpToolSpecs(),
|
||||
configHandlers: () => makeMcpToolConfigHandlers(),
|
||||
layer: () => McpToolRuntimeLive,
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import {
|
|||
ConsumerSpec,
|
||||
ProducerSpec,
|
||||
RequestResponseSpec,
|
||||
makeFlowProcessorProgram,
|
||||
errorMessage,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type AgentRequest,
|
||||
|
|
@ -35,8 +37,18 @@ import {
|
|||
type TriplesQueryResponse,
|
||||
type ToolRequest,
|
||||
type ToolResponse,
|
||||
type EffectConfigHandler,
|
||||
type EffectRequestOptions,
|
||||
type EffectRequestResponse,
|
||||
type FlowRequestOptions,
|
||||
type FlowRequestor,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer, Ref } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
import {
|
||||
createKnowledgeQueryTool,
|
||||
|
|
@ -51,398 +63,490 @@ import type { AgentTool, ToolArg } from "./types.js";
|
|||
|
||||
const MAX_ITERATIONS = 10;
|
||||
|
||||
export class AgentService extends FlowProcessor {
|
||||
/** Config-driven tools; null means "use hardcoded fallback". */
|
||||
private configuredTools: AgentTool[] | null = null;
|
||||
class AgentToolExecutionError extends S.TaggedErrorClass<AgentToolExecutionError>()(
|
||||
"AgentToolExecutionError",
|
||||
{
|
||||
message: S.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
const UnknownRecord = S.Record(S.String, S.Unknown);
|
||||
const ToolArgumentConfig = S.StructWithRest(
|
||||
S.Struct({
|
||||
name: S.optionalKey(S.String),
|
||||
type: S.optionalKey(S.String),
|
||||
description: S.optionalKey(S.String),
|
||||
}),
|
||||
[UnknownRecord],
|
||||
);
|
||||
const ToolConfigEntry = S.StructWithRest(
|
||||
S.Struct({
|
||||
type: S.optionalKey(S.String),
|
||||
name: S.optionalKey(S.String),
|
||||
description: S.optionalKey(S.String),
|
||||
arguments: ToolArgumentConfig.pipe(S.Array, S.optionalKey),
|
||||
}),
|
||||
[UnknownRecord],
|
||||
);
|
||||
type ToolConfigEntry = typeof ToolConfigEntry.Type;
|
||||
|
||||
const decodeRawToolConfig = S.decodeUnknownOption(S.Record(S.String, S.String));
|
||||
const decodeToolConfigEntry = S.decodeUnknownOption(ToolConfigEntry.pipe(S.fromJsonString));
|
||||
|
||||
export interface AgentRuntimeService {
|
||||
readonly configureTools: (
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
) => Effect.Effect<void>;
|
||||
readonly getConfiguredTools: Effect.Effect<ReadonlyArray<AgentTool> | null>;
|
||||
}
|
||||
|
||||
export class AgentRuntime extends Context.Service<AgentRuntime, AgentRuntimeService>()(
|
||||
"@trustgraph/flow/agent/react/service/AgentRuntime",
|
||||
) {}
|
||||
|
||||
const toEffectRequestOptions = <TRes>(
|
||||
options: FlowRequestOptions<TRes> | undefined,
|
||||
): EffectRequestOptions<TRes> | undefined => {
|
||||
if (options === undefined) return undefined;
|
||||
return {
|
||||
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
||||
...(options.recipient === undefined
|
||||
? {}
|
||||
: {
|
||||
recipient: (response: TRes) =>
|
||||
Effect.promise(() => options.recipient?.(response) ?? Promise.resolve(true)),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const toPromiseRequestor = <TReq, TRes>(
|
||||
requestor: EffectRequestResponse<TReq, TRes>,
|
||||
): FlowRequestor<TReq, TRes> => ({
|
||||
request: (request, options) =>
|
||||
Effect.runPromise(requestor.request(request, toEffectRequestOptions(options))),
|
||||
stop: () => Effect.runPromise(requestor.stop),
|
||||
});
|
||||
|
||||
const buildConfiguredTool = (
|
||||
toolId: string,
|
||||
data: ToolConfigEntry,
|
||||
): AgentTool | null => {
|
||||
const implType = data.type ?? "";
|
||||
const name = data.name ?? "";
|
||||
const description = data.description ?? "";
|
||||
const config = { ...data } as Record<string, unknown>;
|
||||
|
||||
if (name.length === 0) {
|
||||
console.warn(`[AgentService] Skipping tool with no name: ${toolId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (implType) {
|
||||
case "knowledge-query":
|
||||
return {
|
||||
name,
|
||||
description:
|
||||
description.length > 0
|
||||
? description
|
||||
: "Query the knowledge graph for information about entities and their relationships.",
|
||||
args: [{ name: "question", type: "string", description: "The question to ask" }],
|
||||
config,
|
||||
execute: async () => "",
|
||||
};
|
||||
|
||||
case "document-query":
|
||||
return {
|
||||
name,
|
||||
description:
|
||||
description.length > 0
|
||||
? description
|
||||
: "Search documents for relevant information.",
|
||||
args: [{ name: "question", type: "string", description: "The question to search for" }],
|
||||
config,
|
||||
execute: async () => "",
|
||||
};
|
||||
|
||||
case "triples-query":
|
||||
return {
|
||||
name,
|
||||
description:
|
||||
description.length > 0
|
||||
? description
|
||||
: "Query for specific triples in the knowledge graph.",
|
||||
args: [
|
||||
{ name: "subject", type: "string", description: "Subject entity (optional)" },
|
||||
{ name: "predicate", type: "string", description: "Predicate/relationship (optional)" },
|
||||
{ name: "object", type: "string", description: "Object entity (optional)" },
|
||||
],
|
||||
config,
|
||||
execute: async () => "",
|
||||
};
|
||||
|
||||
case "mcp-tool": {
|
||||
const args: ToolArg[] = (data.arguments ?? []).map((arg) => ({
|
||||
name: arg.name ?? "",
|
||||
type: arg.type ?? "string",
|
||||
description: arg.description ?? "",
|
||||
}));
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
args,
|
||||
config,
|
||||
execute: async () => "",
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn(`[AgentService] Unknown tool type "${implType}" for ${name}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfiguredTools = Effect.fn("AgentRuntime.loadConfiguredTools")(function* (
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
) {
|
||||
yield* Effect.log(`[AgentService] Loading tool configuration version ${version}`);
|
||||
|
||||
if (!("tool" in config) || typeof config.tool !== "object" || config.tool === null) {
|
||||
yield* Effect.log("[AgentService] No tool config found, using default tools");
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawConfig = decodeRawToolConfig(config.tool);
|
||||
if (O.isNone(rawConfig)) {
|
||||
yield* Effect.logError("[AgentService] Tool config must be an object of JSON strings");
|
||||
return null;
|
||||
}
|
||||
|
||||
const tools: AgentTool[] = [];
|
||||
for (const [toolId, toolValue] of Object.entries(rawConfig.value)) {
|
||||
const decoded = decodeToolConfigEntry(toolValue);
|
||||
if (O.isNone(decoded)) {
|
||||
yield* Effect.logError(`[AgentService] Failed to parse tool config ${toolId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tool = buildConfiguredTool(toolId, decoded.value);
|
||||
if (tool === null) continue;
|
||||
|
||||
tools.push(tool);
|
||||
yield* Effect.log(`[AgentService] Registered tool: ${tool.name} (${tool.config?.type ?? "unknown"})`);
|
||||
}
|
||||
|
||||
yield* Effect.log(`[AgentService] ${tools.length} tools loaded from config`);
|
||||
return tools.length > 0 ? tools : null;
|
||||
});
|
||||
|
||||
export const makeAgentRuntime = Effect.gen(function* () {
|
||||
const configuredToolsRef = yield* Ref.make<ReadonlyArray<AgentTool> | null>(null);
|
||||
|
||||
return AgentRuntime.of({
|
||||
configureTools: Effect.fn("AgentRuntime.configureTools")(function* (config, version) {
|
||||
const tools = yield* loadConfiguredTools(config, version);
|
||||
yield* Ref.set(configuredToolsRef, tools);
|
||||
}),
|
||||
getConfiguredTools: Ref.get(configuredToolsRef),
|
||||
});
|
||||
});
|
||||
|
||||
export const AgentRuntimeLive = Layer.effect(AgentRuntime, makeAgentRuntime);
|
||||
|
||||
const onToolsConfig = Effect.fn("AgentService.onToolsConfig")(function* (
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
) {
|
||||
const runtime = yield* AgentRuntime;
|
||||
yield* runtime.configureTools(config, version);
|
||||
});
|
||||
|
||||
const wireTools = Effect.fn("AgentService.wireTools")(function* (
|
||||
tools: ReadonlyArray<AgentTool>,
|
||||
flowCtx: FlowContext<AgentRuntime>,
|
||||
collection: string | undefined,
|
||||
onExplain: (data: ExplainData) => void,
|
||||
) {
|
||||
const graphRag = yield* flowCtx.flow.requestorEffect<GraphRagRequest, GraphRagResponse>("graph-rag");
|
||||
const docRag = yield* flowCtx.flow.requestorEffect<DocumentRagRequest, DocumentRagResponse>("doc-rag");
|
||||
const triples = yield* flowCtx.flow.requestorEffect<TriplesQueryRequest, TriplesQueryResponse>("triples");
|
||||
const mcpTool = yield* flowCtx.flow.requestorEffect<ToolRequest, ToolResponse>("mcp-tool");
|
||||
|
||||
return tools.map((tool) => {
|
||||
const implType = tool.config?.type as string | undefined;
|
||||
|
||||
switch (implType) {
|
||||
case "knowledge-query": {
|
||||
const live = createKnowledgeQueryTool(
|
||||
toPromiseRequestor(graphRag),
|
||||
collection,
|
||||
onExplain,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
case "document-query": {
|
||||
const live = createDocumentQueryTool(
|
||||
toPromiseRequestor(docRag),
|
||||
collection,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
case "triples-query": {
|
||||
const live = createTriplesQueryTool(
|
||||
toPromiseRequestor(triples),
|
||||
collection,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
case "mcp-tool": {
|
||||
const live = createMcpTool(
|
||||
toPromiseRequestor(mcpTool),
|
||||
tool.name,
|
||||
tool.description,
|
||||
tool.args,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
default:
|
||||
return tool;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const defaultTools = Effect.fn("AgentService.defaultTools")(function* (
|
||||
flowCtx: FlowContext<AgentRuntime>,
|
||||
collection: string | undefined,
|
||||
onExplain: (data: ExplainData) => void,
|
||||
) {
|
||||
const graphRag = yield* flowCtx.flow.requestorEffect<GraphRagRequest, GraphRagResponse>("graph-rag");
|
||||
const docRag = yield* flowCtx.flow.requestorEffect<DocumentRagRequest, DocumentRagResponse>("doc-rag");
|
||||
const triples = yield* flowCtx.flow.requestorEffect<TriplesQueryRequest, TriplesQueryResponse>("triples");
|
||||
|
||||
return [
|
||||
createKnowledgeQueryTool(
|
||||
toPromiseRequestor(graphRag),
|
||||
collection,
|
||||
onExplain,
|
||||
),
|
||||
createDocumentQueryTool(
|
||||
toPromiseRequestor(docRag),
|
||||
collection,
|
||||
),
|
||||
createTriplesQueryTool(
|
||||
toPromiseRequestor(triples),
|
||||
collection,
|
||||
),
|
||||
];
|
||||
});
|
||||
|
||||
const executeTool = (
|
||||
tool: AgentTool,
|
||||
input: string,
|
||||
): Effect.Effect<string> =>
|
||||
Effect.tryPromise({
|
||||
try: () => tool.execute(input),
|
||||
catch: (cause) => new AgentToolExecutionError({ message: errorMessage(cause) }),
|
||||
}).pipe(
|
||||
Effect.catch((error: AgentToolExecutionError) =>
|
||||
Effect.succeed(`Error executing tool: ${error.message}`),
|
||||
),
|
||||
);
|
||||
|
||||
type AgentHandlerError =
|
||||
| FlowResourceNotFoundError
|
||||
| MessagingDeliveryError;
|
||||
|
||||
const onAgentRequest = Effect.fn("AgentService.onRequest")(function* (
|
||||
msg: AgentRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<AgentRuntime>,
|
||||
): Effect.fn.Return<void, AgentHandlerError, AgentRuntime> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = yield* flowCtx.flow.producerEffect<AgentResponse>("agent-response");
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const runtime = yield* AgentRuntime;
|
||||
const explainEvents: ExplainData[] = [];
|
||||
const onExplain = (data: ExplainData) => {
|
||||
explainEvents.push(data);
|
||||
};
|
||||
|
||||
const configuredTools = yield* runtime.getConfiguredTools;
|
||||
let tools = configuredTools !== null
|
||||
? yield* wireTools(configuredTools, flowCtx, msg.collection, onExplain)
|
||||
: yield* defaultTools(flowCtx, msg.collection, onExplain);
|
||||
|
||||
tools = filterToolsByGroupAndState(tools, msg.group, msg.state);
|
||||
|
||||
const { system, prompt: initialPrompt } = buildReActPrompt(
|
||||
tools,
|
||||
msg.question,
|
||||
);
|
||||
|
||||
const llmClient = yield* flowCtx.flow.requestorEffect<
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse
|
||||
>("llm");
|
||||
|
||||
let conversation = initialPrompt;
|
||||
|
||||
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
||||
yield* Effect.log(
|
||||
`[AgentService] Iteration ${iteration + 1}/${MAX_ITERATIONS} for request ${requestId}`,
|
||||
);
|
||||
|
||||
const llmResponse = yield* llmClient.request({
|
||||
system,
|
||||
prompt: conversation,
|
||||
});
|
||||
|
||||
if (llmResponse.error !== undefined) {
|
||||
yield* responseProducer.send(requestId, {
|
||||
chunk_type: "error",
|
||||
content: `LLM error: ${llmResponse.error.message}`,
|
||||
end_of_dialog: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const text = llmResponse.response;
|
||||
const parsed = parseReActResponse(text);
|
||||
|
||||
if (parsed.thought.length > 0) {
|
||||
yield* responseProducer.send(requestId, {
|
||||
chunk_type: "thought",
|
||||
content: parsed.thought,
|
||||
end_of_message: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.finalAnswer.length > 0) {
|
||||
for (const explain of explainEvents) {
|
||||
yield* responseProducer.send(requestId, {
|
||||
chunk_type: "explain",
|
||||
content: "",
|
||||
explain_id: explain.explainId,
|
||||
explain_triples: explain.triples,
|
||||
} as AgentResponse);
|
||||
}
|
||||
|
||||
yield* responseProducer.send(requestId, {
|
||||
chunk_type: "answer",
|
||||
content: parsed.finalAnswer,
|
||||
end_of_message: true,
|
||||
end_of_dialog: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.action.length > 0 && parsed.actionInput.length > 0) {
|
||||
const tool = tools.find((candidate) => candidate.name === parsed.action);
|
||||
const observation = tool === undefined
|
||||
? `Unknown tool: ${parsed.action}. Available tools: ${tools.map((candidate) => candidate.name).join(", ")}`
|
||||
: yield* executeTool(tool, parsed.actionInput);
|
||||
|
||||
yield* responseProducer.send(requestId, {
|
||||
chunk_type: "observation",
|
||||
content: observation,
|
||||
end_of_message: true,
|
||||
});
|
||||
|
||||
conversation += `\n${text}\nObservation: ${observation}\n`;
|
||||
} else if (parsed.finalAnswer.length === 0) {
|
||||
conversation += `\n${text}\nObservation: You must either use a tool (Action + Action Input) or provide a Final Answer.\n`;
|
||||
}
|
||||
}
|
||||
|
||||
yield* responseProducer.send(requestId, {
|
||||
chunk_type: "error",
|
||||
content:
|
||||
"Maximum reasoning iterations reached without a final answer. " +
|
||||
"The agent was unable to complete the task within the allowed steps.",
|
||||
end_of_message: true,
|
||||
end_of_dialog: true,
|
||||
});
|
||||
}).pipe(
|
||||
Effect.catch((error: unknown) =>
|
||||
Effect.logError(`[AgentService] Error processing request ${requestId}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
responseProducer.send(requestId, {
|
||||
chunk_type: "error",
|
||||
content: `Agent error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
end_of_message: true,
|
||||
end_of_dialog: true,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
export const makeAgentSpecs = (): ReadonlyArray<Spec<AgentRuntime>> => [
|
||||
new ConsumerSpec<AgentRequest, AgentHandlerError, AgentRuntime>(
|
||||
"agent-request",
|
||||
onAgentRequest,
|
||||
),
|
||||
new ProducerSpec<AgentResponse>("agent-response"),
|
||||
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
|
||||
"llm",
|
||||
"text-completion-request",
|
||||
"text-completion-response",
|
||||
),
|
||||
new RequestResponseSpec<GraphRagRequest, GraphRagResponse>(
|
||||
"graph-rag",
|
||||
"graph-rag-request",
|
||||
"graph-rag-response",
|
||||
),
|
||||
new RequestResponseSpec<DocumentRagRequest, DocumentRagResponse>(
|
||||
"doc-rag",
|
||||
"document-rag-request",
|
||||
"document-rag-response",
|
||||
),
|
||||
new RequestResponseSpec<TriplesQueryRequest, TriplesQueryResponse>(
|
||||
"triples",
|
||||
"triples-request",
|
||||
"triples-response",
|
||||
),
|
||||
new RequestResponseSpec<ToolRequest, ToolResponse>(
|
||||
"mcp-tool",
|
||||
"mcp-tool-request",
|
||||
"mcp-tool-response",
|
||||
),
|
||||
];
|
||||
|
||||
export const makeAgentConfigHandlers = (): ReadonlyArray<
|
||||
EffectConfigHandler<never, AgentRuntime>
|
||||
> => [onToolsConfig];
|
||||
|
||||
export class AgentService extends FlowProcessor<AgentRuntime> {
|
||||
private readonly runtime = Effect.runSync(makeAgentRuntime);
|
||||
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
// Consumer: agent requests
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<AgentRequest>("agent-request", this.onRequest.bind(this)),
|
||||
);
|
||||
for (const spec of makeAgentSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
// Producer: agent responses (streaming chunks)
|
||||
this.registerSpecification(new ProducerSpec<AgentResponse>("agent-response"));
|
||||
|
||||
// Request-response clients for tool execution
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
|
||||
"llm",
|
||||
"text-completion-request",
|
||||
"text-completion-response",
|
||||
),
|
||||
this.registerConfigHandler((config, version) =>
|
||||
Effect.runPromise(onToolsConfig(config, version).pipe(
|
||||
Effect.provideService(AgentRuntime, this.runtime),
|
||||
)),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<GraphRagRequest, GraphRagResponse>(
|
||||
"graph-rag",
|
||||
"graph-rag-request",
|
||||
"graph-rag-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<DocumentRagRequest, DocumentRagResponse>(
|
||||
"doc-rag",
|
||||
"document-rag-request",
|
||||
"document-rag-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<TriplesQueryRequest, TriplesQueryResponse>(
|
||||
"triples",
|
||||
"triples-request",
|
||||
"triples-response",
|
||||
),
|
||||
);
|
||||
|
||||
// MCP tool invocation client
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<ToolRequest, ToolResponse>(
|
||||
"mcp-tool",
|
||||
"mcp-tool-request",
|
||||
"mcp-tool-response",
|
||||
),
|
||||
);
|
||||
|
||||
// Register for config-push to build tools dynamically
|
||||
this.registerConfigHandler(this.onToolsConfig.bind(this));
|
||||
|
||||
console.log("[AgentService] Service initialized");
|
||||
}
|
||||
|
||||
// ---------- Config-driven tool registration ----------
|
||||
|
||||
private async onToolsConfig(
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
): Promise<void> {
|
||||
console.log(`[AgentService] Loading tool configuration version ${version}`);
|
||||
|
||||
try {
|
||||
if (!("tool" in config) || typeof config.tool !== "object" || config.tool === null) {
|
||||
// No tool config — keep using hardcoded fallback
|
||||
this.configuredTools = null;
|
||||
console.log("[AgentService] No tool config found, using default tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const toolConfig = config.tool as Record<string, string>;
|
||||
const tools: AgentTool[] = [];
|
||||
|
||||
for (const [_toolId, toolValue] of Object.entries(toolConfig)) {
|
||||
try {
|
||||
const data = JSON.parse(toolValue) as Record<string, unknown>;
|
||||
const implType = typeof data["type"] === "string" ? data["type"] : "";
|
||||
const name = typeof data["name"] === "string" ? data["name"] : "";
|
||||
const description =
|
||||
typeof data["description"] === "string" ? data["description"] : "";
|
||||
|
||||
if (name.length === 0) {
|
||||
console.warn(`[AgentService] Skipping tool with no name: ${_toolId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let tool: AgentTool | null = null;
|
||||
|
||||
switch (implType) {
|
||||
case "knowledge-query":
|
||||
// Will be wired to requestor at request time
|
||||
tool = {
|
||||
name,
|
||||
description:
|
||||
description.length > 0
|
||||
? description
|
||||
: "Query the knowledge graph for information about entities and their relationships.",
|
||||
args: [{ name: "question", type: "string", description: "The question to ask" }],
|
||||
config: data,
|
||||
execute: async () => "", // placeholder — wired at request time
|
||||
};
|
||||
break;
|
||||
|
||||
case "document-query":
|
||||
tool = {
|
||||
name,
|
||||
description:
|
||||
description.length > 0
|
||||
? description
|
||||
: "Search documents for relevant information.",
|
||||
args: [{ name: "question", type: "string", description: "The question to search for" }],
|
||||
config: data,
|
||||
execute: async () => "",
|
||||
};
|
||||
break;
|
||||
|
||||
case "triples-query":
|
||||
tool = {
|
||||
name,
|
||||
description:
|
||||
description.length > 0
|
||||
? description
|
||||
: "Query for specific triples in the knowledge graph.",
|
||||
args: [
|
||||
{ name: "subject", type: "string", description: "Subject entity (optional)" },
|
||||
{ name: "predicate", type: "string", description: "Predicate/relationship (optional)" },
|
||||
{ name: "object", type: "string", description: "Object entity (optional)" },
|
||||
],
|
||||
config: data,
|
||||
execute: async () => "",
|
||||
};
|
||||
break;
|
||||
|
||||
case "mcp-tool": {
|
||||
const configArgs = (data["arguments"] as Array<Record<string, string>>) ?? [];
|
||||
const args: ToolArg[] = configArgs.map((a) => ({
|
||||
name: a.name ?? "",
|
||||
type: a.type ?? "string",
|
||||
description: a.description ?? "",
|
||||
}));
|
||||
|
||||
// Create a placeholder — will be wired to the MCP requestor at request time
|
||||
tool = {
|
||||
name,
|
||||
description,
|
||||
args,
|
||||
config: data,
|
||||
execute: async () => "", // placeholder
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn(`[AgentService] Unknown tool type "${implType}" for ${name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tool !== null) {
|
||||
tools.push(tool);
|
||||
console.log(`[AgentService] Registered tool: ${name} (${implType})`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[AgentService] Failed to parse tool config ${_toolId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
this.configuredTools = tools.length > 0 ? tools : null;
|
||||
console.log(`[AgentService] ${tools.length} tools loaded from config`);
|
||||
} catch (err) {
|
||||
console.error("[AgentService] Config reload failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up tool execute functions with live requestors from the flow context.
|
||||
* Config-driven tools store placeholders; this replaces them with real impls.
|
||||
*/
|
||||
private wireTools(
|
||||
tools: AgentTool[],
|
||||
flowCtx: FlowContext,
|
||||
collection?: string,
|
||||
onExplain?: (data: ExplainData) => void,
|
||||
): AgentTool[] {
|
||||
return tools.map((tool) => {
|
||||
const implType = tool.config?.["type"] as string | undefined;
|
||||
|
||||
switch (implType) {
|
||||
case "knowledge-query": {
|
||||
const live = createKnowledgeQueryTool(
|
||||
flowCtx.flow.requestor<GraphRagRequest, GraphRagResponse>("graph-rag"),
|
||||
collection,
|
||||
onExplain,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
case "document-query": {
|
||||
const live = createDocumentQueryTool(
|
||||
flowCtx.flow.requestor<DocumentRagRequest, DocumentRagResponse>("doc-rag"),
|
||||
collection,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
case "triples-query": {
|
||||
const live = createTriplesQueryTool(
|
||||
flowCtx.flow.requestor<TriplesQueryRequest, TriplesQueryResponse>("triples"),
|
||||
collection,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
case "mcp-tool": {
|
||||
const live = createMcpTool(
|
||||
flowCtx.flow.requestor<ToolRequest, ToolResponse>("mcp-tool"),
|
||||
tool.name,
|
||||
tool.description,
|
||||
tool.args,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
default:
|
||||
return tool;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async onRequest(
|
||||
msg: AgentRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = flowCtx.flow.producer<AgentResponse>("agent-response");
|
||||
|
||||
try {
|
||||
// Accumulate explain data from tool calls for emission after completion
|
||||
const explainEvents: ExplainData[] = [];
|
||||
const onExplain = (data: ExplainData) => {
|
||||
explainEvents.push(data);
|
||||
};
|
||||
|
||||
// Build tools — config-driven or hardcoded fallback
|
||||
let tools: AgentTool[];
|
||||
|
||||
if (this.configuredTools !== null) {
|
||||
tools = this.wireTools(this.configuredTools, flowCtx, msg.collection, onExplain);
|
||||
} else {
|
||||
// Hardcoded fallback (backward compat)
|
||||
tools = [
|
||||
createKnowledgeQueryTool(
|
||||
flowCtx.flow.requestor<GraphRagRequest, GraphRagResponse>("graph-rag"),
|
||||
msg.collection,
|
||||
onExplain,
|
||||
),
|
||||
createDocumentQueryTool(
|
||||
flowCtx.flow.requestor<DocumentRagRequest, DocumentRagResponse>("doc-rag"),
|
||||
msg.collection,
|
||||
),
|
||||
createTriplesQueryTool(
|
||||
flowCtx.flow.requestor<TriplesQueryRequest, TriplesQueryResponse>("triples"),
|
||||
msg.collection,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Apply tool filtering by group and state
|
||||
tools = filterToolsByGroupAndState(tools, msg.group, msg.state);
|
||||
|
||||
// Build the ReAct prompt
|
||||
const { system, prompt: initialPrompt } = buildReActPrompt(
|
||||
tools,
|
||||
msg.question,
|
||||
);
|
||||
|
||||
const llmClient = flowCtx.flow.requestor<
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse
|
||||
>("llm");
|
||||
|
||||
// Conversation accumulates the full exchange for multi-turn reasoning
|
||||
let conversation = initialPrompt;
|
||||
|
||||
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
||||
console.log(
|
||||
`[AgentService] Iteration ${iteration + 1}/${MAX_ITERATIONS} for request ${requestId}`,
|
||||
);
|
||||
|
||||
// Call LLM (non-streaming for MVP)
|
||||
const llmResponse = await llmClient.request({
|
||||
system,
|
||||
prompt: conversation,
|
||||
});
|
||||
|
||||
if (llmResponse.error !== undefined) {
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "error",
|
||||
content: `LLM error: ${llmResponse.error.message}`,
|
||||
end_of_dialog: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const text = llmResponse.response;
|
||||
|
||||
// Parse the LLM response with simple line-based parsing
|
||||
const parsed = parseReActResponse(text);
|
||||
|
||||
// Send thought chunk
|
||||
if (parsed.thought.length > 0) {
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "thought",
|
||||
content: parsed.thought,
|
||||
end_of_message: true,
|
||||
});
|
||||
}
|
||||
|
||||
// If we got a final answer, emit explain events then send the answer
|
||||
if (parsed.finalAnswer.length > 0) {
|
||||
// Emit explain events collected from tool calls
|
||||
for (const explain of explainEvents) {
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "explain",
|
||||
content: "",
|
||||
explain_id: explain.explainId,
|
||||
explain_triples: explain.triples,
|
||||
} as AgentResponse);
|
||||
}
|
||||
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "answer",
|
||||
content: parsed.finalAnswer,
|
||||
end_of_message: true,
|
||||
end_of_dialog: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute tool if action was specified
|
||||
if (parsed.action.length > 0 && parsed.actionInput.length > 0) {
|
||||
const tool = tools.find((t) => t.name === parsed.action);
|
||||
let observation: string;
|
||||
|
||||
if (tool !== undefined) {
|
||||
try {
|
||||
observation = await tool.execute(parsed.actionInput);
|
||||
} catch (err) {
|
||||
observation = `Error executing tool: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
} else {
|
||||
observation = `Unknown tool: ${parsed.action}. Available tools: ${tools.map((t) => t.name).join(", ")}`;
|
||||
}
|
||||
|
||||
// Send observation chunk
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "observation",
|
||||
content: observation,
|
||||
end_of_message: true,
|
||||
});
|
||||
|
||||
// Append the full exchange to conversation for the next iteration
|
||||
conversation += `\n${text}\nObservation: ${observation}\n`;
|
||||
} else if (parsed.finalAnswer.length === 0) {
|
||||
// LLM didn't produce a valid action or final answer -- nudge it
|
||||
conversation += `\n${text}\nObservation: You must either use a tool (Action + Action Input) or provide a Final Answer.\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Max iterations reached without a final answer
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "error",
|
||||
content:
|
||||
"Maximum reasoning iterations reached without a final answer. " +
|
||||
"The agent was unable to complete the task within the allowed steps.",
|
||||
end_of_message: true,
|
||||
end_of_dialog: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[AgentService] Error processing request ${requestId}:`, err);
|
||||
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "error",
|
||||
content: `Agent error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
end_of_message: true,
|
||||
end_of_dialog: true,
|
||||
});
|
||||
}
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(AgentRuntime, this.runtime),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -524,11 +628,13 @@ function parseReActResponse(text: string): {
|
|||
};
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig, never, AgentRuntime>({
|
||||
id: "agent",
|
||||
make: (config) => new AgentService(config),
|
||||
specs: () => makeAgentSpecs(),
|
||||
configHandlers: () => makeAgentConfigHandlers(),
|
||||
layer: () => AgentRuntimeLive,
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await AgentService.launch("agent");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,81 +21,86 @@ import {
|
|||
type TextDocument,
|
||||
type Chunk,
|
||||
type Triples,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import { recursiveSplit } from "./recursive-splitter.js";
|
||||
|
||||
const DEFAULT_CHUNK_SIZE = 2000;
|
||||
const DEFAULT_CHUNK_OVERLAP = 100;
|
||||
|
||||
const onChunkMessage = Effect.fn("ChunkingService.onMessage")(function* (
|
||||
msg: TextDocument,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
) {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const chunkSize = yield* flowCtx.flow.parameterEffect<number>("chunk-size").pipe(
|
||||
Effect.orElseSucceed(() => DEFAULT_CHUNK_SIZE),
|
||||
);
|
||||
const chunkOverlap = yield* flowCtx.flow.parameterEffect<number>("chunk-overlap").pipe(
|
||||
Effect.orElseSucceed(() => DEFAULT_CHUNK_OVERLAP),
|
||||
);
|
||||
|
||||
const text = msg.text;
|
||||
if (text.trim().length === 0) {
|
||||
yield* Effect.logWarning(`[ChunkingService] Empty text received for document ${msg.documentId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = recursiveSplit(text, chunkSize, chunkOverlap);
|
||||
|
||||
yield* Effect.log(
|
||||
`[ChunkingService] Split document ${msg.documentId} into ${chunks.length} chunks (size=${chunkSize}, overlap=${chunkOverlap})`,
|
||||
);
|
||||
|
||||
const outputProducer = yield* flowCtx.flow.producerEffect<Chunk>("chunk-output");
|
||||
|
||||
yield* Effect.forEach(
|
||||
chunks,
|
||||
(chunkText) =>
|
||||
outputProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
chunk: chunkText,
|
||||
documentId: msg.documentId,
|
||||
}),
|
||||
{ discard: true },
|
||||
);
|
||||
});
|
||||
|
||||
export const makeChunkingSpecs = (): ReadonlyArray<
|
||||
Spec<never>
|
||||
> => [
|
||||
new ConsumerSpec<TextDocument, FlowResourceNotFoundError | MessagingDeliveryError>(
|
||||
"chunk-input",
|
||||
onChunkMessage,
|
||||
),
|
||||
new ProducerSpec<Chunk>("chunk-output"),
|
||||
new ProducerSpec<Triples>("chunk-triples"),
|
||||
new ParameterSpec("chunk-size"),
|
||||
new ParameterSpec("chunk-overlap"),
|
||||
];
|
||||
|
||||
export class ChunkingService extends FlowProcessor {
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<TextDocument, FlowResourceNotFoundError | MessagingDeliveryError>(
|
||||
"chunk-input",
|
||||
this.onMessageEffect.bind(this),
|
||||
),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<Chunk>("chunk-output"));
|
||||
this.registerSpecification(new ProducerSpec<Triples>("chunk-triples"));
|
||||
this.registerSpecification(new ParameterSpec("chunk-size"));
|
||||
this.registerSpecification(new ParameterSpec("chunk-overlap"));
|
||||
for (const spec of makeChunkingSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
console.log("[ChunkingService] Service initialized");
|
||||
}
|
||||
|
||||
private onMessageEffect(
|
||||
msg: TextDocument,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const chunkSize = yield* flowCtx.flow.parameterEffect<number>("chunk-size").pipe(
|
||||
Effect.catch(() => Effect.succeed(DEFAULT_CHUNK_SIZE)),
|
||||
);
|
||||
const chunkOverlap = yield* flowCtx.flow.parameterEffect<number>("chunk-overlap").pipe(
|
||||
Effect.catch(() => Effect.succeed(DEFAULT_CHUNK_OVERLAP)),
|
||||
);
|
||||
|
||||
const text = msg.text;
|
||||
if (text.trim().length === 0) {
|
||||
yield* Effect.logWarning(`[ChunkingService] Empty text received for document ${msg.documentId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = recursiveSplit(text, chunkSize, chunkOverlap);
|
||||
|
||||
yield* Effect.log(
|
||||
`[ChunkingService] Split document ${msg.documentId} into ${chunks.length} chunks (size=${chunkSize}, overlap=${chunkOverlap})`,
|
||||
);
|
||||
|
||||
const outputProducer = yield* flowCtx.flow.producerEffect<Chunk>("chunk-output");
|
||||
|
||||
yield* Effect.forEach(
|
||||
chunks,
|
||||
(chunkText) =>
|
||||
outputProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
chunk: chunkText,
|
||||
documentId: msg.documentId,
|
||||
}),
|
||||
{ discard: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram({
|
||||
id: "chunking",
|
||||
make: (config) => new ChunkingService(config),
|
||||
specs: () => makeChunkingSpecs(),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await ChunkingService.launch("chunking");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,8 +44,33 @@ const ConfigPushSchema = S.Struct({
|
|||
config: S.Record(S.String, S.Unknown),
|
||||
});
|
||||
|
||||
const DEFAULT_WORKSPACE = "default";
|
||||
|
||||
interface ConfigKeyLike {
|
||||
type: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
interface ConfigValueLike {
|
||||
workspace?: string;
|
||||
type: string;
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
type NamespaceStore = Map<string, unknown>;
|
||||
type WorkspaceStore = Map<string, NamespaceStore>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export class ConfigService extends AsyncProcessor {
|
||||
private store = new Map<string, Map<string, unknown>>();
|
||||
private store = new Map<string, WorkspaceStore>();
|
||||
private version = 0;
|
||||
private readonly persistPath: string | null;
|
||||
private consumer: BackendConsumer<ConfigRequest> | null = null;
|
||||
|
|
@ -137,36 +162,146 @@ export class ConfigService extends AsyncProcessor {
|
|||
|
||||
switch (op) {
|
||||
case "get":
|
||||
return this.handleGet(request.keys ?? []);
|
||||
return this.handleGet(request);
|
||||
|
||||
case "put":
|
||||
return await this.handlePut(request.keys ?? [], request.values ?? {});
|
||||
return await this.handlePut(request);
|
||||
|
||||
case "delete":
|
||||
return await this.handleDelete(request.keys ?? []);
|
||||
return await this.handleDelete(request);
|
||||
|
||||
case "list":
|
||||
return this.handleList(request.keys ?? []);
|
||||
return this.handleList(request);
|
||||
|
||||
case "config":
|
||||
return this.handleConfigDump();
|
||||
return this.handleConfigDump(request);
|
||||
|
||||
case "getvalues":
|
||||
return this.handleGetValues(request);
|
||||
|
||||
case "getvalues-all-ws":
|
||||
return this.handleGetValuesAllWorkspaces(request);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown config operation: ${op as string}`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleGet(keys: string[]): ConfigResponse {
|
||||
private requestRecord(request: ConfigRequest): Record<string, unknown> {
|
||||
return request as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private workspaceFor(request: ConfigRequest): string {
|
||||
return optionalString(this.requestRecord(request).workspace) ?? DEFAULT_WORKSPACE;
|
||||
}
|
||||
|
||||
private workspaceStore(workspace: string, create: boolean): WorkspaceStore | undefined {
|
||||
let store = this.store.get(workspace);
|
||||
if (store === undefined && create) {
|
||||
store = new Map<string, NamespaceStore>();
|
||||
this.store.set(workspace, store);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
private namespaceStore(
|
||||
workspace: string,
|
||||
namespace: string,
|
||||
create: boolean,
|
||||
): NamespaceStore | undefined {
|
||||
const ws = this.workspaceStore(workspace, create);
|
||||
if (ws === undefined) return undefined;
|
||||
|
||||
let ns = ws.get(namespace);
|
||||
if (ns === undefined && create) {
|
||||
ns = new Map<string, unknown>();
|
||||
ws.set(namespace, ns);
|
||||
}
|
||||
return ns;
|
||||
}
|
||||
|
||||
private rawKeys(request: ConfigRequest): unknown[] {
|
||||
const keys = this.requestRecord(request).keys;
|
||||
return Array.isArray(keys) ? keys : [];
|
||||
}
|
||||
|
||||
private stringKeys(request: ConfigRequest): string[] {
|
||||
return this.rawKeys(request).filter((key): key is string => typeof key === "string");
|
||||
}
|
||||
|
||||
private objectKeys(request: ConfigRequest): ConfigKeyLike[] {
|
||||
return this.rawKeys(request).flatMap((key) => {
|
||||
if (!isRecord(key)) return [];
|
||||
const type = optionalString(key.type);
|
||||
if (type === undefined) return [];
|
||||
const item: ConfigKeyLike = { type };
|
||||
const keyValue = optionalString(key.key);
|
||||
if (keyValue !== undefined) item.key = keyValue;
|
||||
return [item];
|
||||
});
|
||||
}
|
||||
|
||||
private requestType(request: ConfigRequest): string | undefined {
|
||||
return optionalString(this.requestRecord(request).type) ?? this.stringKeys(request)[0];
|
||||
}
|
||||
|
||||
private configValues(request: ConfigRequest): ConfigValueLike[] {
|
||||
const req = this.requestRecord(request);
|
||||
const rawValues = req.values;
|
||||
const workspace = this.workspaceFor(request);
|
||||
|
||||
if (Array.isArray(rawValues)) {
|
||||
return rawValues.flatMap((value) => {
|
||||
if (!isRecord(value)) return [];
|
||||
const type = optionalString(value.type);
|
||||
const key = optionalString(value.key);
|
||||
if (type === undefined || key === undefined) return [];
|
||||
return [{
|
||||
workspace: optionalString(value.workspace) ?? workspace,
|
||||
type,
|
||||
key,
|
||||
value: value.value,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
if (isRecord(rawValues)) {
|
||||
const namespace = this.requestType(request);
|
||||
if (namespace === undefined) return [];
|
||||
return Object.entries(rawValues).map(([key, value]) => ({
|
||||
workspace,
|
||||
type: namespace,
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private handleGet(request: ConfigRequest): ConfigResponse {
|
||||
const workspace = this.workspaceFor(request);
|
||||
const objectKeys = this.objectKeys(request);
|
||||
|
||||
if (objectKeys.length > 0) {
|
||||
const values = objectKeys.map((key) => ({
|
||||
type: key.type,
|
||||
key: key.key ?? "",
|
||||
value: key.key !== undefined
|
||||
? this.namespaceStore(workspace, key.type, false)?.get(key.key)
|
||||
: undefined,
|
||||
}));
|
||||
return { version: this.version, values };
|
||||
}
|
||||
|
||||
const keys = this.stringKeys(request);
|
||||
if (keys.length === 0) {
|
||||
return { version: this.version, values: {} };
|
||||
}
|
||||
|
||||
const values: Record<string, unknown> = {};
|
||||
const namespace = keys[0];
|
||||
const subMap = this.store.get(namespace);
|
||||
const subMap = this.namespaceStore(workspace, namespace, false);
|
||||
|
||||
if (subMap !== undefined) {
|
||||
if (keys.length === 1) {
|
||||
|
|
@ -188,23 +323,12 @@ export class ConfigService extends AsyncProcessor {
|
|||
return { version: this.version, values };
|
||||
}
|
||||
|
||||
private async handlePut(
|
||||
keys: string[],
|
||||
values: Record<string, unknown>,
|
||||
): Promise<ConfigResponse> {
|
||||
if (keys.length === 0) {
|
||||
throw new Error("Put requires at least one key (namespace)");
|
||||
}
|
||||
private async handlePut(request: ConfigRequest): Promise<ConfigResponse> {
|
||||
const values = this.configValues(request);
|
||||
if (values.length === 0) throw new Error("Put requires config values");
|
||||
|
||||
const namespace = keys[0];
|
||||
let subMap = this.store.get(namespace);
|
||||
if (subMap === undefined) {
|
||||
subMap = new Map<string, unknown>();
|
||||
this.store.set(namespace, subMap);
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
subMap.set(k, v);
|
||||
for (const item of values) {
|
||||
this.namespaceStore(item.workspace ?? DEFAULT_WORKSPACE, item.type, true)?.set(item.key, item.value);
|
||||
}
|
||||
|
||||
this.version++;
|
||||
|
|
@ -214,25 +338,49 @@ export class ConfigService extends AsyncProcessor {
|
|||
return { version: this.version };
|
||||
}
|
||||
|
||||
private async handleDelete(keys: string[]): Promise<ConfigResponse> {
|
||||
private async handleDelete(request: ConfigRequest): Promise<ConfigResponse> {
|
||||
const workspace = this.workspaceFor(request);
|
||||
const objectKeys = this.objectKeys(request);
|
||||
if (objectKeys.length > 0) {
|
||||
for (const key of objectKeys) {
|
||||
const ws = this.workspaceStore(workspace, false);
|
||||
if (ws === undefined) continue;
|
||||
if (key.key === undefined) {
|
||||
ws.delete(key.type);
|
||||
} else {
|
||||
const ns = ws.get(key.type);
|
||||
ns?.delete(key.key);
|
||||
if (ns !== undefined && ns.size === 0) ws.delete(key.type);
|
||||
}
|
||||
}
|
||||
|
||||
this.version++;
|
||||
await this.persist();
|
||||
await this.pushConfig();
|
||||
return { version: this.version };
|
||||
}
|
||||
|
||||
const keys = this.stringKeys(request);
|
||||
if (keys.length === 0) {
|
||||
throw new Error("Delete requires at least one key");
|
||||
}
|
||||
|
||||
const namespace = keys[0];
|
||||
const ws = this.workspaceStore(workspace, false);
|
||||
if (ws === undefined) return { version: this.version };
|
||||
|
||||
if (keys.length === 1) {
|
||||
// Delete entire namespace
|
||||
this.store.delete(namespace);
|
||||
ws.delete(namespace);
|
||||
} else {
|
||||
// Delete specific keys within namespace
|
||||
const subMap = this.store.get(namespace);
|
||||
const subMap = ws.get(namespace);
|
||||
if (subMap !== undefined) {
|
||||
for (let i = 1; i < keys.length; i++) {
|
||||
subMap.delete(keys[i]);
|
||||
}
|
||||
if (subMap.size === 0) {
|
||||
this.store.delete(namespace);
|
||||
ws.delete(namespace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -244,17 +392,20 @@ export class ConfigService extends AsyncProcessor {
|
|||
return { version: this.version };
|
||||
}
|
||||
|
||||
private handleList(keys: string[]): ConfigResponse {
|
||||
if (keys.length === 0) {
|
||||
private handleList(request: ConfigRequest): ConfigResponse {
|
||||
const workspace = this.workspaceFor(request);
|
||||
const ws = this.workspaceStore(workspace, false);
|
||||
const namespace = this.requestType(request);
|
||||
|
||||
if (namespace === undefined) {
|
||||
// List all namespaces
|
||||
return {
|
||||
version: this.version,
|
||||
directory: [...this.store.keys()],
|
||||
directory: ws !== undefined ? [...ws.keys()] : [],
|
||||
};
|
||||
}
|
||||
|
||||
const namespace = keys[0];
|
||||
const subMap = this.store.get(namespace);
|
||||
const subMap = ws?.get(namespace);
|
||||
|
||||
return {
|
||||
version: this.version,
|
||||
|
|
@ -263,30 +414,48 @@ export class ConfigService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
private handleGetValues(request: ConfigRequest): ConfigResponse {
|
||||
const type = request.type ?? "";
|
||||
const workspace = this.workspaceFor(request);
|
||||
const type = this.requestType(request) ?? "";
|
||||
const ws = this.workspaceStore(workspace, false);
|
||||
|
||||
const values: { key: string; value: unknown }[] = [];
|
||||
const values: { type: string; key: string; value: unknown }[] = [];
|
||||
|
||||
for (const [namespace, subMap] of this.store) {
|
||||
for (const [namespace, subMap] of ws ?? new Map<string, NamespaceStore>()) {
|
||||
if (
|
||||
type.length === 0 ||
|
||||
namespace === type ||
|
||||
namespace.startsWith(`${type}.`) ||
|
||||
namespace.startsWith(`${type}/`)
|
||||
namespace === type
|
||||
) {
|
||||
for (const [k, v] of subMap) {
|
||||
values.push({ key: `${namespace}.${k}`, value: v });
|
||||
values.push({ type: namespace, key: k, value: v });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { version: this.version, values: values as unknown as Record<string, unknown> };
|
||||
return { version: this.version, values };
|
||||
}
|
||||
|
||||
private handleConfigDump(): ConfigResponse {
|
||||
private handleGetValuesAllWorkspaces(request: ConfigRequest): ConfigResponse {
|
||||
const type = this.requestType(request) ?? "";
|
||||
const values: { workspace: string; type: string; key: string; value: unknown }[] = [];
|
||||
|
||||
for (const [workspace, ws] of this.store) {
|
||||
for (const [namespace, subMap] of ws) {
|
||||
if (type.length > 0 && namespace !== type) continue;
|
||||
for (const [key, value] of subMap) {
|
||||
values.push({ workspace, type: namespace, key, value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { version: this.version, values };
|
||||
}
|
||||
|
||||
private handleConfigDump(request: ConfigRequest): ConfigResponse {
|
||||
const workspace = this.workspaceFor(request);
|
||||
const ws = this.workspaceStore(workspace, false);
|
||||
const config: Record<string, unknown> = {};
|
||||
|
||||
for (const [namespace, subMap] of this.store) {
|
||||
for (const [namespace, subMap] of ws ?? new Map<string, NamespaceStore>()) {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const [k, v] of subMap) {
|
||||
obj[k] = v;
|
||||
|
|
@ -305,7 +474,8 @@ export class ConfigService extends AsyncProcessor {
|
|||
if (pushProducer === null) return;
|
||||
|
||||
const config: Record<string, unknown> = {};
|
||||
for (const [namespace, subMap] of this.store) {
|
||||
const ws = this.workspaceStore(DEFAULT_WORKSPACE, false);
|
||||
for (const [namespace, subMap] of ws ?? new Map<string, NamespaceStore>()) {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const [k, v] of subMap) {
|
||||
obj[k] = v;
|
||||
|
|
@ -326,18 +496,22 @@ export class ConfigService extends AsyncProcessor {
|
|||
if (persistPath === null) return;
|
||||
|
||||
try {
|
||||
const data: Record<string, Record<string, unknown>> = {};
|
||||
const workspaces: Record<string, Record<string, Record<string, unknown>>> = {};
|
||||
|
||||
for (const [namespace, subMap] of this.store) {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const [k, v] of subMap) {
|
||||
obj[k] = v;
|
||||
for (const [workspace, ws] of this.store) {
|
||||
const workspaceData: Record<string, Record<string, unknown>> = {};
|
||||
for (const [namespace, subMap] of ws) {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const [k, v] of subMap) {
|
||||
obj[k] = v;
|
||||
}
|
||||
workspaceData[namespace] = obj;
|
||||
}
|
||||
data[namespace] = obj;
|
||||
workspaces[workspace] = workspaceData;
|
||||
}
|
||||
|
||||
const json = JSON.stringify(
|
||||
{ version: this.version, data },
|
||||
{ version: this.version, workspaces },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
|
@ -356,22 +530,39 @@ export class ConfigService extends AsyncProcessor {
|
|||
const raw = await readTextFile(persistPath);
|
||||
const parsed = JSON.parse(raw) as {
|
||||
version: number;
|
||||
data: Record<string, Record<string, unknown>>;
|
||||
data?: Record<string, Record<string, unknown>>;
|
||||
workspaces?: Record<string, Record<string, Record<string, unknown>>>;
|
||||
};
|
||||
|
||||
this.version = parsed.version ?? 0;
|
||||
this.store.clear();
|
||||
|
||||
for (const [namespace, obj] of Object.entries(parsed.data ?? {})) {
|
||||
const subMap = new Map<string, unknown>();
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
subMap.set(k, v);
|
||||
if (parsed.workspaces !== undefined) {
|
||||
for (const [workspace, namespaces] of Object.entries(parsed.workspaces)) {
|
||||
const ws = new Map<string, NamespaceStore>();
|
||||
for (const [namespace, obj] of Object.entries(namespaces)) {
|
||||
const subMap = new Map<string, unknown>();
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
subMap.set(k, v);
|
||||
}
|
||||
ws.set(namespace, subMap);
|
||||
}
|
||||
this.store.set(workspace, ws);
|
||||
}
|
||||
this.store.set(namespace, subMap);
|
||||
} else {
|
||||
const ws = new Map<string, NamespaceStore>();
|
||||
for (const [namespace, obj] of Object.entries(parsed.data ?? {})) {
|
||||
const subMap = new Map<string, unknown>();
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
subMap.set(k, v);
|
||||
}
|
||||
ws.set(namespace, subMap);
|
||||
}
|
||||
this.store.set(DEFAULT_WORKSPACE, ws);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ConfigService] Loaded persisted config (version=${this.version}, namespaces=${this.store.size})`,
|
||||
`[ConfigService] Loaded persisted config (version=${this.version}, workspaces=${this.store.size})`,
|
||||
);
|
||||
} catch {
|
||||
// File doesn't exist yet or is invalid — start fresh
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ import {
|
|||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import type { BackendProducer, BackendConsumer, Message } from "@trustgraph/base";
|
||||
import { joinPath, readTextFile, writeTextFile } from "../runtime/effect-files.js";
|
||||
import { Effect } from "effect";
|
||||
import { ensureDirectory, joinPath, readTextFile, writeTextFile } from "../runtime/effect-files.js";
|
||||
|
||||
export interface KnowledgeCoreServiceConfig extends ProcessorConfig {
|
||||
dataDir?: string;
|
||||
|
|
@ -32,9 +33,17 @@ interface KnowledgeCore {
|
|||
graphEmbeddings: { entity: Term; vectors: number[][] }[];
|
||||
}
|
||||
|
||||
interface DocumentEmbeddingsCore {
|
||||
metadata?: Record<string, unknown>;
|
||||
chunks?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class KnowledgeCoreService extends AsyncProcessor {
|
||||
/** Keyed by `${user}:${id}` */
|
||||
private cores = new Map<string, KnowledgeCore>();
|
||||
private deCores = new Map<string, DocumentEmbeddingsCore[]>();
|
||||
private readonly dataDir: string;
|
||||
private readonly persistPath: string;
|
||||
|
||||
private consumer: BackendConsumer<KnowledgeRequest> | null = null;
|
||||
|
|
@ -43,6 +52,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
constructor(config: KnowledgeCoreServiceConfig) {
|
||||
super(config);
|
||||
const dataDir = config.dataDir ?? process.env.KNOWLEDGE_DATA_DIR ?? "./data/knowledge";
|
||||
this.dataDir = dataDir;
|
||||
this.persistPath = joinPath(dataDir, "knowledge-state.json");
|
||||
}
|
||||
|
||||
|
|
@ -51,6 +61,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
protected override async run(): Promise<void> {
|
||||
await ensureDirectory(this.dataDir);
|
||||
// Load persisted state
|
||||
await this.loadFromDisk();
|
||||
|
||||
|
|
@ -116,11 +127,40 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
return this.putKgCore(request, requestId);
|
||||
case "load-kg-core":
|
||||
return this.loadKgCore(request, requestId);
|
||||
case "unload-kg-core":
|
||||
return this.unloadKgCore(request, requestId);
|
||||
case "list-de-cores":
|
||||
return this.listDeCores(request, requestId);
|
||||
case "get-de-core":
|
||||
return this.getDeCore(request, requestId);
|
||||
case "delete-de-core":
|
||||
return this.deleteDeCore(request, requestId);
|
||||
case "put-de-core":
|
||||
return this.putDeCore(request, requestId);
|
||||
case "load-de-core":
|
||||
return this.loadDeCore(request, requestId);
|
||||
default:
|
||||
throw new Error(`Unknown knowledge operation: ${request.operation as string}`);
|
||||
}
|
||||
}
|
||||
|
||||
private requestRecord(request: KnowledgeRequest): Record<string, unknown> {
|
||||
return request as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private graphEmbeddings(request: KnowledgeRequest): { entity: Term; vectors: number[][] }[] {
|
||||
const req = this.requestRecord(request);
|
||||
const value = request.graphEmbeddings ?? req["graph-embeddings"];
|
||||
return Array.isArray(value) ? value as { entity: Term; vectors: number[][] }[] : [];
|
||||
}
|
||||
|
||||
private documentEmbeddings(request: KnowledgeRequest): DocumentEmbeddingsCore | undefined {
|
||||
const req = this.requestRecord(request);
|
||||
const value = request.documentEmbeddings ?? req["document-embeddings"];
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
||||
return value as DocumentEmbeddingsCore;
|
||||
}
|
||||
|
||||
private async listKgCores(request: KnowledgeRequest, requestId: string): Promise<void> {
|
||||
const user = request.user ?? "";
|
||||
const prefix = user.length > 0 ? `${user}:` : "";
|
||||
|
|
@ -167,7 +207,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
const isLast = i + BATCH_SIZE >= core.graphEmbeddings.length;
|
||||
|
||||
await this.responseProducer!.send(
|
||||
{ graphEmbeddings: batch, eos: isLast },
|
||||
{ graphEmbeddings: batch, "graph-embeddings": batch, eos: isLast } as KnowledgeResponse,
|
||||
{ id: requestId },
|
||||
);
|
||||
}
|
||||
|
|
@ -207,8 +247,9 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
// Append graph embeddings if provided
|
||||
if (request.graphEmbeddings !== undefined && request.graphEmbeddings.length > 0) {
|
||||
core.graphEmbeddings.push(...request.graphEmbeddings);
|
||||
const graphEmbeddings = this.graphEmbeddings(request);
|
||||
if (graphEmbeddings.length > 0) {
|
||||
core.graphEmbeddings.push(...graphEmbeddings);
|
||||
}
|
||||
|
||||
await this.persist();
|
||||
|
|
@ -229,22 +270,108 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
throw new Error(`Knowledge core not found: ${key}`);
|
||||
}
|
||||
|
||||
// MVP: just acknowledge. Full implementation would publish triples
|
||||
// to flow storage topics via the flow config.
|
||||
if (core.triples.length > 0) {
|
||||
const producer = await this.pubsub.createProducer<unknown>({ topic: "tg.flow.triples" });
|
||||
try {
|
||||
await producer.send({
|
||||
metadata: {
|
||||
id: coreId,
|
||||
root: coreId,
|
||||
user,
|
||||
collection: request.collection ?? "default",
|
||||
},
|
||||
triples: core.triples,
|
||||
});
|
||||
} finally {
|
||||
await producer.close();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[KnowledgeCoreService] Load requested for core ${key} (triples=${core.triples.length}, embeddings=${core.graphEmbeddings.length}) — returning success`,
|
||||
`[KnowledgeCoreService] Loaded core ${key} (triples=${core.triples.length}, embeddings=${core.graphEmbeddings.length})`,
|
||||
);
|
||||
await this.responseProducer!.send({}, { id: requestId });
|
||||
}
|
||||
|
||||
private async unloadKgCore(_request: KnowledgeRequest, requestId: string): Promise<void> {
|
||||
await this.responseProducer!.send({}, { id: requestId });
|
||||
}
|
||||
|
||||
private async listDeCores(request: KnowledgeRequest, requestId: string): Promise<void> {
|
||||
const user = request.user ?? "";
|
||||
const prefix = user.length > 0 ? `${user}:` : "";
|
||||
const ids = [...this.deCores.keys()]
|
||||
.filter((key) => prefix.length === 0 || key.startsWith(prefix))
|
||||
.map((key) => key.slice(prefix.length));
|
||||
await this.responseProducer!.send({ ids }, { id: requestId });
|
||||
}
|
||||
|
||||
private async getDeCore(request: KnowledgeRequest, requestId: string): Promise<void> {
|
||||
const user = request.user ?? "";
|
||||
const coreId = request.id ?? "";
|
||||
const key = this.coreKey(user, coreId);
|
||||
const core = this.deCores.get(key);
|
||||
if (core === undefined) throw new Error(`Document embeddings core not found: ${key}`);
|
||||
|
||||
for (let i = 0; i < core.length; i++) {
|
||||
const isLast = i === core.length - 1;
|
||||
await this.responseProducer!.send(
|
||||
{
|
||||
documentEmbeddings: core[i],
|
||||
"document-embeddings": core[i],
|
||||
eos: isLast,
|
||||
} as KnowledgeResponse,
|
||||
{ id: requestId },
|
||||
);
|
||||
}
|
||||
if (core.length === 0) {
|
||||
await this.responseProducer!.send({ eos: true }, { id: requestId });
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteDeCore(request: KnowledgeRequest, requestId: string): Promise<void> {
|
||||
const user = request.user ?? "";
|
||||
const coreId = request.id ?? "";
|
||||
this.deCores.delete(this.coreKey(user, coreId));
|
||||
await this.persist();
|
||||
await this.responseProducer!.send({}, { id: requestId });
|
||||
}
|
||||
|
||||
private async putDeCore(request: KnowledgeRequest, requestId: string): Promise<void> {
|
||||
const user = request.user ?? "";
|
||||
const coreId = request.id ?? "";
|
||||
const key = this.coreKey(user, coreId);
|
||||
const item = this.documentEmbeddings(request);
|
||||
if (item === undefined) throw new Error("put-de-core requires document-embeddings");
|
||||
const core = this.deCores.get(key) ?? [];
|
||||
core.push(item);
|
||||
this.deCores.set(key, core);
|
||||
await this.persist();
|
||||
await this.responseProducer!.send({}, { id: requestId });
|
||||
}
|
||||
|
||||
private async loadDeCore(request: KnowledgeRequest, requestId: string): Promise<void> {
|
||||
const user = request.user ?? "";
|
||||
const coreId = request.id ?? "";
|
||||
const key = this.coreKey(user, coreId);
|
||||
if (!this.deCores.has(key)) throw new Error(`Document embeddings core not found: ${key}`);
|
||||
await this.responseProducer!.send({}, { id: requestId });
|
||||
}
|
||||
|
||||
// ---------- Persistence ----------
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
try {
|
||||
// Serialize Map to object
|
||||
const data: Record<string, KnowledgeCore> = {};
|
||||
const data: {
|
||||
kg: Record<string, KnowledgeCore>;
|
||||
de: Record<string, DocumentEmbeddingsCore[]>;
|
||||
} = { kg: {}, de: {} };
|
||||
for (const [key, core] of this.cores) {
|
||||
data[key] = core;
|
||||
data.kg[key] = core;
|
||||
}
|
||||
for (const [key, core] of this.deCores) {
|
||||
data.de[key] = core;
|
||||
}
|
||||
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
|
|
@ -257,14 +384,24 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
private async loadFromDisk(): Promise<void> {
|
||||
try {
|
||||
const raw = await readTextFile(this.persistPath);
|
||||
const parsed = JSON.parse(raw) as Record<string, KnowledgeCore>;
|
||||
const parsed = JSON.parse(raw) as Record<string, KnowledgeCore> | {
|
||||
kg?: Record<string, KnowledgeCore>;
|
||||
de?: Record<string, DocumentEmbeddingsCore[]>;
|
||||
};
|
||||
|
||||
this.cores.clear();
|
||||
for (const [key, core] of Object.entries(parsed)) {
|
||||
this.deCores.clear();
|
||||
const kg = "kg" in parsed && parsed.kg !== undefined ? parsed.kg : parsed as Record<string, KnowledgeCore>;
|
||||
for (const [key, core] of Object.entries(kg)) {
|
||||
this.cores.set(key, core);
|
||||
}
|
||||
if ("de" in parsed && parsed.de !== undefined) {
|
||||
for (const [key, core] of Object.entries(parsed.de)) {
|
||||
this.deCores.set(key, core);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[KnowledgeCoreService] Loaded persisted state (cores=${this.cores.size})`);
|
||||
console.log(`[KnowledgeCoreService] Loaded persisted state (kg=${this.cores.size}, de=${this.deCores.size})`);
|
||||
} catch {
|
||||
console.log("[KnowledgeCoreService] No persisted state found, starting fresh");
|
||||
}
|
||||
|
|
@ -293,5 +430,5 @@ export const program = makeProcessorProgram({
|
|||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await KnowledgeCoreService.launch("knowledge-svc");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
RequestResponseSpec,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type FlowResourceNotFoundError,
|
||||
type Document,
|
||||
type TextDocument,
|
||||
type Triples,
|
||||
|
|
@ -29,170 +30,205 @@ import {
|
|||
type Term,
|
||||
type LibrarianRequest,
|
||||
type LibrarianResponse,
|
||||
type MessagingDeliveryError,
|
||||
type MessagingTimeoutError,
|
||||
type Spec,
|
||||
errorMessage,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export class PdfDecoderError extends S.TaggedErrorClass<PdfDecoderError>()(
|
||||
"PdfDecoderError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
documentId: S.String,
|
||||
cause: S.DefectWithStack,
|
||||
},
|
||||
) {}
|
||||
|
||||
type PdfDecoderHandlerError =
|
||||
| FlowResourceNotFoundError
|
||||
| MessagingDeliveryError
|
||||
| MessagingTimeoutError
|
||||
| PdfDecoderError;
|
||||
|
||||
type PdfDocument = Awaited<ReturnType<typeof getDocument>["promise"]>;
|
||||
|
||||
const pdfDecoderError = (
|
||||
operation: string,
|
||||
documentId: string,
|
||||
cause: unknown,
|
||||
) =>
|
||||
new PdfDecoderError({
|
||||
operation,
|
||||
documentId,
|
||||
message: errorMessage(cause),
|
||||
cause,
|
||||
});
|
||||
|
||||
const loadPdf = (documentId: string, pdfBuffer: Buffer) =>
|
||||
Effect.tryPromise({
|
||||
try: () => getDocument({ data: new Uint8Array(pdfBuffer) }).promise,
|
||||
catch: (cause) => pdfDecoderError("load-pdf", documentId, cause),
|
||||
});
|
||||
|
||||
const loadPageText = (documentId: string, pageNumber: number, pdf: PdfDocument) =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const textContent = await page.getTextContent();
|
||||
return textContent.items
|
||||
.filter((item): item is TextItem => "str" in item)
|
||||
.map((item) => item.str)
|
||||
.join(" ");
|
||||
},
|
||||
catch: (cause) => pdfDecoderError("load-page-text", documentId, cause),
|
||||
});
|
||||
|
||||
const onPdfDecodeMessage = Effect.fn("PdfDecoderService.onMessage")(function* (
|
||||
msg: Document,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Effect.fn.Return<void, PdfDecoderHandlerError> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const { documentId } = msg;
|
||||
const user = msg.metadata.user;
|
||||
|
||||
const librarian = yield* flowCtx.flow.requestorEffect<LibrarianRequest, LibrarianResponse>(
|
||||
"librarian-client",
|
||||
);
|
||||
|
||||
const metadataResp = yield* librarian.request({
|
||||
operation: "get-document-metadata",
|
||||
documentId,
|
||||
user,
|
||||
});
|
||||
|
||||
if (metadataResp.error !== undefined) {
|
||||
yield* Effect.logError(`[PdfDecoder] Failed to get metadata for ${documentId}`, {
|
||||
error: metadataResp.error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = metadataResp.documentMetadata?.kind;
|
||||
if (kind !== "application/pdf") {
|
||||
yield* Effect.log(`[PdfDecoder] Skipping document ${documentId}: kind=${kind} (not PDF)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const contentResp = yield* librarian.request({
|
||||
operation: "get-document-content",
|
||||
documentId,
|
||||
user,
|
||||
});
|
||||
|
||||
if (
|
||||
contentResp.error !== undefined ||
|
||||
contentResp.content === undefined ||
|
||||
contentResp.content.length === 0
|
||||
) {
|
||||
yield* Effect.logError(`[PdfDecoder] Failed to get content for ${documentId}`, {
|
||||
error: contentResp.error?.message ?? "no content",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pdfBuffer = Buffer.from(contentResp.content, "base64");
|
||||
const pdf = yield* loadPdf(documentId, pdfBuffer);
|
||||
|
||||
yield* Effect.log(`[PdfDecoder] Document ${documentId}: ${pdf.numPages} pages`);
|
||||
|
||||
const outputProducer = yield* flowCtx.flow.producerEffect<TextDocument>("decode-output");
|
||||
const triplesProducer = yield* flowCtx.flow.producerEffect<Triples>("decode-triples");
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const pageText = yield* loadPageText(documentId, i, pdf);
|
||||
|
||||
if (pageText.trim().length === 0) {
|
||||
yield* Effect.log(`[PdfDecoder] Skipping empty page ${i} of document ${documentId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const childResp = yield* librarian.request({
|
||||
operation: "add-child-document",
|
||||
documentMetadata: {
|
||||
id: "",
|
||||
user,
|
||||
kind: "text/plain",
|
||||
title: `Page ${i}`,
|
||||
parentId: documentId,
|
||||
documentType: "page",
|
||||
time: Date.now(),
|
||||
comments: "",
|
||||
tags: [],
|
||||
},
|
||||
content: Buffer.from(pageText).toString("base64"),
|
||||
});
|
||||
|
||||
if (childResp.error !== undefined) {
|
||||
yield* Effect.logError(`[PdfDecoder] Failed to save page ${i} of ${documentId}`, {
|
||||
error: childResp.error.message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const childDocId = childResp.documentMetadata?.id ?? "";
|
||||
|
||||
yield* outputProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
text: pageText,
|
||||
documentId: childDocId,
|
||||
});
|
||||
|
||||
const triples: Triple[] = [
|
||||
{
|
||||
s: iriTerm(`urn:tg:page:${childDocId}`),
|
||||
p: iriTerm("http://www.w3.org/ns/prov#wasDerivedFrom"),
|
||||
o: iriTerm(`urn:tg:doc:${documentId}`),
|
||||
},
|
||||
{
|
||||
s: iriTerm(`urn:tg:page:${childDocId}`),
|
||||
p: iriTerm("http://www.w3.org/2000/01/rdf-schema#label"),
|
||||
o: literalTerm(`Page ${i}`),
|
||||
},
|
||||
];
|
||||
|
||||
yield* triplesProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
triples,
|
||||
});
|
||||
}
|
||||
|
||||
yield* Effect.log(`[PdfDecoder] Finished processing document ${documentId}`);
|
||||
});
|
||||
|
||||
export const makePdfDecoderSpecs = (): ReadonlyArray<Spec<never>> => [
|
||||
new ConsumerSpec<Document, PdfDecoderHandlerError>("decode-input", onPdfDecodeMessage),
|
||||
new ProducerSpec<TextDocument>("decode-output"),
|
||||
new ProducerSpec<Triples>("decode-triples"),
|
||||
new RequestResponseSpec<LibrarianRequest, LibrarianResponse>(
|
||||
"librarian-client",
|
||||
"librarian-request",
|
||||
"librarian-response",
|
||||
),
|
||||
];
|
||||
|
||||
export class PdfDecoderService extends FlowProcessor {
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<Document>("decode-input", this.onMessage.bind(this)),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<TextDocument>("decode-output"));
|
||||
this.registerSpecification(new ProducerSpec<Triples>("decode-triples"));
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<LibrarianRequest, LibrarianResponse>(
|
||||
"librarian-client",
|
||||
"librarian-request",
|
||||
"librarian-response",
|
||||
),
|
||||
);
|
||||
for (const spec of makePdfDecoderSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
console.log("[PdfDecoder] Service initialized");
|
||||
}
|
||||
|
||||
private async onMessage(
|
||||
msg: Document,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const { documentId } = msg;
|
||||
const user = msg.metadata.user;
|
||||
|
||||
const librarian = flowCtx.flow.requestor<LibrarianRequest, LibrarianResponse>(
|
||||
"librarian-client",
|
||||
);
|
||||
|
||||
// 1. Fetch document metadata to check MIME type
|
||||
const metadataResp = await librarian.request({
|
||||
operation: "get-document-metadata",
|
||||
documentId,
|
||||
user,
|
||||
});
|
||||
|
||||
if (metadataResp.error !== undefined) {
|
||||
console.error(
|
||||
`[PdfDecoder] Failed to get metadata for ${documentId}:`,
|
||||
metadataResp.error.message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = metadataResp.documentMetadata?.kind;
|
||||
if (kind !== "application/pdf") {
|
||||
console.log(
|
||||
`[PdfDecoder] Skipping document ${documentId}: kind=${kind} (not PDF)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Fetch document content
|
||||
const contentResp = await librarian.request({
|
||||
operation: "get-document-content",
|
||||
documentId,
|
||||
user,
|
||||
});
|
||||
|
||||
if (
|
||||
contentResp.error !== undefined ||
|
||||
contentResp.content === undefined ||
|
||||
contentResp.content.length === 0
|
||||
) {
|
||||
console.error(
|
||||
`[PdfDecoder] Failed to get content for ${documentId}:`,
|
||||
contentResp.error?.message ?? "no content",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Decode base64 content and extract text per page
|
||||
const pdfBuffer = Buffer.from(contentResp.content, "base64");
|
||||
const pdf = await getDocument({ data: new Uint8Array(pdfBuffer) }).promise;
|
||||
|
||||
console.log(
|
||||
`[PdfDecoder] Document ${documentId}: ${pdf.numPages} pages`,
|
||||
);
|
||||
|
||||
const outputProducer = flowCtx.flow.producer<TextDocument>("decode-output");
|
||||
const triplesProducer = flowCtx.flow.producer<Triples>("decode-triples");
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const textContent = await page.getTextContent();
|
||||
const pageText = textContent.items
|
||||
.filter((item): item is TextItem => "str" in item)
|
||||
.map((item) => item.str)
|
||||
.join(" ");
|
||||
|
||||
if (pageText.trim().length === 0) {
|
||||
console.log(
|
||||
`[PdfDecoder] Skipping empty page ${i} of document ${documentId}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Save as child document in librarian
|
||||
const childResp = await librarian.request({
|
||||
operation: "add-child-document",
|
||||
documentMetadata: {
|
||||
id: "",
|
||||
user,
|
||||
kind: "text/plain",
|
||||
title: `Page ${i}`,
|
||||
parentId: documentId,
|
||||
documentType: "page",
|
||||
time: Date.now(),
|
||||
comments: "",
|
||||
tags: [],
|
||||
},
|
||||
content: Buffer.from(pageText).toString("base64"),
|
||||
});
|
||||
|
||||
if (childResp.error !== undefined) {
|
||||
console.error(
|
||||
`[PdfDecoder] Failed to save page ${i} of ${documentId}:`,
|
||||
childResp.error.message,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const childDocId = childResp.documentMetadata?.id ?? "";
|
||||
|
||||
// 5. Emit TextDocument for the chunking pipeline
|
||||
await outputProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
text: pageText,
|
||||
documentId: childDocId,
|
||||
});
|
||||
|
||||
// 6. Emit provenance triples
|
||||
const triples: Triple[] = [
|
||||
{
|
||||
s: iriTerm(`urn:tg:page:${childDocId}`),
|
||||
p: iriTerm("http://www.w3.org/ns/prov#wasDerivedFrom"),
|
||||
o: iriTerm(`urn:tg:doc:${documentId}`),
|
||||
},
|
||||
{
|
||||
s: iriTerm(`urn:tg:page:${childDocId}`),
|
||||
p: iriTerm("http://www.w3.org/2000/01/rdf-schema#label"),
|
||||
o: literalTerm(`Page ${i}`),
|
||||
},
|
||||
];
|
||||
|
||||
await triplesProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
triples,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[PdfDecoder] Finished processing document ${documentId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function iriTerm(iri: string): Term {
|
||||
|
|
@ -203,11 +239,11 @@ function literalTerm(value: string): Term {
|
|||
return { type: "LITERAL", value };
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram({
|
||||
id: "pdf-decoder",
|
||||
make: (config) => new PdfDecoderService(config),
|
||||
specs: () => makePdfDecoderSpecs(),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await PdfDecoderService.launch("pdf-decoder");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,11 @@ import {
|
|||
Embeddings,
|
||||
EmbeddingsService,
|
||||
embeddingsError,
|
||||
makeEmbeddingsSpecs,
|
||||
type EmbeddingsServiceShape,
|
||||
type ProcessorConfig,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
|
||||
export interface OllamaEmbeddingsConfig extends ProcessorConfig {
|
||||
model?: string;
|
||||
|
|
@ -102,11 +103,12 @@ export class OllamaEmbeddingsProcessor extends EmbeddingsService {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<OllamaEmbeddingsConfig, never, Embeddings>({
|
||||
id: "embeddings",
|
||||
make: (config) => new OllamaEmbeddingsProcessor(config),
|
||||
specs: () => makeEmbeddingsSpecs(),
|
||||
layer: (config) => OllamaEmbeddingsLive(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await OllamaEmbeddingsProcessor.launch("embeddings");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
ConsumerSpec,
|
||||
ProducerSpec,
|
||||
RequestResponseSpec,
|
||||
makeFlowProcessorProgram,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type Chunk,
|
||||
|
|
@ -27,229 +28,270 @@ import {
|
|||
type TextCompletionResponse,
|
||||
type Triple,
|
||||
type Term,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type EffectRequestResponse,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
// Well-known RDF/SKOS IRIs
|
||||
const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
|
||||
const SKOS_DEFINITION = "http://www.w3.org/2004/02/skos/core#definition";
|
||||
|
||||
interface ExtractedRelationship {
|
||||
subject: string;
|
||||
predicate: string;
|
||||
object: string;
|
||||
}
|
||||
const ExtractedRelationship = S.Struct({
|
||||
subject: S.String,
|
||||
predicate: S.String,
|
||||
object: S.String,
|
||||
});
|
||||
type ExtractedRelationship = typeof ExtractedRelationship.Type;
|
||||
|
||||
interface ExtractedDefinition {
|
||||
entity: string;
|
||||
definition: string;
|
||||
}
|
||||
const ExtractedRelationshipsFromJson = S.Array(ExtractedRelationship).pipe(S.fromJsonString);
|
||||
const decodeExtractedRelationships = S.decodeUnknownOption(ExtractedRelationshipsFromJson);
|
||||
|
||||
const ExtractedDefinition = S.Struct({
|
||||
entity: S.String,
|
||||
definition: S.String,
|
||||
});
|
||||
type ExtractedDefinition = typeof ExtractedDefinition.Type;
|
||||
|
||||
const ExtractedDefinitionsFromJson = S.Array(ExtractedDefinition).pipe(S.fromJsonString);
|
||||
const decodeExtractedDefinitions = S.decodeUnknownOption(ExtractedDefinitionsFromJson);
|
||||
|
||||
type KnowledgeExtractHandlerError =
|
||||
| FlowResourceNotFoundError
|
||||
| MessagingDeliveryError;
|
||||
|
||||
type PromptClient = EffectRequestResponse<PromptRequest, PromptResponse>;
|
||||
type LlmClient = EffectRequestResponse<TextCompletionRequest, TextCompletionResponse>;
|
||||
|
||||
const requestPrompt = Effect.fn("KnowledgeExtract.requestPrompt")(function* (
|
||||
promptClient: PromptClient,
|
||||
name: string,
|
||||
text: string,
|
||||
) {
|
||||
return yield* promptClient.request(
|
||||
{ name, variables: { text } },
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
});
|
||||
|
||||
const requestCompletion = Effect.fn("KnowledgeExtract.requestCompletion")(function* (
|
||||
llmClient: LlmClient,
|
||||
prompt: PromptResponse,
|
||||
) {
|
||||
return yield* llmClient.request(
|
||||
{ system: prompt.system, prompt: prompt.prompt },
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
});
|
||||
|
||||
const extractRelationships = Effect.fn("KnowledgeExtract.extractRelationships")(function* (
|
||||
promptClient: PromptClient,
|
||||
llmClient: LlmClient,
|
||||
text: string,
|
||||
) {
|
||||
const relPrompt = yield* requestPrompt(promptClient, "extract-relationships", text);
|
||||
if (relPrompt.error !== undefined) return null;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const relCompletion = yield* requestCompletion(llmClient, relPrompt);
|
||||
|
||||
if (relCompletion.error !== undefined || relCompletion.response.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const relationships = parseRelationshipsResponse(relCompletion.response);
|
||||
if (relationships !== null) return relationships;
|
||||
|
||||
yield* Effect.logWarning(
|
||||
`[KnowledgeExtract] Relationship parse failed, attempt ${attempt + 1}/3`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const extractDefinitions = Effect.fn("KnowledgeExtract.extractDefinitions")(function* (
|
||||
promptClient: PromptClient,
|
||||
llmClient: LlmClient,
|
||||
text: string,
|
||||
) {
|
||||
const defPrompt = yield* requestPrompt(promptClient, "extract-definitions", text);
|
||||
if (defPrompt.error !== undefined) return null;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const defCompletion = yield* requestCompletion(llmClient, defPrompt);
|
||||
|
||||
if (defCompletion.error !== undefined || defCompletion.response.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const definitions = parseDefinitionsResponse(defCompletion.response);
|
||||
if (definitions !== null) return definitions;
|
||||
|
||||
yield* Effect.logWarning(
|
||||
`[KnowledgeExtract] Definition parse failed, attempt ${attempt + 1}/3`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const onKnowledgeExtractMessage = Effect.fn("KnowledgeExtractService.onMessage")(function* (
|
||||
msg: Chunk,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Effect.fn.Return<void, KnowledgeExtractHandlerError> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const text = msg.chunk;
|
||||
if (text.trim().length === 0) return;
|
||||
|
||||
const promptClient = yield* flowCtx.flow.requestorEffect<PromptRequest, PromptResponse>("prompt-client");
|
||||
const llmClient = yield* flowCtx.flow.requestorEffect<TextCompletionRequest, TextCompletionResponse>("llm-client");
|
||||
const triplesProducer = yield* flowCtx.flow.producerEffect<Triples>("extract-triples");
|
||||
const entityContextsProducer = yield* flowCtx.flow.producerEffect<EntityContexts>("extract-entity-contexts");
|
||||
|
||||
const allTriples: Triple[] = [];
|
||||
const allEntityContexts: EntityContext[] = [];
|
||||
|
||||
const relationships = yield* extractRelationships(promptClient, llmClient, text).pipe(
|
||||
Effect.catch((error: unknown) =>
|
||||
Effect.logError("[KnowledgeExtract] Relationship extraction failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}).pipe(Effect.as(null)),
|
||||
),
|
||||
);
|
||||
|
||||
if (relationships !== null) {
|
||||
for (const rel of relationships) {
|
||||
if (
|
||||
rel.subject.length === 0 ||
|
||||
rel.predicate.length === 0 ||
|
||||
rel.object.length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const subjectIri = toEntityIri(rel.subject);
|
||||
const predicateIri = toEntityIri(rel.predicate);
|
||||
const objectIri = toEntityIri(rel.object);
|
||||
|
||||
allTriples.push({ s: subjectIri, p: predicateIri, o: objectIri });
|
||||
allTriples.push({
|
||||
s: subjectIri,
|
||||
p: iriTerm(RDFS_LABEL),
|
||||
o: literalTerm(rel.subject),
|
||||
});
|
||||
allTriples.push({
|
||||
s: predicateIri,
|
||||
p: iriTerm(RDFS_LABEL),
|
||||
o: literalTerm(rel.predicate),
|
||||
});
|
||||
allTriples.push({
|
||||
s: objectIri,
|
||||
p: iriTerm(RDFS_LABEL),
|
||||
o: literalTerm(rel.object),
|
||||
});
|
||||
|
||||
allEntityContexts.push({
|
||||
entity: subjectIri,
|
||||
context: text,
|
||||
chunkId: msg.documentId,
|
||||
});
|
||||
allEntityContexts.push({
|
||||
entity: objectIri,
|
||||
context: text,
|
||||
chunkId: msg.documentId,
|
||||
});
|
||||
}
|
||||
|
||||
yield* Effect.log(`[KnowledgeExtract] Extracted ${relationships.length} relationships`);
|
||||
}
|
||||
|
||||
const definitions = yield* extractDefinitions(promptClient, llmClient, text).pipe(
|
||||
Effect.catch((error: unknown) =>
|
||||
Effect.logError("[KnowledgeExtract] Definition extraction failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}).pipe(Effect.as(null)),
|
||||
),
|
||||
);
|
||||
|
||||
if (definitions !== null) {
|
||||
for (const def of definitions) {
|
||||
if (def.entity.length === 0 || def.definition.length === 0) continue;
|
||||
|
||||
const entityIri = toEntityIri(def.entity);
|
||||
|
||||
allTriples.push({
|
||||
s: entityIri,
|
||||
p: iriTerm(SKOS_DEFINITION),
|
||||
o: literalTerm(def.definition),
|
||||
});
|
||||
allTriples.push({
|
||||
s: entityIri,
|
||||
p: iriTerm(RDFS_LABEL),
|
||||
o: literalTerm(def.entity),
|
||||
});
|
||||
|
||||
allEntityContexts.push({
|
||||
entity: entityIri,
|
||||
context: text,
|
||||
chunkId: msg.documentId,
|
||||
});
|
||||
}
|
||||
|
||||
yield* Effect.log(`[KnowledgeExtract] Extracted ${definitions.length} definitions`);
|
||||
}
|
||||
|
||||
if (allTriples.length > 0) {
|
||||
yield* triplesProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
triples: allTriples,
|
||||
});
|
||||
}
|
||||
|
||||
if (allEntityContexts.length > 0) {
|
||||
yield* entityContextsProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
entities: allEntityContexts,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const makeKnowledgeExtractSpecs = (): ReadonlyArray<Spec<never>> => [
|
||||
new ConsumerSpec<Chunk, KnowledgeExtractHandlerError>(
|
||||
"extract-input",
|
||||
onKnowledgeExtractMessage,
|
||||
),
|
||||
new ProducerSpec<Triples>("extract-triples"),
|
||||
new ProducerSpec<EntityContexts>("extract-entity-contexts"),
|
||||
new RequestResponseSpec<PromptRequest, PromptResponse>(
|
||||
"prompt-client",
|
||||
"prompt-request",
|
||||
"prompt-response",
|
||||
),
|
||||
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
|
||||
"llm-client",
|
||||
"text-completion-request",
|
||||
"text-completion-response",
|
||||
),
|
||||
];
|
||||
|
||||
export class KnowledgeExtractService extends FlowProcessor {
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<Chunk>("extract-input", this.onMessage.bind(this)),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<Triples>("extract-triples"));
|
||||
this.registerSpecification(new ProducerSpec<EntityContexts>("extract-entity-contexts"));
|
||||
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<PromptRequest, PromptResponse>(
|
||||
"prompt-client",
|
||||
"prompt-request",
|
||||
"prompt-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
|
||||
"llm-client",
|
||||
"text-completion-request",
|
||||
"text-completion-response",
|
||||
),
|
||||
);
|
||||
for (const spec of makeKnowledgeExtractSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
console.log("[KnowledgeExtract] Service initialized");
|
||||
}
|
||||
|
||||
private async onMessage(
|
||||
msg: Chunk,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const text = msg.chunk;
|
||||
if (text.trim().length === 0) return;
|
||||
|
||||
const promptClient = flowCtx.flow.requestor<PromptRequest, PromptResponse>("prompt-client");
|
||||
const llmClient = flowCtx.flow.requestor<TextCompletionRequest, TextCompletionResponse>("llm-client");
|
||||
const triplesProducer = flowCtx.flow.producer<Triples>("extract-triples");
|
||||
const entityContextsProducer = flowCtx.flow.producer<EntityContexts>("extract-entity-contexts");
|
||||
|
||||
const allTriples: Triple[] = [];
|
||||
const allEntityContexts: EntityContext[] = [];
|
||||
|
||||
// --- Extract relationships ---
|
||||
try {
|
||||
const relPrompt = await promptClient.request(
|
||||
{ name: "extract-relationships", variables: { text } },
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
if (relPrompt.error === undefined) {
|
||||
let relationships: ExtractedRelationship[] | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const relCompletion = await llmClient.request(
|
||||
{ system: relPrompt.system, prompt: relPrompt.prompt },
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
|
||||
if (
|
||||
relCompletion.error === undefined &&
|
||||
relCompletion.response.length > 0
|
||||
) {
|
||||
relationships = parseJsonResponse<ExtractedRelationship[]>(relCompletion.response);
|
||||
if (relationships !== null) break;
|
||||
console.warn(`[KnowledgeExtract] Relationship parse failed, attempt ${attempt + 1}/3`);
|
||||
} else {
|
||||
break; // LLM error, don't retry
|
||||
}
|
||||
}
|
||||
|
||||
if (relationships !== null) {
|
||||
for (const rel of relationships) {
|
||||
if (
|
||||
rel.subject.length === 0 ||
|
||||
rel.predicate.length === 0 ||
|
||||
rel.object.length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const subjectIri = toEntityIri(rel.subject);
|
||||
const predicateIri = toEntityIri(rel.predicate);
|
||||
const objectIri = toEntityIri(rel.object);
|
||||
|
||||
// Main relationship triple
|
||||
allTriples.push({ s: subjectIri, p: predicateIri, o: objectIri });
|
||||
|
||||
// rdfs:label triples for each entity
|
||||
allTriples.push({
|
||||
s: subjectIri,
|
||||
p: iriTerm(RDFS_LABEL),
|
||||
o: literalTerm(rel.subject),
|
||||
});
|
||||
allTriples.push({
|
||||
s: predicateIri,
|
||||
p: iriTerm(RDFS_LABEL),
|
||||
o: literalTerm(rel.predicate),
|
||||
});
|
||||
allTriples.push({
|
||||
s: objectIri,
|
||||
p: iriTerm(RDFS_LABEL),
|
||||
o: literalTerm(rel.object),
|
||||
});
|
||||
|
||||
// Entity contexts for subject and object
|
||||
allEntityContexts.push({
|
||||
entity: subjectIri,
|
||||
context: text,
|
||||
chunkId: msg.documentId,
|
||||
});
|
||||
allEntityContexts.push({
|
||||
entity: objectIri,
|
||||
context: text,
|
||||
chunkId: msg.documentId,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[KnowledgeExtract] Extracted ${relationships.length} relationships`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[KnowledgeExtract] Relationship extraction failed:", err);
|
||||
}
|
||||
|
||||
// --- Extract definitions ---
|
||||
try {
|
||||
const defPrompt = await promptClient.request(
|
||||
{ name: "extract-definitions", variables: { text } },
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
if (defPrompt.error === undefined) {
|
||||
let definitions: ExtractedDefinition[] | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const defCompletion = await llmClient.request(
|
||||
{ system: defPrompt.system, prompt: defPrompt.prompt },
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
|
||||
if (
|
||||
defCompletion.error === undefined &&
|
||||
defCompletion.response.length > 0
|
||||
) {
|
||||
definitions = parseJsonResponse<ExtractedDefinition[]>(defCompletion.response);
|
||||
if (definitions !== null) break;
|
||||
console.warn(`[KnowledgeExtract] Definition parse failed, attempt ${attempt + 1}/3`);
|
||||
} else {
|
||||
break; // LLM error, don't retry
|
||||
}
|
||||
}
|
||||
|
||||
if (definitions !== null) {
|
||||
for (const def of definitions) {
|
||||
if (def.entity.length === 0 || def.definition.length === 0) continue;
|
||||
|
||||
const entityIri = toEntityIri(def.entity);
|
||||
|
||||
// Definition triple
|
||||
allTriples.push({
|
||||
s: entityIri,
|
||||
p: iriTerm(SKOS_DEFINITION),
|
||||
o: literalTerm(def.definition),
|
||||
});
|
||||
|
||||
// Label triple
|
||||
allTriples.push({
|
||||
s: entityIri,
|
||||
p: iriTerm(RDFS_LABEL),
|
||||
o: literalTerm(def.entity),
|
||||
});
|
||||
|
||||
// Entity context
|
||||
allEntityContexts.push({
|
||||
entity: entityIri,
|
||||
context: text,
|
||||
chunkId: msg.documentId,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[KnowledgeExtract] Extracted ${definitions.length} definitions`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[KnowledgeExtract] Definition extraction failed:", err);
|
||||
}
|
||||
|
||||
// --- Emit results ---
|
||||
if (allTriples.length > 0) {
|
||||
await triplesProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
triples: allTriples,
|
||||
});
|
||||
}
|
||||
|
||||
if (allEntityContexts.length > 0) {
|
||||
await entityContextsProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
entities: allEntityContexts,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
|
@ -275,53 +317,68 @@ function literalTerm(value: string): Term {
|
|||
* Uses progressive fallback: direct parse, array extraction, truncated array repair, single object wrap.
|
||||
*/
|
||||
export function parseJsonResponse<T>(raw: string): T | null {
|
||||
// Attempt 1: direct parse after stripping fences
|
||||
let cleaned = raw.trim();
|
||||
const fenceMatch = cleaned.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
|
||||
if (fenceMatch !== null) {
|
||||
cleaned = (fenceMatch[1] ?? "").trim();
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(cleaned) as T;
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// Attempt 2: extract first JSON array from the text
|
||||
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
|
||||
if (arrayMatch !== null) {
|
||||
try {
|
||||
return JSON.parse(arrayMatch[0]) as T;
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// Attempt 3: try to fix truncated array by closing it after the last complete object
|
||||
const partial = arrayMatch[0];
|
||||
const lastBrace = partial.lastIndexOf('}');
|
||||
if (lastBrace > 0) {
|
||||
const truncated = partial.slice(0, lastBrace + 1) + ']';
|
||||
try {
|
||||
return JSON.parse(truncated) as T;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt 4: extract first JSON object, wrap in array
|
||||
const objMatch = cleaned.match(/\{[\s\S]*?\}/);
|
||||
if (objMatch !== null) {
|
||||
try {
|
||||
const obj = JSON.parse(objMatch[0]);
|
||||
return [obj] as unknown as T;
|
||||
} catch { /* fall through */ }
|
||||
const decodeJson = S.decodeUnknownOption(S.UnknownFromJsonString);
|
||||
for (const candidate of jsonCandidates(raw)) {
|
||||
const decoded = decodeJson(candidate);
|
||||
if (O.isSome(decoded)) return decoded.value as T;
|
||||
}
|
||||
|
||||
console.warn("[KnowledgeExtract] Failed to parse JSON from LLM response:", raw.slice(0, 300));
|
||||
return null;
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
function parseRelationshipsResponse(raw: string): ReadonlyArray<ExtractedRelationship> | null {
|
||||
for (const candidate of jsonCandidates(raw)) {
|
||||
const decoded = decodeExtractedRelationships(candidate);
|
||||
if (O.isSome(decoded)) return decoded.value;
|
||||
}
|
||||
console.warn("[KnowledgeExtract] Failed to parse relationships from LLM response:", raw.slice(0, 300));
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDefinitionsResponse(raw: string): ReadonlyArray<ExtractedDefinition> | null {
|
||||
for (const candidate of jsonCandidates(raw)) {
|
||||
const decoded = decodeExtractedDefinitions(candidate);
|
||||
if (O.isSome(decoded)) return decoded.value;
|
||||
}
|
||||
console.warn("[KnowledgeExtract] Failed to parse definitions from LLM response:", raw.slice(0, 300));
|
||||
return null;
|
||||
}
|
||||
|
||||
function jsonCandidates(raw: string): ReadonlyArray<string> {
|
||||
const candidates: string[] = [];
|
||||
let cleaned = raw.trim();
|
||||
const fenceMatch = cleaned.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
|
||||
if (fenceMatch !== null) {
|
||||
cleaned = (fenceMatch[1] ?? "").trim();
|
||||
}
|
||||
|
||||
candidates.push(cleaned);
|
||||
|
||||
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
|
||||
if (arrayMatch !== null) {
|
||||
candidates.push(arrayMatch[0]);
|
||||
|
||||
const partial = arrayMatch[0];
|
||||
const lastBrace = partial.lastIndexOf("}");
|
||||
if (lastBrace > 0) {
|
||||
candidates.push(partial.slice(0, lastBrace + 1) + "]");
|
||||
}
|
||||
}
|
||||
|
||||
const objMatch = cleaned.match(/\{[\s\S]*?\}/);
|
||||
if (objMatch !== null) {
|
||||
candidates.push(`[${objMatch[0]}]`);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export const program = makeFlowProcessorProgram({
|
||||
id: "knowledge-extract",
|
||||
make: (config) => new KnowledgeExtractService(config),
|
||||
specs: () => makeKnowledgeExtractSpecs(),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await KnowledgeExtractService.launch("knowledge-extract");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import type {
|
|||
BackendConsumer,
|
||||
Message,
|
||||
} from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
|
||||
// ---------- Internal state types ----------
|
||||
|
||||
|
|
@ -35,13 +36,48 @@ interface FlowInstance {
|
|||
id: string;
|
||||
blueprintName: string;
|
||||
description: string;
|
||||
parameters: Record<string, string>;
|
||||
parameters: Record<string, unknown>;
|
||||
status: "running" | "stopped";
|
||||
}
|
||||
|
||||
interface Blueprint {
|
||||
description: string;
|
||||
topics: Record<string, string>;
|
||||
parameters?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ConfigValueEntry {
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function configValues(response: ConfigResponse): ConfigValueEntry[] {
|
||||
const values = response.values;
|
||||
if (!Array.isArray(values)) return [];
|
||||
return values.flatMap((value) => {
|
||||
if (!isRecord(value)) return [];
|
||||
const key = optionalString(value.key);
|
||||
if (key === undefined) return [];
|
||||
return [{ key, value: value.value }];
|
||||
});
|
||||
}
|
||||
|
||||
function parseConfigRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
try {
|
||||
const parsed = typeof value === "string" ? JSON.parse(value) as unknown : value;
|
||||
return isRecord(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Default blueprint ----------
|
||||
|
|
@ -122,6 +158,8 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
subscription: `${this.config.id}-config-client`,
|
||||
});
|
||||
await this.configClient.start();
|
||||
await this.ensureDefaultBlueprint();
|
||||
await this.refreshBlueprintsFromConfig();
|
||||
|
||||
// Create producer for flow-response topic
|
||||
this.responseProducer = await this.pubsub.createProducer<Record<string, unknown>>({
|
||||
|
|
@ -178,15 +216,101 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
private async configRequest(request: ConfigRequest): Promise<ConfigResponse> {
|
||||
if (this.configClient === null) throw new Error("Config client not started");
|
||||
return this.configClient.request(request);
|
||||
}
|
||||
|
||||
private async ensureDefaultBlueprint(): Promise<void> {
|
||||
const response = await this.configRequest({
|
||||
operation: "getvalues",
|
||||
type: "flow-blueprint",
|
||||
});
|
||||
if (configValues(response).some((value) => value.key === "default")) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.configRequest({
|
||||
operation: "put",
|
||||
keys: ["flow-blueprint"],
|
||||
values: {
|
||||
default: JSON.stringify(DEFAULT_BLUEPRINT),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshBlueprintsFromConfig(): Promise<void> {
|
||||
const response = await this.configRequest({
|
||||
operation: "getvalues",
|
||||
type: "flow-blueprint",
|
||||
});
|
||||
const next = new Map<string, Blueprint>();
|
||||
|
||||
for (const item of configValues(response)) {
|
||||
const parsed = parseConfigRecord(item.value);
|
||||
if (parsed === undefined) continue;
|
||||
next.set(item.key, parsed as Blueprint);
|
||||
}
|
||||
|
||||
if (!next.has("default")) {
|
||||
next.set("default", DEFAULT_BLUEPRINT);
|
||||
}
|
||||
this.blueprints = next;
|
||||
}
|
||||
|
||||
private async refreshFlowsFromConfig(): Promise<void> {
|
||||
const response = await this.configRequest({
|
||||
operation: "getvalues",
|
||||
type: "flow",
|
||||
});
|
||||
const next = new Map<string, FlowInstance>();
|
||||
|
||||
for (const item of configValues(response)) {
|
||||
const parsed = parseConfigRecord(item.value);
|
||||
if (parsed === undefined) continue;
|
||||
const parameters = isRecord(parsed.parameters) ? parsed.parameters : {};
|
||||
next.set(item.key, {
|
||||
id: item.key,
|
||||
blueprintName: optionalString(parsed["blueprint-name"]) ?? optionalString(parsed.blueprintName) ?? "default",
|
||||
description: optionalString(parsed.description) ?? "",
|
||||
parameters,
|
||||
status: "running",
|
||||
});
|
||||
}
|
||||
|
||||
if (next.size === 0) {
|
||||
const flowsResponse = await this.configRequest({
|
||||
operation: "getvalues",
|
||||
type: "flows",
|
||||
});
|
||||
for (const item of configValues(flowsResponse)) {
|
||||
next.set(item.key, {
|
||||
id: item.key,
|
||||
blueprintName: "default",
|
||||
description: "",
|
||||
parameters: {},
|
||||
status: "running",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.flows = next;
|
||||
}
|
||||
|
||||
private async handleOperation(
|
||||
request: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const op = request.operation as string;
|
||||
await this.refreshBlueprintsFromConfig();
|
||||
await this.refreshFlowsFromConfig();
|
||||
|
||||
switch (op) {
|
||||
case "list-blueprints":
|
||||
return this.handleListBlueprints();
|
||||
|
||||
case "put-blueprint":
|
||||
return await this.handlePutBlueprint(request);
|
||||
|
||||
case "get-blueprint":
|
||||
return this.handleGetBlueprint(request);
|
||||
|
||||
|
|
@ -236,9 +360,33 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
};
|
||||
}
|
||||
|
||||
private handleDeleteBlueprint(
|
||||
private async handlePutBlueprint(
|
||||
request: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
): Promise<Record<string, unknown>> {
|
||||
const name = request["blueprint-name"] as string | undefined;
|
||||
if (name === undefined || name.length === 0) {
|
||||
throw new Error("Missing blueprint-name");
|
||||
}
|
||||
const rawDefinition = request["blueprint-definition"];
|
||||
if (rawDefinition === undefined) {
|
||||
throw new Error("Missing blueprint-definition");
|
||||
}
|
||||
const definition = typeof rawDefinition === "string"
|
||||
? rawDefinition
|
||||
: JSON.stringify(rawDefinition);
|
||||
|
||||
await this.configRequest({
|
||||
operation: "put",
|
||||
keys: ["flow-blueprint"],
|
||||
values: { [name]: definition },
|
||||
});
|
||||
await this.refreshBlueprintsFromConfig();
|
||||
return {};
|
||||
}
|
||||
|
||||
private async handleDeleteBlueprint(
|
||||
request: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const name = request["blueprint-name"] as string | undefined;
|
||||
if (name === undefined || name.length === 0) {
|
||||
throw new Error("Missing blueprint-name");
|
||||
|
|
@ -248,10 +396,11 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
throw new Error("Cannot delete the default blueprint");
|
||||
}
|
||||
|
||||
const existed = this.blueprints.delete(name);
|
||||
if (!existed) {
|
||||
throw new Error(`Blueprint not found: ${name}`);
|
||||
}
|
||||
await this.configRequest({
|
||||
operation: "delete",
|
||||
keys: ["flow-blueprint", name],
|
||||
});
|
||||
this.blueprints.delete(name);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
|
@ -292,7 +441,7 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
const id = request["flow-id"] as string | undefined;
|
||||
const blueprintName = (request["blueprint-name"] as string) ?? "default";
|
||||
const description = (request["description"] as string) ?? "";
|
||||
const parameters = (request["parameters"] as Record<string, string>) ?? {};
|
||||
const parameters = (request["parameters"] as Record<string, unknown>) ?? {};
|
||||
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("Missing flow-id");
|
||||
|
|
@ -342,13 +491,15 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
|
||||
this.flows.delete(id);
|
||||
|
||||
console.log(`[FlowManager] Stopped flow "${id}"`);
|
||||
console.log(`[FlowManager] Stopped flow "${id}"`);
|
||||
|
||||
// Push updated flows config (without the removed flow)
|
||||
await this.pushFlowsConfig();
|
||||
await this.deleteFlowConfig(id);
|
||||
|
||||
return {};
|
||||
}
|
||||
// Push updated flows config (without the removed flow)
|
||||
await this.pushFlowsConfig();
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
// ---------- Config push ----------
|
||||
|
||||
|
|
@ -360,10 +511,16 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
if (this.configClient === null) return;
|
||||
|
||||
const flowsConfig: Record<string, { topics: Record<string, string> }> = {};
|
||||
const flowRecords: Record<string, string> = {};
|
||||
for (const [id, inst] of this.flows) {
|
||||
const blueprint = this.blueprints.get(inst.blueprintName);
|
||||
if (blueprint !== undefined) {
|
||||
flowsConfig[id] = { topics: blueprint.topics };
|
||||
flowRecords[id] = JSON.stringify({
|
||||
"blueprint-name": inst.blueprintName,
|
||||
description: inst.description,
|
||||
parameters: inst.parameters,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -373,6 +530,11 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
keys: ["flows"],
|
||||
values: flowsConfig,
|
||||
});
|
||||
await this.configClient.request({
|
||||
operation: "put",
|
||||
keys: ["flow"],
|
||||
values: flowRecords,
|
||||
});
|
||||
console.log(
|
||||
`[FlowManager] Pushed flows config (${this.flows.size} active flows)`,
|
||||
);
|
||||
|
|
@ -381,6 +543,18 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
private async deleteFlowConfig(id: string): Promise<void> {
|
||||
if (this.configClient === null) return;
|
||||
await this.configClient.request({
|
||||
operation: "delete",
|
||||
keys: ["flows", id],
|
||||
});
|
||||
await this.configClient.request({
|
||||
operation: "delete",
|
||||
keys: ["flow", id],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Lifecycle ----------
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
|
|
@ -410,5 +584,5 @@ export const program = makeProcessorProgram({
|
|||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await FlowManagerService.launch("flow-manager");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
/**
|
||||
* WebSocket multiplexer — handles concurrent requests over a single connection.
|
||||
*
|
||||
* Python reference: trustgraph-flow/trustgraph/gateway/dispatch/mux.py
|
||||
*/
|
||||
|
||||
import { AsyncQueue } from "@trustgraph/base";
|
||||
|
||||
const MAX_OUTSTANDING = 15;
|
||||
const MAX_QUEUE_SIZE = 10;
|
||||
|
||||
export interface MuxRequest {
|
||||
id: string;
|
||||
service: string;
|
||||
flow?: string;
|
||||
request: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type MuxHandler = (
|
||||
request: MuxRequest,
|
||||
respond: (response: unknown, complete: boolean) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
|
||||
export class Mux {
|
||||
private queue = new AsyncQueue<MuxRequest>();
|
||||
private outstanding = 0;
|
||||
private running = true;
|
||||
private readonly handler: MuxHandler;
|
||||
|
||||
constructor(handler: MuxHandler) {
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
receive(request: MuxRequest): void {
|
||||
if (this.queue.length >= MAX_QUEUE_SIZE) {
|
||||
console.warn("[Mux] Queue full, dropping request:", request.id);
|
||||
return;
|
||||
}
|
||||
this.queue.push(request);
|
||||
}
|
||||
|
||||
async run(send: (data: string) => void): Promise<void> {
|
||||
while (this.running) {
|
||||
if (this.outstanding >= MAX_OUTSTANDING) {
|
||||
await sleep(50);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const request = await this.queue.pop(1000);
|
||||
this.outstanding++;
|
||||
|
||||
// Fire and forget — error handling inside
|
||||
this.processRequest(request, send).finally(() => {
|
||||
this.outstanding--;
|
||||
});
|
||||
} catch {
|
||||
// Timeout on queue pop — just loop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
private async processRequest(
|
||||
request: MuxRequest,
|
||||
send: (data: string) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.handler(request, async (response, complete) => {
|
||||
send(JSON.stringify({ id: request.id, response, complete }));
|
||||
});
|
||||
} catch (err) {
|
||||
send(
|
||||
JSON.stringify({
|
||||
id: request.id,
|
||||
error: { type: "internal", message: String(err) },
|
||||
complete: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
export { createGateway, run, type GatewayConfig } from "./server.js";
|
||||
export { DispatcherManager } from "./dispatch/manager.js";
|
||||
export { Mux, type MuxRequest, type MuxHandler } from "./dispatch/mux.js";
|
||||
export {
|
||||
clientTermToInternal,
|
||||
clientTripleToInternal,
|
||||
|
|
|
|||
35
ts/packages/flow/src/gateway/rpc-contract.ts
Normal file
35
ts/packages/flow/src/gateway/rpc-contract.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Schema as S } from "effect";
|
||||
import * as Rpc from "effect/unstable/rpc/Rpc";
|
||||
import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
|
||||
|
||||
export class DispatchPayload extends S.Class<DispatchPayload>("DispatchPayload")({
|
||||
scope: S.Literals(["global", "flow"]),
|
||||
service: S.String,
|
||||
flow: S.optionalKey(S.String),
|
||||
request: S.Record(S.String, S.Unknown),
|
||||
}) {}
|
||||
|
||||
export class DispatchStreamChunk extends S.Class<DispatchStreamChunk>("DispatchStreamChunk")({
|
||||
response: S.Unknown,
|
||||
complete: S.Boolean,
|
||||
}) {}
|
||||
|
||||
export class DispatchError extends S.ErrorClass<DispatchError>("DispatchError")({
|
||||
_tag: S.tag("DispatchError"),
|
||||
message: S.String,
|
||||
}) {}
|
||||
|
||||
export class Dispatch extends Rpc.make("Dispatch", {
|
||||
payload: DispatchPayload,
|
||||
success: S.Unknown,
|
||||
error: DispatchError,
|
||||
}) {}
|
||||
|
||||
export class DispatchStream extends Rpc.make("DispatchStream", {
|
||||
payload: DispatchPayload,
|
||||
success: DispatchStreamChunk,
|
||||
error: DispatchError,
|
||||
stream: true,
|
||||
}) {}
|
||||
|
||||
export const TrustGraphRpcs = RpcGroup.make(Dispatch, DispatchStream);
|
||||
92
ts/packages/flow/src/gateway/rpc-protocol.ts
Normal file
92
ts/packages/flow/src/gateway/rpc-protocol.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { Effect, Queue, Scope } from "effect";
|
||||
import * as RpcMessage from "effect/unstable/rpc/RpcMessage";
|
||||
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
||||
import * as RpcServer from "effect/unstable/rpc/RpcServer";
|
||||
import * as Socket from "effect/unstable/socket/Socket";
|
||||
|
||||
export const makeSocketRpcProtocol = Effect.gen(function* () {
|
||||
const serialization = yield* RpcSerialization.RpcSerialization;
|
||||
const disconnects = yield* Queue.make<number>();
|
||||
|
||||
let nextClientId = 0;
|
||||
const clients = new Map<number, {
|
||||
readonly write: (response: RpcMessage.FromServerEncoded) => Effect.Effect<void>;
|
||||
}>();
|
||||
const clientIds = new Set<number>();
|
||||
|
||||
let writeRequest!: (
|
||||
clientId: number,
|
||||
message: RpcMessage.FromClientEncoded,
|
||||
) => Effect.Effect<void>;
|
||||
|
||||
const onSocket = function* (
|
||||
socket: Socket.Socket,
|
||||
headers?: ReadonlyArray<[string, string]>,
|
||||
) {
|
||||
const scope = yield* Effect.scope;
|
||||
const parser = serialization.makeUnsafe();
|
||||
const clientId = nextClientId++;
|
||||
|
||||
yield* Scope.addFinalizerExit(scope, () => {
|
||||
clients.delete(clientId);
|
||||
clientIds.delete(clientId);
|
||||
return Queue.offer(disconnects, clientId);
|
||||
});
|
||||
|
||||
const writeRaw = yield* socket.writer;
|
||||
const write = (response: RpcMessage.FromServerEncoded) => {
|
||||
try {
|
||||
const encoded = parser.encode(response);
|
||||
if (encoded === undefined) return Effect.void;
|
||||
return Effect.orDie(writeRaw(encoded));
|
||||
} catch (cause) {
|
||||
return Effect.orDie(
|
||||
writeRaw(parser.encode(RpcMessage.ResponseDefectEncoded(cause))!),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
clients.set(clientId, { write });
|
||||
clientIds.add(clientId);
|
||||
|
||||
yield* socket.runRaw((data) => {
|
||||
try {
|
||||
const decoded = parser.decode(data) as ReadonlyArray<RpcMessage.FromClientEncoded>;
|
||||
return Effect.forEach(decoded, (message) => {
|
||||
if (message._tag === "Request" && headers !== undefined) {
|
||||
return writeRequest(clientId, {
|
||||
...message,
|
||||
headers: headers.concat(message.headers),
|
||||
});
|
||||
}
|
||||
return writeRequest(clientId, message);
|
||||
}, { discard: true });
|
||||
} catch (cause) {
|
||||
return writeRaw(parser.encode(RpcMessage.ResponseDefectEncoded(cause))!);
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.orDie,
|
||||
);
|
||||
};
|
||||
|
||||
const protocol = yield* RpcServer.Protocol.make((writeRequest_) => {
|
||||
writeRequest = writeRequest_;
|
||||
return Effect.succeed({
|
||||
disconnects,
|
||||
send: (clientId, response) => {
|
||||
const client = clients.get(clientId);
|
||||
if (client === undefined) return Effect.void;
|
||||
return Effect.orDie(client.write(response));
|
||||
},
|
||||
end: () => Effect.void,
|
||||
clientIds: Effect.sync(() => clientIds),
|
||||
initialMessage: Effect.succeedNone,
|
||||
supportsAck: true,
|
||||
supportsTransferables: false,
|
||||
supportsSpanPropagation: true,
|
||||
});
|
||||
});
|
||||
|
||||
return { onSocket, protocol } as const;
|
||||
});
|
||||
109
ts/packages/flow/src/gateway/rpc-server.ts
Normal file
109
ts/packages/flow/src/gateway/rpc-server.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { Cause, Effect, Layer, Queue, Scope } from "effect";
|
||||
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
||||
import * as RpcServer from "effect/unstable/rpc/RpcServer";
|
||||
import type * as Socket from "effect/unstable/socket/Socket";
|
||||
import { errorMessage } from "@trustgraph/base";
|
||||
import type { DispatcherManager } from "./dispatch/manager.js";
|
||||
import { DispatchError, DispatchPayload, DispatchStreamChunk, TrustGraphRpcs } from "./rpc-contract.js";
|
||||
import { makeSocketRpcProtocol } from "./rpc-protocol.js";
|
||||
|
||||
export interface GatewayRpcServer {
|
||||
readonly onSocket: (
|
||||
socket: Socket.Socket,
|
||||
headers?: ReadonlyArray<[string, string]>,
|
||||
) => Effect.Effect<void, never, Scope.Scope>;
|
||||
}
|
||||
|
||||
export const makeGatewayRpcServer = Effect.fn("makeGatewayRpcServer")(function* (
|
||||
dispatcher: DispatcherManager,
|
||||
) {
|
||||
const { onSocket, protocol } = yield* makeSocketRpcProtocol;
|
||||
|
||||
const serverLayer = RpcServer.layer(TrustGraphRpcs, {
|
||||
disableFatalDefects: true,
|
||||
}).pipe(
|
||||
Layer.provide(Layer.succeed(RpcServer.Protocol, protocol)),
|
||||
Layer.provide(makeGatewayRpcHandlers(dispatcher)),
|
||||
Layer.provide(RpcSerialization.layerNdjson),
|
||||
);
|
||||
|
||||
yield* Layer.launch(serverLayer).pipe(Effect.forkScoped);
|
||||
|
||||
return {
|
||||
onSocket: Effect.fn("GatewayRpc.onSocket")(function* (socket, headers) {
|
||||
yield* onSocket(socket, headers);
|
||||
}),
|
||||
} satisfies GatewayRpcServer;
|
||||
});
|
||||
|
||||
const makeGatewayRpcHandlers = (dispatcher: DispatcherManager) =>
|
||||
TrustGraphRpcs.toLayer(Effect.succeed(
|
||||
TrustGraphRpcs.of({
|
||||
Dispatch: (payload) =>
|
||||
Effect.tryPromise({
|
||||
try: () => dispatchOne(dispatcher, payload),
|
||||
catch: (cause) => new DispatchError({ message: errorMessage(cause) }),
|
||||
}),
|
||||
DispatchStream: Effect.fn("GatewayRpc.DispatchStream")(function* (payload) {
|
||||
const context = yield* Effect.context<never>();
|
||||
const runPromise = Effect.runPromiseWith(context);
|
||||
const queue = yield* Queue.bounded<DispatchStreamChunk, DispatchError | Cause.Done>(16);
|
||||
yield* Effect.addFinalizer(() => Queue.shutdown(queue));
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
dispatchStream(dispatcher, payload, async (response, complete) => {
|
||||
await runPromise(Queue.offer(queue, new DispatchStreamChunk({ response, complete })));
|
||||
return complete;
|
||||
}),
|
||||
catch: (cause) => new DispatchError({ message: errorMessage(cause) }),
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Queue.end(queue)),
|
||||
Effect.catch((error) => Queue.fail(queue, error)),
|
||||
Effect.forkScoped,
|
||||
);
|
||||
|
||||
return queue;
|
||||
}),
|
||||
}),
|
||||
));
|
||||
|
||||
function dispatchOne(
|
||||
dispatcher: DispatcherManager,
|
||||
payload: DispatchPayload,
|
||||
): Promise<unknown> {
|
||||
if (payload.scope === "flow") {
|
||||
return dispatcher.dispatchFlowService(
|
||||
payload.flow ?? "default",
|
||||
payload.service,
|
||||
payload.request,
|
||||
);
|
||||
}
|
||||
return dispatcher.dispatchGlobalService(payload.service, payload.request);
|
||||
}
|
||||
|
||||
async function dispatchStream(
|
||||
dispatcher: DispatcherManager,
|
||||
payload: DispatchPayload,
|
||||
responder: (response: unknown, complete: boolean) => Promise<boolean>,
|
||||
): Promise<void> {
|
||||
const send = async (response: unknown, complete: boolean) => {
|
||||
await responder(response, complete);
|
||||
};
|
||||
|
||||
if (payload.scope === "flow") {
|
||||
await dispatcher.dispatchFlowServiceStreaming(
|
||||
payload.flow ?? "default",
|
||||
payload.service,
|
||||
payload.request,
|
||||
send,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await dispatcher.dispatchGlobalServiceStreaming(
|
||||
payload.service,
|
||||
payload.request,
|
||||
send,
|
||||
);
|
||||
}
|
||||
|
|
@ -2,19 +2,20 @@
|
|||
* API Gateway — HTTP + WebSocket server.
|
||||
*
|
||||
* Replaces the Python aiohttp gateway with Fastify.
|
||||
* Uses the Mux class for WebSocket multiplexing (queue-based request
|
||||
* buffering, concurrency control, proper task lifecycle).
|
||||
* Uses Effect RPC over WebSocket for streaming client requests.
|
||||
*
|
||||
* Python reference: trustgraph-flow/trustgraph/gateway/service.py
|
||||
*/
|
||||
|
||||
import Fastify from "fastify";
|
||||
import websocketPlugin from "@fastify/websocket";
|
||||
import { Config, Effect } from "effect";
|
||||
import { Config, Effect, Exit, Scope } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import { errorMessage, optionalStringConfig, registry, toTgError } from "@trustgraph/base";
|
||||
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
||||
import * as EffectSocket from "effect/unstable/socket/Socket";
|
||||
import { optionalStringConfig, registry, toTgError } from "@trustgraph/base";
|
||||
import { DispatcherManager } from "./dispatch/manager.js";
|
||||
import { Mux, type MuxRequest, type MuxHandler } from "./dispatch/mux.js";
|
||||
import { makeGatewayRpcServer } from "./rpc-server.js";
|
||||
|
||||
export interface GatewayConfig {
|
||||
port: number;
|
||||
|
|
@ -29,11 +30,18 @@ export async function createGateway(config: GatewayConfig) {
|
|||
|
||||
const dispatcher = new DispatcherManager(config);
|
||||
await dispatcher.start();
|
||||
const rpcScope = await Effect.runPromise(Scope.make());
|
||||
const rpcServer = await Effect.runPromise(
|
||||
makeGatewayRpcServer(dispatcher).pipe(
|
||||
Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.ndjson),
|
||||
Scope.provide(rpcScope),
|
||||
),
|
||||
);
|
||||
|
||||
// Authentication middleware
|
||||
app.addHook("onRequest", async (request, reply) => {
|
||||
if (request.url === "/api/v1/metrics") return;
|
||||
if (request.url.startsWith("/api/v1/socket")) return; // Socket auth via query param
|
||||
if (request.url.startsWith("/api/v1/rpc")) return; // RPC socket auth via query param
|
||||
|
||||
if (config.secret !== undefined && config.secret.length > 0) {
|
||||
const auth = request.headers.authorization;
|
||||
|
|
@ -43,6 +51,38 @@ export async function createGateway(config: GatewayConfig) {
|
|||
}
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Body: {
|
||||
scope?: string;
|
||||
service?: string;
|
||||
flow?: string;
|
||||
request?: Record<string, unknown>;
|
||||
};
|
||||
}>("/api/v1/workbench/dispatch", async (request, reply) => {
|
||||
const body = request.body;
|
||||
const service = body.service;
|
||||
const payload = body.request;
|
||||
if (service === undefined || service.length === 0 || payload === undefined) {
|
||||
return reply.code(400).send({
|
||||
error: { type: "bad-request", message: "service and request are required" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = body.scope === "flow"
|
||||
? await dispatcher.dispatchFlowService(body.flow ?? "default", service, payload)
|
||||
: await dispatcher.dispatchGlobalService(service, payload);
|
||||
const err = (result as Record<string, unknown>)?.error as { type?: string; message?: string } | undefined;
|
||||
if (err !== undefined) {
|
||||
const statusCode = err.type === "not-found" ? 404 : 400;
|
||||
return reply.code(statusCode).send(result);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
reply.code(500).send({ error: toTgError(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// REST endpoint: POST /api/v1/:kind (global services)
|
||||
app.post<{ Params: { kind: string } }>("/api/v1/:kind", async (request, reply) => {
|
||||
const { kind } = request.params;
|
||||
|
|
@ -124,10 +164,8 @@ export async function createGateway(config: GatewayConfig) {
|
|||
},
|
||||
);
|
||||
|
||||
// WebSocket endpoint: /api/v1/socket
|
||||
// Uses Mux for queue-based request buffering and concurrency control.
|
||||
app.get("/api/v1/socket", { websocket: true }, (socket, request) => {
|
||||
// Auth via query param
|
||||
// Effect RPC WebSocket endpoint: /api/v1/rpc
|
||||
app.get("/api/v1/rpc", { websocket: true }, (socket, request) => {
|
||||
const url = new URL(request.url, `http://${request.headers.host}`);
|
||||
const token = url.searchParams.get("token");
|
||||
if (config.secret !== undefined && config.secret.length > 0 && token !== config.secret) {
|
||||
|
|
@ -135,91 +173,18 @@ export async function createGateway(config: GatewayConfig) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Build the MuxHandler that dispatches to the DispatcherManager
|
||||
const handler: MuxHandler = async (muxReq, respond) => {
|
||||
if (muxReq.flow !== undefined && muxReq.flow.length > 0) {
|
||||
await dispatcher.dispatchFlowServiceStreaming(
|
||||
muxReq.flow,
|
||||
muxReq.service,
|
||||
muxReq.request,
|
||||
respond,
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const effectSocket = yield* EffectSocket.fromWebSocket(
|
||||
Effect.succeed(socket as unknown as globalThis.WebSocket),
|
||||
{ closeCodeIsError: (code) => code !== 1000 },
|
||||
);
|
||||
} else {
|
||||
await dispatcher.dispatchGlobalServiceStreaming(
|
||||
muxReq.service,
|
||||
muxReq.request,
|
||||
respond,
|
||||
);
|
||||
}
|
||||
};
|
||||
yield* rpcServer.onSocket(effectSocket, headersFrom(request.headers));
|
||||
}),
|
||||
);
|
||||
|
||||
const mux = new Mux(handler);
|
||||
|
||||
// Start the Mux run loop — sends responses back over the socket
|
||||
const runPromise = mux.run((data) => {
|
||||
// Only send if the socket is still open (readyState 1 = OPEN)
|
||||
if (socket.readyState === 1) {
|
||||
socket.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Incoming messages get queued into the Mux
|
||||
socket.on("message", (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString()) as {
|
||||
id?: string;
|
||||
service?: string;
|
||||
flow?: string;
|
||||
request?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (
|
||||
msg.id === undefined ||
|
||||
msg.id.length === 0 ||
|
||||
msg.service === undefined ||
|
||||
msg.service.length === 0 ||
|
||||
msg.request === undefined
|
||||
) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: msg.id ?? null,
|
||||
error: { type: "bad-request", message: "Missing id, service, or request" },
|
||||
complete: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const muxReq: MuxRequest = {
|
||||
id: msg.id,
|
||||
service: msg.service,
|
||||
request: msg.request,
|
||||
...(msg.flow !== undefined ? { flow: msg.flow } : {}),
|
||||
};
|
||||
|
||||
mux.receive(muxReq);
|
||||
} catch (err) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
error: { type: "parse-error", message: errorMessage(err) },
|
||||
complete: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
mux.stop();
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
mux.stop();
|
||||
});
|
||||
|
||||
// Ensure runPromise errors don't go unhandled
|
||||
runPromise.catch((err) => {
|
||||
console.error("[Gateway] Mux run loop error:", err);
|
||||
mux.stop();
|
||||
Effect.runPromise(program.pipe(Scope.provide(rpcScope))).catch((err) => {
|
||||
console.error("[Gateway] RPC WebSocket error:", err);
|
||||
if (socket.readyState === 1) {
|
||||
socket.close(1011, "Internal server error");
|
||||
}
|
||||
|
|
@ -236,11 +201,21 @@ export async function createGateway(config: GatewayConfig) {
|
|||
start: () => app.listen({ port: config.port, host: "0.0.0.0" }),
|
||||
stop: async () => {
|
||||
await app.close();
|
||||
await Effect.runPromise(Scope.close(rpcScope, Exit.void));
|
||||
await dispatcher.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function headersFrom(headers: Record<string, string | string[] | number | undefined>): ReadonlyArray<[string, string]> {
|
||||
return Object.entries(headers).flatMap(([key, value]) => {
|
||||
if (typeof value === "string") return [[key, value] satisfies [string, string]];
|
||||
if (typeof value === "number") return [[key, String(value)] satisfies [string, string]];
|
||||
if (Array.isArray(value)) return value.map((item) => [key, item] satisfies [string, string]);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,28 @@
|
|||
export { createGateway, type GatewayConfig } from "./gateway/index.js";
|
||||
export { OpenAIProcessor } from "./model/text-completion/openai.js";
|
||||
export { ClaudeProcessor } from "./model/text-completion/claude.js";
|
||||
export { GraphRag, type GraphRagConfig, type GraphRagClients } from "./retrieval/graph-rag.js";
|
||||
export { DocumentRag, type DocumentRagClients } from "./retrieval/document-rag.js";
|
||||
export {
|
||||
GraphRag,
|
||||
GraphRagEngine,
|
||||
GraphRagLive,
|
||||
makeGraphRagEngine,
|
||||
normalizeGraphRagConfig,
|
||||
stringToTerm,
|
||||
termToString,
|
||||
type GraphRagConfig,
|
||||
type GraphRagClients,
|
||||
type GraphRagEngineShape,
|
||||
type GraphRagQueryOptions,
|
||||
} from "./retrieval/graph-rag.js";
|
||||
export {
|
||||
DocumentRag,
|
||||
DocumentRagEngine,
|
||||
DocumentRagLive,
|
||||
makeDocumentRagEngine,
|
||||
type DocumentRagClients,
|
||||
type DocumentRagEngineShape,
|
||||
type DocumentRagQueryOptions,
|
||||
} from "./retrieval/document-rag.js";
|
||||
export { FalkorDBTriplesStore, type FalkorDBConfig } from "./storage/triples/falkordb.js";
|
||||
export { FalkorDBTriplesQuery, type FalkorDBQueryConfig } from "./query/triples/falkordb.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import type { BackendProducer, BackendConsumer, Message } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import { CollectionManager } from "./collection-manager.js";
|
||||
import {
|
||||
ensureDirectory,
|
||||
|
|
@ -38,9 +39,29 @@ export interface LibrarianServiceConfig extends ProcessorConfig {
|
|||
dataDir?: string;
|
||||
}
|
||||
|
||||
interface UploadSession {
|
||||
id: string;
|
||||
documentMetadata: DocumentMetadata;
|
||||
totalSize: number;
|
||||
chunkSize: number;
|
||||
totalChunks: number;
|
||||
createdAt: string;
|
||||
chunks: Map<number, string>;
|
||||
user: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export class LibrarianService extends AsyncProcessor {
|
||||
private documents = new Map<string, DocumentMetadata>();
|
||||
private processing = new Map<string, ProcessingMetadata>();
|
||||
private uploads = new Map<string, UploadSession>();
|
||||
private collectionManager = new CollectionManager();
|
||||
private readonly dataDir: string;
|
||||
private readonly persistPath: string;
|
||||
|
|
@ -112,6 +133,107 @@ export class LibrarianService extends AsyncProcessor {
|
|||
|
||||
// ---------- Librarian message handling ----------
|
||||
|
||||
private requestRecord(request: LibrarianRequest): Record<string, unknown> {
|
||||
return request as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private documentId(request: LibrarianRequest): string | undefined {
|
||||
const req = this.requestRecord(request);
|
||||
return optionalString(req.documentId) ?? optionalString(req["document-id"]);
|
||||
}
|
||||
|
||||
private processingId(request: LibrarianRequest): string | undefined {
|
||||
const req = this.requestRecord(request);
|
||||
return optionalString(req.processingId) ?? optionalString(req["processing-id"]);
|
||||
}
|
||||
|
||||
private documentMetadata(request: LibrarianRequest): DocumentMetadata | undefined {
|
||||
const req = this.requestRecord(request);
|
||||
const value = req.documentMetadata ?? req["document-metadata"];
|
||||
return isRecord(value) ? this.normaliseDocumentMetadata(value) : undefined;
|
||||
}
|
||||
|
||||
private processingMetadata(request: LibrarianRequest): ProcessingMetadata | undefined {
|
||||
const req = this.requestRecord(request);
|
||||
const value = req.processingMetadata ?? req["processing-metadata"];
|
||||
if (!isRecord(value)) return undefined;
|
||||
const documentId = optionalString(value.documentId) ?? optionalString(value["document-id"]) ?? "";
|
||||
return {
|
||||
id: optionalString(value.id) ?? crypto.randomUUID(),
|
||||
documentId,
|
||||
"document-id": documentId,
|
||||
time: typeof value.time === "number" ? value.time : Math.floor(Date.now() / 1000),
|
||||
flow: optionalString(value.flow) ?? "default",
|
||||
user: optionalString(value.user) ?? optionalString(this.requestRecord(request).user) ?? "default",
|
||||
collection: optionalString(value.collection) ?? optionalString(this.requestRecord(request).collection) ?? "default",
|
||||
tags: Array.isArray(value.tags) ? value.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
||||
};
|
||||
}
|
||||
|
||||
private normaliseDocumentMetadata(value: Record<string, unknown>): DocumentMetadata {
|
||||
const id = optionalString(value.id) ?? crypto.randomUUID();
|
||||
const parentId = optionalString(value.parentId) ?? optionalString(value["parent-id"]);
|
||||
const documentType = optionalString(value.documentType) ?? optionalString(value["document-type"]) ?? "source";
|
||||
return {
|
||||
id,
|
||||
time: typeof value.time === "number" ? value.time : Math.floor(Date.now() / 1000),
|
||||
kind: optionalString(value.kind) ?? "application/octet-stream",
|
||||
title: optionalString(value.title) ?? "",
|
||||
comments: optionalString(value.comments) ?? "",
|
||||
user: optionalString(value.user) ?? "default",
|
||||
tags: Array.isArray(value.tags) ? value.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
||||
...(parentId !== undefined ? { parentId, "parent-id": parentId } : {}),
|
||||
documentType,
|
||||
"document-type": documentType,
|
||||
...(Array.isArray(value.metadata) ? { metadata: value.metadata as NonNullable<DocumentMetadata["metadata"]> } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private publicDocument(doc: DocumentMetadata): DocumentMetadata {
|
||||
const parentId = doc.parentId ?? doc["parent-id"];
|
||||
const documentType = doc.documentType ?? doc["document-type"] ?? "source";
|
||||
return {
|
||||
...doc,
|
||||
...(parentId !== undefined ? { parentId, "parent-id": parentId } : {}),
|
||||
documentType,
|
||||
"document-type": documentType,
|
||||
};
|
||||
}
|
||||
|
||||
private publicProcessing(proc: ProcessingMetadata): ProcessingMetadata {
|
||||
const documentId = proc.documentId ?? proc["document-id"] ?? "";
|
||||
return {
|
||||
...proc,
|
||||
documentId,
|
||||
"document-id": documentId,
|
||||
};
|
||||
}
|
||||
|
||||
private documentResponse(doc: DocumentMetadata): LibrarianResponse {
|
||||
const publicDoc = this.publicDocument(doc);
|
||||
return {
|
||||
documentMetadata: publicDoc,
|
||||
"document-metadata": publicDoc,
|
||||
};
|
||||
}
|
||||
|
||||
private documentsResponse(docs: DocumentMetadata[]): LibrarianResponse {
|
||||
const publicDocs = docs.map((doc) => this.publicDocument(doc));
|
||||
return {
|
||||
documents: publicDocs,
|
||||
"document-metadatas": publicDocs,
|
||||
};
|
||||
}
|
||||
|
||||
private processingResponse(records: ProcessingMetadata[]): LibrarianResponse {
|
||||
const publicRecords = records.map((proc) => this.publicProcessing(proc));
|
||||
return {
|
||||
processing: publicRecords,
|
||||
"processing-metadata": publicRecords,
|
||||
"processing-metadatas": publicRecords,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleLibrarianMessage(msg: Message<LibrarianRequest>): Promise<void> {
|
||||
const request = msg.value();
|
||||
const props = msg.properties();
|
||||
|
|
@ -123,6 +245,12 @@ export class LibrarianService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
try {
|
||||
if (request.operation === "stream-document") {
|
||||
for (const response of await this.streamDocument(request)) {
|
||||
await this.libProducer!.send(response, { id: requestId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const response = await this.handleLibrarianOperation(request);
|
||||
await this.libProducer!.send(response, { id: requestId });
|
||||
} catch (err) {
|
||||
|
|
@ -140,6 +268,8 @@ export class LibrarianService extends AsyncProcessor {
|
|||
return this.addDocument(request);
|
||||
case "remove-document":
|
||||
return this.removeDocument(request);
|
||||
case "update-document":
|
||||
return this.updateDocument(request);
|
||||
case "list-documents":
|
||||
return this.listDocuments(request);
|
||||
case "get-document-metadata":
|
||||
|
|
@ -156,17 +286,31 @@ export class LibrarianService extends AsyncProcessor {
|
|||
return this.removeProcessing(request);
|
||||
case "list-processing":
|
||||
return this.listProcessing(request);
|
||||
case "begin-upload":
|
||||
return this.beginUpload(request);
|
||||
case "upload-chunk":
|
||||
return this.uploadChunk(request);
|
||||
case "complete-upload":
|
||||
return this.completeUpload(request);
|
||||
case "get-upload-status":
|
||||
return this.getUploadStatus(request);
|
||||
case "abort-upload":
|
||||
return this.abortUpload(request);
|
||||
case "list-uploads":
|
||||
return this.listUploads(request);
|
||||
case "stream-document":
|
||||
throw new Error("stream-document must be handled as a streaming operation");
|
||||
default:
|
||||
throw new Error(`Unknown librarian operation: ${request.operation as string}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async addDocument(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const meta = request.documentMetadata;
|
||||
const meta = this.documentMetadata(request);
|
||||
if (meta === undefined) throw new Error("add-document requires documentMetadata");
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const id = meta.id;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const doc: DocumentMetadata = {
|
||||
...meta,
|
||||
|
|
@ -186,11 +330,11 @@ export class LibrarianService extends AsyncProcessor {
|
|||
await this.persist();
|
||||
console.log(`[LibrarianService] Added document ${id}: ${doc.title}`);
|
||||
|
||||
return { documentMetadata: doc };
|
||||
return this.documentResponse(doc);
|
||||
}
|
||||
|
||||
private async removeDocument(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const id = request.documentId;
|
||||
const id = this.documentId(request);
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("remove-document requires documentId");
|
||||
}
|
||||
|
|
@ -234,23 +378,45 @@ export class LibrarianService extends AsyncProcessor {
|
|||
return {};
|
||||
}
|
||||
|
||||
private async updateDocument(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const id = this.documentId(request) ?? this.documentMetadata(request)?.id;
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("update-document requires documentId");
|
||||
}
|
||||
const existing = this.documents.get(id);
|
||||
if (existing === undefined) throw new Error(`Document not found: ${id}`);
|
||||
const meta = this.documentMetadata(request);
|
||||
if (meta === undefined) throw new Error("update-document requires documentMetadata");
|
||||
|
||||
const doc: DocumentMetadata = this.publicDocument({
|
||||
...existing,
|
||||
...meta,
|
||||
id,
|
||||
time: meta.time ?? existing.time,
|
||||
});
|
||||
this.documents.set(id, doc);
|
||||
await this.persist();
|
||||
return this.documentResponse(doc);
|
||||
}
|
||||
|
||||
private listDocuments(request: LibrarianRequest): LibrarianResponse {
|
||||
const user = request.user ?? "";
|
||||
const includeChildren = this.requestRecord(request)["include-children"] === true;
|
||||
const docs: DocumentMetadata[] = [];
|
||||
|
||||
for (const doc of this.documents.values()) {
|
||||
// Filter by user
|
||||
if (user.length > 0 && doc.user !== user) continue;
|
||||
// Exclude children (only top-level documents) unless explicitly requested
|
||||
if (doc.parentId !== undefined && doc.parentId.length > 0) continue;
|
||||
if (!includeChildren && doc.parentId !== undefined && doc.parentId.length > 0) continue;
|
||||
docs.push(doc);
|
||||
}
|
||||
|
||||
return { documents: docs };
|
||||
return this.documentsResponse(docs);
|
||||
}
|
||||
|
||||
private getDocumentMetadata(request: LibrarianRequest): LibrarianResponse {
|
||||
const id = request.documentId;
|
||||
const id = this.documentId(request);
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("get-document-metadata requires documentId");
|
||||
}
|
||||
|
|
@ -258,11 +424,11 @@ export class LibrarianService extends AsyncProcessor {
|
|||
const doc = this.documents.get(id);
|
||||
if (doc === undefined) throw new Error(`Document not found: ${id}`);
|
||||
|
||||
return { documentMetadata: doc };
|
||||
return this.documentResponse(doc);
|
||||
}
|
||||
|
||||
private async getDocumentContent(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const id = request.documentId;
|
||||
const id = this.documentId(request);
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("get-document-content requires documentId");
|
||||
}
|
||||
|
|
@ -274,14 +440,14 @@ export class LibrarianService extends AsyncProcessor {
|
|||
const filePath = joinPath(this.dataDir, "docs", `${id}.bin`);
|
||||
const buf = await readBinaryFile(filePath);
|
||||
const content = Buffer.from(buf).toString("base64");
|
||||
return { documentMetadata: doc, content };
|
||||
return { ...this.documentResponse(doc), content };
|
||||
} catch {
|
||||
throw new Error(`Document content not found on disk: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async addChildDocument(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const meta = request.documentMetadata;
|
||||
const meta = this.documentMetadata(request);
|
||||
if (meta === undefined) {
|
||||
throw new Error("add-child-document requires documentMetadata");
|
||||
}
|
||||
|
|
@ -294,8 +460,8 @@ export class LibrarianService extends AsyncProcessor {
|
|||
throw new Error(`Parent document not found: ${meta.parentId}`);
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const id = meta.id;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const doc: DocumentMetadata = {
|
||||
...meta,
|
||||
|
|
@ -315,11 +481,11 @@ export class LibrarianService extends AsyncProcessor {
|
|||
await this.persist();
|
||||
console.log(`[LibrarianService] Added child document ${id} (parent: ${meta.parentId})`);
|
||||
|
||||
return { documentMetadata: doc };
|
||||
return this.documentResponse(doc);
|
||||
}
|
||||
|
||||
private listChildren(request: LibrarianRequest): LibrarianResponse {
|
||||
const parentId = request.documentId;
|
||||
const parentId = this.documentId(request);
|
||||
if (parentId === undefined || parentId.length === 0) {
|
||||
throw new Error("list-children requires documentId");
|
||||
}
|
||||
|
|
@ -331,15 +497,15 @@ export class LibrarianService extends AsyncProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
return { documents: children };
|
||||
return this.documentsResponse(children);
|
||||
}
|
||||
|
||||
private async addProcessing(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const proc = request.processingMetadata;
|
||||
const proc = this.processingMetadata(request);
|
||||
if (proc === undefined) throw new Error("add-processing requires processingMetadata");
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const id = proc.id;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const record: ProcessingMetadata = {
|
||||
...proc,
|
||||
|
|
@ -351,11 +517,11 @@ export class LibrarianService extends AsyncProcessor {
|
|||
await this.persist();
|
||||
|
||||
console.log(`[LibrarianService] Added processing ${id} for document ${proc.documentId}`);
|
||||
return { processing: [record] };
|
||||
return this.processingResponse([record]);
|
||||
}
|
||||
|
||||
private async removeProcessing(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const id = request.processingId;
|
||||
const id = this.processingId(request);
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("remove-processing requires processingId");
|
||||
}
|
||||
|
|
@ -367,17 +533,167 @@ export class LibrarianService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
private listProcessing(request: LibrarianRequest): LibrarianResponse {
|
||||
const documentId = request.documentId;
|
||||
const documentId = this.documentId(request);
|
||||
const records: ProcessingMetadata[] = [];
|
||||
|
||||
for (const proc of this.processing.values()) {
|
||||
if (documentId !== undefined && documentId.length > 0 && proc.documentId !== documentId) {
|
||||
const procDocumentId = proc.documentId ?? proc["document-id"];
|
||||
if (documentId !== undefined && documentId.length > 0 && procDocumentId !== documentId) {
|
||||
continue;
|
||||
}
|
||||
records.push(proc);
|
||||
}
|
||||
|
||||
return { processing: records };
|
||||
return this.processingResponse(records);
|
||||
}
|
||||
|
||||
private beginUpload(request: LibrarianRequest): LibrarianResponse {
|
||||
const meta = this.documentMetadata(request);
|
||||
if (meta === undefined) throw new Error("begin-upload requires documentMetadata");
|
||||
const req = this.requestRecord(request);
|
||||
const totalSize = typeof req["total-size"] === "number" ? req["total-size"] : 0;
|
||||
if (totalSize <= 0) throw new Error("begin-upload requires total-size");
|
||||
const chunkSize = typeof req["chunk-size"] === "number" && req["chunk-size"] > 0
|
||||
? req["chunk-size"]
|
||||
: 3 * 1024 * 1024;
|
||||
const totalChunks = Math.max(1, Math.ceil(totalSize / chunkSize));
|
||||
const uploadId = crypto.randomUUID();
|
||||
|
||||
this.uploads.set(uploadId, {
|
||||
id: uploadId,
|
||||
documentMetadata: meta,
|
||||
totalSize,
|
||||
chunkSize,
|
||||
totalChunks,
|
||||
createdAt: new Date().toISOString(),
|
||||
chunks: new Map<number, string>(),
|
||||
user: meta.user ?? optionalString(req.user) ?? "default",
|
||||
});
|
||||
|
||||
return {
|
||||
"upload-id": uploadId,
|
||||
"chunk-size": chunkSize,
|
||||
"total-chunks": totalChunks,
|
||||
} as LibrarianResponse;
|
||||
}
|
||||
|
||||
private uploadChunk(request: LibrarianRequest): LibrarianResponse {
|
||||
const req = this.requestRecord(request);
|
||||
const uploadId = optionalString(req["upload-id"]);
|
||||
if (uploadId === undefined) throw new Error("upload-chunk requires upload-id");
|
||||
const session = this.uploads.get(uploadId);
|
||||
if (session === undefined) throw new Error(`Upload not found: ${uploadId}`);
|
||||
const chunkIndex = typeof req["chunk-index"] === "number" ? req["chunk-index"] : -1;
|
||||
if (!Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex >= session.totalChunks) {
|
||||
throw new Error("upload-chunk requires a valid chunk-index");
|
||||
}
|
||||
const content = optionalString(req.content);
|
||||
if (content === undefined) throw new Error("upload-chunk requires content");
|
||||
session.chunks.set(chunkIndex, content);
|
||||
|
||||
const bytesReceived = [...session.chunks.values()].reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
return {
|
||||
"upload-id": uploadId,
|
||||
"chunk-index": chunkIndex,
|
||||
"chunks-received": session.chunks.size,
|
||||
"total-chunks": session.totalChunks,
|
||||
"bytes-received": bytesReceived,
|
||||
"total-bytes": session.totalSize,
|
||||
} as LibrarianResponse;
|
||||
}
|
||||
|
||||
private async completeUpload(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const uploadId = optionalString(this.requestRecord(request)["upload-id"]);
|
||||
if (uploadId === undefined) throw new Error("complete-upload requires upload-id");
|
||||
const session = this.uploads.get(uploadId);
|
||||
if (session === undefined) throw new Error(`Upload not found: ${uploadId}`);
|
||||
if (session.chunks.size !== session.totalChunks) {
|
||||
throw new Error(`Upload incomplete: ${session.chunks.size}/${session.totalChunks} chunks received`);
|
||||
}
|
||||
|
||||
const content = Array.from({ length: session.totalChunks }, (_, i) => session.chunks.get(i) ?? "").join("");
|
||||
const response = await this.addDocument({
|
||||
operation: "add-document",
|
||||
documentMetadata: session.documentMetadata,
|
||||
"document-metadata": session.documentMetadata,
|
||||
content,
|
||||
user: session.user,
|
||||
} as LibrarianRequest);
|
||||
this.uploads.delete(uploadId);
|
||||
const documentId = response.documentMetadata?.id ?? response["document-metadata"]?.id ?? session.documentMetadata.id;
|
||||
return {
|
||||
...response,
|
||||
"document-id": documentId,
|
||||
"object-id": documentId,
|
||||
} as LibrarianResponse;
|
||||
}
|
||||
|
||||
private getUploadStatus(request: LibrarianRequest): LibrarianResponse {
|
||||
const uploadId = optionalString(this.requestRecord(request)["upload-id"]);
|
||||
if (uploadId === undefined) throw new Error("get-upload-status requires upload-id");
|
||||
const session = this.uploads.get(uploadId);
|
||||
if (session === undefined) throw new Error(`Upload not found: ${uploadId}`);
|
||||
const receivedChunks = [...session.chunks.keys()].sort((a, b) => a - b);
|
||||
const receivedSet = new Set(receivedChunks);
|
||||
const missingChunks = Array.from({ length: session.totalChunks }, (_, i) => i).filter((i) => !receivedSet.has(i));
|
||||
const bytesReceived = [...session.chunks.values()].reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
return {
|
||||
"upload-id": uploadId,
|
||||
"upload-state": "in-progress",
|
||||
"chunks-received": session.chunks.size,
|
||||
"total-chunks": session.totalChunks,
|
||||
"received-chunks": receivedChunks,
|
||||
"missing-chunks": missingChunks,
|
||||
"bytes-received": bytesReceived,
|
||||
"total-bytes": session.totalSize,
|
||||
} as LibrarianResponse;
|
||||
}
|
||||
|
||||
private abortUpload(request: LibrarianRequest): LibrarianResponse {
|
||||
const uploadId = optionalString(this.requestRecord(request)["upload-id"]);
|
||||
if (uploadId === undefined) throw new Error("abort-upload requires upload-id");
|
||||
this.uploads.delete(uploadId);
|
||||
return {};
|
||||
}
|
||||
|
||||
private listUploads(request: LibrarianRequest): LibrarianResponse {
|
||||
const user = optionalString(this.requestRecord(request).user);
|
||||
const sessions = [...this.uploads.values()]
|
||||
.filter((session) => user === undefined || session.user === user)
|
||||
.map((session) => ({
|
||||
"upload-id": session.id,
|
||||
"document-id": session.documentMetadata.id,
|
||||
"document-metadata-json": JSON.stringify(this.publicDocument(session.documentMetadata)),
|
||||
"total-size": session.totalSize,
|
||||
"chunk-size": session.chunkSize,
|
||||
"total-chunks": session.totalChunks,
|
||||
"chunks-received": session.chunks.size,
|
||||
"created-at": session.createdAt,
|
||||
}));
|
||||
return { "upload-sessions": sessions } as LibrarianResponse;
|
||||
}
|
||||
|
||||
private async streamDocument(request: LibrarianRequest): Promise<LibrarianResponse[]> {
|
||||
const id = this.documentId(request);
|
||||
if (id === undefined) throw new Error("stream-document requires documentId");
|
||||
const req = this.requestRecord(request);
|
||||
const chunkSize = typeof req["chunk-size"] === "number" && req["chunk-size"] > 0
|
||||
? req["chunk-size"]
|
||||
: 1024 * 1024;
|
||||
const filePath = joinPath(this.dataDir, "docs", `${id}.bin`);
|
||||
const buf = await readBinaryFile(filePath);
|
||||
const base64 = Buffer.from(buf).toString("base64");
|
||||
const totalChunks = Math.max(1, Math.ceil(base64.length / chunkSize));
|
||||
return Array.from({ length: totalChunks }, (_, index) => {
|
||||
const start = index * chunkSize;
|
||||
const content = base64.slice(start, start + chunkSize);
|
||||
return {
|
||||
content,
|
||||
"chunk-index": index,
|
||||
"total-chunks": totalChunks,
|
||||
eos: index === totalChunks - 1,
|
||||
} as LibrarianResponse;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Collection management ----------
|
||||
|
|
@ -471,14 +787,14 @@ export class LibrarianService extends AsyncProcessor {
|
|||
this.documents.clear();
|
||||
if (parsed.documents !== undefined) {
|
||||
for (const [id, doc] of Object.entries(parsed.documents)) {
|
||||
this.documents.set(id, doc);
|
||||
this.documents.set(id, this.publicDocument(doc));
|
||||
}
|
||||
}
|
||||
|
||||
this.processing.clear();
|
||||
if (parsed.processing !== undefined) {
|
||||
for (const [id, proc] of Object.entries(parsed.processing)) {
|
||||
this.processing.set(id, proc);
|
||||
this.processing.set(id, this.publicProcessing(proc));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -525,5 +841,5 @@ export const program = makeProcessorProgram({
|
|||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await LibrarianService.launch("librarian-svc");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,13 +10,17 @@
|
|||
|
||||
import { AzureOpenAI } from "openai";
|
||||
import {
|
||||
Llm,
|
||||
LlmService,
|
||||
makeFlowProcessorProgram,
|
||||
makeLlmServiceShape,
|
||||
makeLlmSpecs,
|
||||
type ProcessorConfig,
|
||||
type LlmResult,
|
||||
type LlmChunk,
|
||||
tooManyRequestsError,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect, Layer } from "effect";
|
||||
|
||||
export class AzureOpenAIProcessor extends LlmService {
|
||||
private client: AzureOpenAI;
|
||||
|
|
@ -157,11 +161,16 @@ export class AzureOpenAIProcessor extends LlmService {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
|
||||
id: "text-completion",
|
||||
make: (config) => new AzureOpenAIProcessor(config),
|
||||
specs: () => makeLlmSpecs(),
|
||||
layer: (config) =>
|
||||
Layer.succeed(
|
||||
Llm,
|
||||
Llm.of(makeLlmServiceShape(new AzureOpenAIProcessor(config))),
|
||||
),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await AzureOpenAIProcessor.launch("text-completion");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,18 @@
|
|||
*/
|
||||
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk, tooManyRequestsError } from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import {
|
||||
Llm,
|
||||
LlmService,
|
||||
makeFlowProcessorProgram,
|
||||
makeLlmServiceShape,
|
||||
makeLlmSpecs,
|
||||
type ProcessorConfig,
|
||||
type LlmResult,
|
||||
type LlmChunk,
|
||||
tooManyRequestsError,
|
||||
} from "@trustgraph/base";
|
||||
import { Effect, Layer } from "effect";
|
||||
|
||||
export class ClaudeProcessor extends LlmService {
|
||||
private client: Anthropic;
|
||||
|
|
@ -127,11 +137,16 @@ export class ClaudeProcessor extends LlmService {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
|
||||
id: "text-completion",
|
||||
make: (config) => new ClaudeProcessor(config),
|
||||
specs: () => makeLlmSpecs(),
|
||||
layer: (config) =>
|
||||
Layer.succeed(
|
||||
Llm,
|
||||
Llm.of(makeLlmServiceShape(new ClaudeProcessor(config))),
|
||||
),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await ClaudeProcessor.launch("text-completion");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,17 @@
|
|||
|
||||
import { Mistral } from "@mistralai/mistralai";
|
||||
import {
|
||||
Llm,
|
||||
LlmService,
|
||||
makeFlowProcessorProgram,
|
||||
makeLlmServiceShape,
|
||||
makeLlmSpecs,
|
||||
type ProcessorConfig,
|
||||
type LlmResult,
|
||||
type LlmChunk,
|
||||
tooManyRequestsError,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect, Layer } from "effect";
|
||||
|
||||
export class MistralProcessor extends LlmService {
|
||||
private client: Mistral;
|
||||
|
|
@ -143,11 +147,16 @@ export class MistralProcessor extends LlmService {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
|
||||
id: "text-completion",
|
||||
make: (config) => new MistralProcessor(config),
|
||||
specs: () => makeLlmSpecs(),
|
||||
layer: (config) =>
|
||||
Layer.succeed(
|
||||
Llm,
|
||||
Llm.of(makeLlmServiceShape(new MistralProcessor(config))),
|
||||
),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await MistralProcessor.launch("text-completion");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,17 @@
|
|||
*/
|
||||
|
||||
import { Ollama } from "ollama";
|
||||
import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk } from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import {
|
||||
Llm,
|
||||
LlmService,
|
||||
makeFlowProcessorProgram,
|
||||
makeLlmServiceShape,
|
||||
makeLlmSpecs,
|
||||
type ProcessorConfig,
|
||||
type LlmResult,
|
||||
type LlmChunk,
|
||||
} from "@trustgraph/base";
|
||||
import { Effect, Layer } from "effect";
|
||||
|
||||
export class OllamaProcessor extends LlmService {
|
||||
private client: Ollama;
|
||||
|
|
@ -113,11 +122,16 @@ export class OllamaProcessor extends LlmService {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
|
||||
id: "text-completion",
|
||||
make: (config) => new OllamaProcessor(config),
|
||||
specs: () => makeLlmSpecs(),
|
||||
layer: (config) =>
|
||||
Layer.succeed(
|
||||
Llm,
|
||||
Llm.of(makeLlmServiceShape(new OllamaProcessor(config))),
|
||||
),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await OllamaProcessor.launch("text-completion");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,12 +11,16 @@
|
|||
|
||||
import OpenAI from "openai";
|
||||
import {
|
||||
Llm,
|
||||
LlmService,
|
||||
makeFlowProcessorProgram,
|
||||
makeLlmServiceShape,
|
||||
makeLlmSpecs,
|
||||
type ProcessorConfig,
|
||||
type LlmResult,
|
||||
type LlmChunk,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect, Layer } from "effect";
|
||||
|
||||
export class OpenAICompatibleProcessor extends LlmService {
|
||||
private client: OpenAI;
|
||||
|
|
@ -137,11 +141,16 @@ export class OpenAICompatibleProcessor extends LlmService {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
|
||||
id: "text-completion",
|
||||
make: (config) => new OpenAICompatibleProcessor(config),
|
||||
specs: () => makeLlmSpecs(),
|
||||
layer: (config) =>
|
||||
Layer.succeed(
|
||||
Llm,
|
||||
Llm.of(makeLlmServiceShape(new OpenAICompatibleProcessor(config))),
|
||||
),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await OpenAICompatibleProcessor.launch("text-completion");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,18 @@
|
|||
*/
|
||||
|
||||
import OpenAI from "openai";
|
||||
import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk, tooManyRequestsError } from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import {
|
||||
Llm,
|
||||
LlmService,
|
||||
makeFlowProcessorProgram,
|
||||
makeLlmServiceShape,
|
||||
makeLlmSpecs,
|
||||
type ProcessorConfig,
|
||||
type LlmResult,
|
||||
type LlmChunk,
|
||||
tooManyRequestsError,
|
||||
} from "@trustgraph/base";
|
||||
import { Effect, Layer } from "effect";
|
||||
|
||||
export class OpenAIProcessor extends LlmService {
|
||||
private client: OpenAI;
|
||||
|
|
@ -137,11 +147,16 @@ export class OpenAIProcessor extends LlmService {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
|
||||
id: "text-completion",
|
||||
make: (config) => new OpenAIProcessor(config),
|
||||
specs: () => makeLlmSpecs(),
|
||||
layer: (config) =>
|
||||
Layer.succeed(
|
||||
Llm,
|
||||
Llm.of(makeLlmServiceShape(new OpenAIProcessor(config))),
|
||||
),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await OpenAIProcessor.launch("text-completion");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,11 +29,17 @@ import {
|
|||
ConsumerSpec,
|
||||
ProducerSpec,
|
||||
type ProcessorConfig,
|
||||
type EffectConfigHandler,
|
||||
type FlowContext,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface PromptTemplate {
|
||||
system: string;
|
||||
|
|
@ -44,94 +50,129 @@ export interface PromptTemplateConfig extends ProcessorConfig {
|
|||
configKey?: string;
|
||||
}
|
||||
|
||||
const PromptTemplateEntry = S.Struct({
|
||||
system: S.optionalKey(S.String),
|
||||
prompt: S.optionalKey(S.String),
|
||||
});
|
||||
|
||||
const PromptTemplateEntries = S.Record(S.String, PromptTemplateEntry);
|
||||
|
||||
interface PromptTemplateRuntime {
|
||||
readonly specs: ReadonlyArray<Spec<never>>;
|
||||
readonly configHandlers: ReadonlyArray<EffectConfigHandler>;
|
||||
}
|
||||
|
||||
const programRuntimes = new WeakMap<PromptTemplateConfig, PromptTemplateRuntime>();
|
||||
|
||||
const makePromptTemplateRuntime = (config: PromptTemplateConfig): PromptTemplateRuntime => {
|
||||
const templates = new Map<string, PromptTemplate>();
|
||||
const configKey = config.configKey ?? "prompt";
|
||||
|
||||
const onPromptConfig = Effect.fn("PromptTemplateService.onConfig")(function* (
|
||||
pushedConfig: Record<string, unknown>,
|
||||
version: number,
|
||||
) {
|
||||
yield* Effect.log(`[PromptTemplate] Loading prompt configuration version ${version}`);
|
||||
|
||||
const promptConfig = pushedConfig[configKey];
|
||||
if (promptConfig === undefined) {
|
||||
yield* Effect.logWarning(`[PromptTemplate] No key "${configKey}" in config`);
|
||||
return;
|
||||
}
|
||||
|
||||
const decoded = yield* S.decodeUnknownEffect(PromptTemplateEntries)(promptConfig).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[PromptTemplate] Failed to decode prompt configuration", {
|
||||
error: error.message,
|
||||
configKey,
|
||||
}).pipe(Effect.as(null)),
|
||||
),
|
||||
);
|
||||
if (decoded === null) return;
|
||||
|
||||
templates.clear();
|
||||
|
||||
for (const [name, template] of Object.entries(decoded)) {
|
||||
templates.set(name, {
|
||||
system: template.system ?? "",
|
||||
prompt: template.prompt ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
yield* Effect.log(
|
||||
`[PromptTemplate] Loaded ${templates.size} template(s): ${[...templates.keys()].join(", ")}`,
|
||||
);
|
||||
});
|
||||
|
||||
const onRequest = Effect.fn("PromptTemplateService.onRequest")(function* (
|
||||
msg: PromptRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
) {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = yield* flowCtx.flow.producerEffect<PromptResponse>("prompt-response");
|
||||
const template = templates.get(msg.name);
|
||||
if (template === undefined) {
|
||||
yield* responseProducer.send(requestId, {
|
||||
system: "",
|
||||
prompt: "",
|
||||
error: {
|
||||
type: "prompt-error",
|
||||
message: `Unknown prompt template: "${msg.name}"`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const variables = msg.variables ?? {};
|
||||
|
||||
yield* responseProducer.send(requestId, {
|
||||
system: renderTemplate(template.system, variables),
|
||||
prompt: renderTemplate(template.prompt, variables),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
specs: [
|
||||
new ConsumerSpec<PromptRequest, FlowResourceNotFoundError | MessagingDeliveryError>(
|
||||
"prompt-request",
|
||||
onRequest,
|
||||
),
|
||||
new ProducerSpec<PromptResponse>("prompt-response"),
|
||||
],
|
||||
configHandlers: [onPromptConfig],
|
||||
};
|
||||
};
|
||||
|
||||
const promptTemplateRuntime = (config: PromptTemplateConfig): PromptTemplateRuntime => {
|
||||
const existing = programRuntimes.get(config);
|
||||
if (existing !== undefined) return existing;
|
||||
const runtime = makePromptTemplateRuntime(config);
|
||||
programRuntimes.set(config, runtime);
|
||||
return runtime;
|
||||
};
|
||||
|
||||
export class PromptTemplateService extends FlowProcessor {
|
||||
private templates = new Map<string, PromptTemplate>();
|
||||
private readonly configKey: string;
|
||||
private readonly runtime: PromptTemplateRuntime;
|
||||
|
||||
constructor(config: PromptTemplateConfig) {
|
||||
super(config);
|
||||
|
||||
this.configKey = config.configKey ?? "prompt";
|
||||
this.runtime = makePromptTemplateRuntime(config);
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<PromptRequest>(
|
||||
"prompt-request",
|
||||
this.onRequest.bind(this),
|
||||
),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<PromptResponse>("prompt-response"));
|
||||
|
||||
this.registerConfigHandler(this.onPromptConfig.bind(this));
|
||||
for (const spec of this.runtime.specs) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
for (const handler of this.runtime.configHandlers) {
|
||||
this.registerConfigHandler((pushedConfig, version) =>
|
||||
Effect.runPromise(handler(pushedConfig, version)),
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[PromptTemplate] Service initialized");
|
||||
}
|
||||
|
||||
private async onPromptConfig(
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
): Promise<void> {
|
||||
console.log(`[PromptTemplate] Loading prompt configuration version ${version}`);
|
||||
|
||||
const promptConfig = config[this.configKey] as
|
||||
| Record<string, { system?: string; prompt?: string }>
|
||||
| undefined;
|
||||
|
||||
if (promptConfig === undefined) {
|
||||
console.warn(`[PromptTemplate] No key "${this.configKey}" in config`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.templates.clear();
|
||||
|
||||
for (const [name, template] of Object.entries(promptConfig)) {
|
||||
this.templates.set(name, {
|
||||
system: template.system ?? "",
|
||||
prompt: template.prompt ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[PromptTemplate] Loaded ${this.templates.size} template(s): ${[...this.templates.keys()].join(", ")}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[PromptTemplate] Failed to load prompt configuration:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async onRequest(
|
||||
msg: PromptRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = flowCtx.flow.producer<PromptResponse>("prompt-response");
|
||||
|
||||
try {
|
||||
const template = this.templates.get(msg.name);
|
||||
if (template === undefined) {
|
||||
throw new Error(`Unknown prompt template: "${msg.name}"`);
|
||||
}
|
||||
|
||||
const variables = msg.variables ?? {};
|
||||
|
||||
const system = renderTemplate(template.system, variables);
|
||||
const prompt = renderTemplate(template.prompt, variables);
|
||||
|
||||
await responseProducer.send(requestId, { system, prompt });
|
||||
} catch (err) {
|
||||
console.error(`[PromptTemplate] Error processing request:`, err);
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await responseProducer.send(requestId, {
|
||||
system: "",
|
||||
prompt: "",
|
||||
error: { type: "prompt-error", message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -150,11 +191,12 @@ function renderTemplate(
|
|||
});
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram({
|
||||
id: "prompt",
|
||||
make: (config) => new PromptTemplateService(config),
|
||||
specs: (config: PromptTemplateConfig) => promptTemplateRuntime(config).specs,
|
||||
configHandlers: (config: PromptTemplateConfig) => promptTemplateRuntime(config).configHandlers,
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await PromptTemplateService.launch("prompt");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,79 +13,108 @@ import {
|
|||
ProducerSpec,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type DocumentEmbeddingsRequest,
|
||||
type DocumentEmbeddingsResponse,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { QdrantDocEmbeddingsQuery } from "./qdrant-doc.js";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
QdrantDocEmbeddingsQueryLive,
|
||||
QdrantDocEmbeddingsQueryService,
|
||||
makeQdrantDocEmbeddingsQueryService,
|
||||
type QdrantDocQueryConfig,
|
||||
} from "./qdrant-doc.js";
|
||||
|
||||
export class DocEmbeddingsQueryService extends FlowProcessor {
|
||||
private query: QdrantDocEmbeddingsQuery;
|
||||
const onDocEmbeddingsQueryMessage = Effect.fn("DocEmbeddingsQueryService.onMessage")(function* (
|
||||
msg: DocumentEmbeddingsRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<QdrantDocEmbeddingsQueryService>,
|
||||
) {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = yield* flowCtx.flow.producerEffect<DocumentEmbeddingsResponse>("document-embeddings-response");
|
||||
const query = yield* QdrantDocEmbeddingsQueryService;
|
||||
const collection = msg.collection ?? "default";
|
||||
const allChunks: DocumentEmbeddingsResponse["chunks"] = [];
|
||||
|
||||
for (const vector of msg.vectors ?? []) {
|
||||
const matches = yield* query.query({
|
||||
vector,
|
||||
user: msg.user ?? "default",
|
||||
collection,
|
||||
limit: msg.limit ?? 10,
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[DocEmbeddingsQuery] Query failed", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
producer.send(requestId, {
|
||||
chunks: [],
|
||||
error: { type: "query-error", message: error.message },
|
||||
})
|
||||
),
|
||||
Effect.as(null),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (matches === null) return;
|
||||
|
||||
for (const match of matches) {
|
||||
allChunks.push({
|
||||
chunkId: match.chunkId,
|
||||
score: match.score,
|
||||
...(match.content !== undefined ? { content: match.content } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
yield* producer.send(requestId, { chunks: allChunks });
|
||||
});
|
||||
|
||||
export const makeDocEmbeddingsQuerySpecs = (): ReadonlyArray<Spec<QdrantDocEmbeddingsQueryService>> => [
|
||||
new ConsumerSpec<
|
||||
DocumentEmbeddingsRequest,
|
||||
FlowResourceNotFoundError | MessagingDeliveryError,
|
||||
QdrantDocEmbeddingsQueryService
|
||||
>("document-embeddings-request", onDocEmbeddingsQueryMessage),
|
||||
new ProducerSpec<DocumentEmbeddingsResponse>("document-embeddings-response"),
|
||||
];
|
||||
|
||||
export class DocEmbeddingsQueryService extends FlowProcessor<QdrantDocEmbeddingsQueryService> {
|
||||
private readonly query = makeQdrantDocEmbeddingsQueryService();
|
||||
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
this.query = new QdrantDocEmbeddingsQuery();
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<DocumentEmbeddingsRequest>(
|
||||
"document-embeddings-request",
|
||||
this.onMessage.bind(this),
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new ProducerSpec<DocumentEmbeddingsResponse>("document-embeddings-response"),
|
||||
);
|
||||
for (const spec of makeDocEmbeddingsQuerySpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
console.log("[DocEmbeddingsQuery] Service initialized");
|
||||
}
|
||||
|
||||
private async onMessage(
|
||||
msg: DocumentEmbeddingsRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = flowCtx.flow.producer<DocumentEmbeddingsResponse>("document-embeddings-response");
|
||||
const collection = msg.collection ?? "default";
|
||||
|
||||
try {
|
||||
const allChunks: DocumentEmbeddingsResponse["chunks"] = [];
|
||||
|
||||
for (const vector of msg.vectors ?? []) {
|
||||
const matches = await this.query.query({
|
||||
vector,
|
||||
user: msg.user ?? "default",
|
||||
collection,
|
||||
limit: msg.limit ?? 10,
|
||||
});
|
||||
|
||||
for (const match of matches) {
|
||||
allChunks.push({
|
||||
chunkId: match.chunkId,
|
||||
score: match.score,
|
||||
...(match.content !== undefined ? { content: match.content } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await producer.send(requestId, { chunks: allChunks });
|
||||
} catch (err) {
|
||||
console.error("[DocEmbeddingsQuery] Query failed:", err);
|
||||
await producer.send(requestId, {
|
||||
chunks: [],
|
||||
error: { type: "query-error", message: String(err) },
|
||||
});
|
||||
}
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(
|
||||
QdrantDocEmbeddingsQueryService,
|
||||
QdrantDocEmbeddingsQueryService.of(this.query),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig & QdrantDocQueryConfig, never, QdrantDocEmbeddingsQueryService>({
|
||||
id: "doc-embeddings-query",
|
||||
make: (config) => new DocEmbeddingsQueryService(config),
|
||||
specs: () => makeDocEmbeddingsQuerySpecs(),
|
||||
layer: (config) => QdrantDocEmbeddingsQueryLive(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await DocEmbeddingsQueryService.launch("doc-embeddings-query");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
*/
|
||||
|
||||
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||
import { errorMessage } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface QdrantDocQueryConfig {
|
||||
url?: string;
|
||||
|
|
@ -83,3 +86,54 @@ export class QdrantDocEmbeddingsQuery {
|
|||
return chunks;
|
||||
}
|
||||
}
|
||||
|
||||
export class QdrantDocEmbeddingsQueryError extends S.TaggedErrorClass<QdrantDocEmbeddingsQueryError>()(
|
||||
"QdrantDocEmbeddingsQueryError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
cause: S.DefectWithStack,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface QdrantDocEmbeddingsQueryServiceShape {
|
||||
readonly query: (
|
||||
request: DocEmbeddingsQueryRequest,
|
||||
) => Effect.Effect<ReadonlyArray<ChunkMatch>, QdrantDocEmbeddingsQueryError>;
|
||||
}
|
||||
|
||||
export class QdrantDocEmbeddingsQueryService extends Context.Service<
|
||||
QdrantDocEmbeddingsQueryService,
|
||||
QdrantDocEmbeddingsQueryServiceShape
|
||||
>()(
|
||||
"@trustgraph/flow/query/embeddings/qdrant-doc/QdrantDocEmbeddingsQueryService",
|
||||
) {}
|
||||
|
||||
const qdrantDocEmbeddingsQueryError = (operation: string, cause: unknown) =>
|
||||
new QdrantDocEmbeddingsQueryError({
|
||||
operation,
|
||||
message: errorMessage(cause),
|
||||
cause,
|
||||
});
|
||||
|
||||
export const makeQdrantDocEmbeddingsQueryService = (
|
||||
config: QdrantDocQueryConfig = {},
|
||||
): QdrantDocEmbeddingsQueryServiceShape => {
|
||||
const query = new QdrantDocEmbeddingsQuery(config);
|
||||
return {
|
||||
query: Effect.fn("QdrantDocEmbeddingsQuery.query")(function* (request) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => query.query(request),
|
||||
catch: (cause) => qdrantDocEmbeddingsQueryError("query", cause),
|
||||
});
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const QdrantDocEmbeddingsQueryLive = (
|
||||
config: QdrantDocQueryConfig = {},
|
||||
): Layer.Layer<QdrantDocEmbeddingsQueryService> =>
|
||||
Layer.succeed(
|
||||
QdrantDocEmbeddingsQueryService,
|
||||
QdrantDocEmbeddingsQueryService.of(makeQdrantDocEmbeddingsQueryService(config)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,78 +13,109 @@ import {
|
|||
ProducerSpec,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type GraphEmbeddingsRequest,
|
||||
type GraphEmbeddingsResponse,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { QdrantGraphEmbeddingsQuery } from "./qdrant-graph.js";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
QdrantGraphEmbeddingsQueryLive,
|
||||
QdrantGraphEmbeddingsQueryService,
|
||||
makeQdrantGraphEmbeddingsQueryService,
|
||||
type QdrantGraphQueryConfig,
|
||||
} from "./qdrant-graph.js";
|
||||
|
||||
export class GraphEmbeddingsQueryService extends FlowProcessor {
|
||||
private query: QdrantGraphEmbeddingsQuery;
|
||||
const onGraphEmbeddingsQueryMessage = Effect.fn("GraphEmbeddingsQueryService.onMessage")(function* (
|
||||
msg: GraphEmbeddingsRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<QdrantGraphEmbeddingsQueryService>,
|
||||
) {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = yield* flowCtx.flow.producerEffect<GraphEmbeddingsResponse>("graph-embeddings-response");
|
||||
const query = yield* QdrantGraphEmbeddingsQueryService;
|
||||
const user = msg.user ?? "default";
|
||||
const collection = msg.collection ?? "default";
|
||||
yield* Effect.log(
|
||||
`[GraphEmbeddingsQuery] Request: user=${user}, collection=${collection}, vectors=${msg.vectors?.length ?? 0}, limit=${msg.limit}`,
|
||||
);
|
||||
|
||||
const allEntities: GraphEmbeddingsResponse["entities"] = [];
|
||||
|
||||
for (const vector of msg.vectors ?? []) {
|
||||
const matches = yield* query.query({
|
||||
vector,
|
||||
user,
|
||||
collection,
|
||||
limit: msg.limit ?? 50,
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[GraphEmbeddingsQuery] Query failed", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
producer.send(requestId, {
|
||||
entities: [],
|
||||
error: { type: "query-error", message: error.message },
|
||||
})
|
||||
),
|
||||
Effect.as(null),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (matches === null) return;
|
||||
|
||||
for (const match of matches) {
|
||||
allEntities.push(match.entity);
|
||||
}
|
||||
}
|
||||
|
||||
yield* producer.send(requestId, { entities: allEntities });
|
||||
});
|
||||
|
||||
export const makeGraphEmbeddingsQuerySpecs = (): ReadonlyArray<Spec<QdrantGraphEmbeddingsQueryService>> => [
|
||||
new ConsumerSpec<
|
||||
GraphEmbeddingsRequest,
|
||||
FlowResourceNotFoundError | MessagingDeliveryError,
|
||||
QdrantGraphEmbeddingsQueryService
|
||||
>("graph-embeddings-request", onGraphEmbeddingsQueryMessage),
|
||||
new ProducerSpec<GraphEmbeddingsResponse>("graph-embeddings-response"),
|
||||
];
|
||||
|
||||
export class GraphEmbeddingsQueryService extends FlowProcessor<QdrantGraphEmbeddingsQueryService> {
|
||||
private readonly query = makeQdrantGraphEmbeddingsQueryService();
|
||||
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
this.query = new QdrantGraphEmbeddingsQuery();
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<GraphEmbeddingsRequest>(
|
||||
"graph-embeddings-request",
|
||||
this.onMessage.bind(this),
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new ProducerSpec<GraphEmbeddingsResponse>("graph-embeddings-response"),
|
||||
);
|
||||
for (const spec of makeGraphEmbeddingsQuerySpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
console.log("[GraphEmbeddingsQuery] Service initialized");
|
||||
}
|
||||
|
||||
private async onMessage(
|
||||
msg: GraphEmbeddingsRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = flowCtx.flow.producer<GraphEmbeddingsResponse>("graph-embeddings-response");
|
||||
const user = msg.user ?? "default";
|
||||
const collection = msg.collection ?? "default";
|
||||
console.log(`[GraphEmbeddingsQuery] Request: user=${user}, collection=${collection}, vectors=${msg.vectors?.length ?? 0}, limit=${msg.limit}`);
|
||||
|
||||
try {
|
||||
// Query for each vector and aggregate results
|
||||
const allEntities: GraphEmbeddingsResponse["entities"] = [];
|
||||
|
||||
for (const vector of msg.vectors ?? []) {
|
||||
const matches = await this.query.query({
|
||||
vector,
|
||||
user,
|
||||
collection,
|
||||
limit: msg.limit ?? 50,
|
||||
});
|
||||
|
||||
for (const match of matches) {
|
||||
allEntities.push(match.entity);
|
||||
}
|
||||
}
|
||||
|
||||
await producer.send(requestId, { entities: allEntities });
|
||||
} catch (err) {
|
||||
console.error("[GraphEmbeddingsQuery] Query failed:", err);
|
||||
await producer.send(requestId, {
|
||||
entities: [],
|
||||
error: { type: "query-error", message: String(err) },
|
||||
});
|
||||
}
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(
|
||||
QdrantGraphEmbeddingsQueryService,
|
||||
QdrantGraphEmbeddingsQueryService.of(this.query),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig & QdrantGraphQueryConfig, never, QdrantGraphEmbeddingsQueryService>({
|
||||
id: "graph-embeddings-query",
|
||||
make: (config) => new GraphEmbeddingsQueryService(config),
|
||||
specs: () => makeGraphEmbeddingsQuerySpecs(),
|
||||
layer: (config) => QdrantGraphEmbeddingsQueryLive(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await GraphEmbeddingsQueryService.launch("graph-embeddings-query");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@
|
|||
*/
|
||||
|
||||
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||
import type { Term } from "@trustgraph/base";
|
||||
import { errorMessage, type Term } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface QdrantGraphQueryConfig {
|
||||
url?: string;
|
||||
|
|
@ -104,3 +106,54 @@ export class QdrantGraphEmbeddingsQuery {
|
|||
return entities;
|
||||
}
|
||||
}
|
||||
|
||||
export class QdrantGraphEmbeddingsQueryError extends S.TaggedErrorClass<QdrantGraphEmbeddingsQueryError>()(
|
||||
"QdrantGraphEmbeddingsQueryError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
cause: S.DefectWithStack,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface QdrantGraphEmbeddingsQueryServiceShape {
|
||||
readonly query: (
|
||||
request: GraphEmbeddingsQueryRequest,
|
||||
) => Effect.Effect<ReadonlyArray<EntityMatch>, QdrantGraphEmbeddingsQueryError>;
|
||||
}
|
||||
|
||||
export class QdrantGraphEmbeddingsQueryService extends Context.Service<
|
||||
QdrantGraphEmbeddingsQueryService,
|
||||
QdrantGraphEmbeddingsQueryServiceShape
|
||||
>()(
|
||||
"@trustgraph/flow/query/embeddings/qdrant-graph/QdrantGraphEmbeddingsQueryService",
|
||||
) {}
|
||||
|
||||
const qdrantGraphEmbeddingsQueryError = (operation: string, cause: unknown) =>
|
||||
new QdrantGraphEmbeddingsQueryError({
|
||||
operation,
|
||||
message: errorMessage(cause),
|
||||
cause,
|
||||
});
|
||||
|
||||
export const makeQdrantGraphEmbeddingsQueryService = (
|
||||
config: QdrantGraphQueryConfig = {},
|
||||
): QdrantGraphEmbeddingsQueryServiceShape => {
|
||||
const query = new QdrantGraphEmbeddingsQuery(config);
|
||||
return {
|
||||
query: Effect.fn("QdrantGraphEmbeddingsQuery.query")(function* (request) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => query.query(request),
|
||||
catch: (cause) => qdrantGraphEmbeddingsQueryError("query", cause),
|
||||
});
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const QdrantGraphEmbeddingsQueryLive = (
|
||||
config: QdrantGraphQueryConfig = {},
|
||||
): Layer.Layer<QdrantGraphEmbeddingsQueryService> =>
|
||||
Layer.succeed(
|
||||
QdrantGraphEmbeddingsQueryService,
|
||||
QdrantGraphEmbeddingsQueryService.of(makeQdrantGraphEmbeddingsQueryService(config)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,61 +13,95 @@ import {
|
|||
ProducerSpec,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type TriplesQueryRequest,
|
||||
type TriplesQueryResponse,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { FalkorDBTriplesQuery } from "./falkordb.js";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
FalkorDBTriplesQueryLive,
|
||||
FalkorDBTriplesQueryService,
|
||||
makeFalkorDBTriplesQueryService,
|
||||
type FalkorDBQueryConfig,
|
||||
} from "./falkordb.js";
|
||||
|
||||
export class TriplesQueryService extends FlowProcessor {
|
||||
private query: FalkorDBTriplesQuery;
|
||||
const onTriplesQueryMessage = Effect.fn("TriplesQueryService.onMessage")(function* (
|
||||
msg: TriplesQueryRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<FalkorDBTriplesQueryService>,
|
||||
) {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = yield* flowCtx.flow.producerEffect<TriplesQueryResponse>("triples-response");
|
||||
const query = yield* FalkorDBTriplesQueryService;
|
||||
const triples = yield* query.queryTriples(
|
||||
msg.s,
|
||||
msg.p,
|
||||
msg.o,
|
||||
msg.limit ?? 100,
|
||||
).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[TriplesQuery] Query failed", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
producer.send(requestId, {
|
||||
triples: [],
|
||||
error: { type: "query-error", message: error.message },
|
||||
})
|
||||
),
|
||||
Effect.as(null),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (triples === null) return;
|
||||
|
||||
yield* producer.send(requestId, { triples: Array.from(triples) });
|
||||
});
|
||||
|
||||
export const makeTriplesQuerySpecs = (): ReadonlyArray<Spec<FalkorDBTriplesQueryService>> => [
|
||||
new ConsumerSpec<
|
||||
TriplesQueryRequest,
|
||||
FlowResourceNotFoundError | MessagingDeliveryError,
|
||||
FalkorDBTriplesQueryService
|
||||
>("triples-request", onTriplesQueryMessage),
|
||||
new ProducerSpec<TriplesQueryResponse>("triples-response"),
|
||||
];
|
||||
|
||||
export class TriplesQueryService extends FlowProcessor<FalkorDBTriplesQueryService> {
|
||||
private readonly query = makeFalkorDBTriplesQueryService();
|
||||
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
this.query = new FalkorDBTriplesQuery();
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<TriplesQueryRequest>("triples-request", this.onMessage.bind(this)),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<TriplesQueryResponse>("triples-response"));
|
||||
for (const spec of makeTriplesQuerySpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
console.log("[TriplesQuery] Service initialized");
|
||||
}
|
||||
|
||||
private async onMessage(
|
||||
msg: TriplesQueryRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = flowCtx.flow.producer<TriplesQueryResponse>("triples-response");
|
||||
|
||||
try {
|
||||
const triples = await this.query.queryTriples(
|
||||
msg.s,
|
||||
msg.p,
|
||||
msg.o,
|
||||
msg.limit ?? 100,
|
||||
);
|
||||
|
||||
await producer.send(requestId, { triples });
|
||||
} catch (err) {
|
||||
console.error("[TriplesQuery] Query failed:", err);
|
||||
await producer.send(requestId, {
|
||||
triples: [],
|
||||
error: { type: "query-error", message: String(err) },
|
||||
});
|
||||
}
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(
|
||||
FalkorDBTriplesQueryService,
|
||||
FalkorDBTriplesQueryService.of(this.query),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig & FalkorDBQueryConfig, never, FalkorDBTriplesQueryService>({
|
||||
id: "triples-query",
|
||||
make: (config) => new TriplesQueryService(config),
|
||||
specs: () => makeTriplesQuerySpecs(),
|
||||
layer: (config) => FalkorDBTriplesQueryLive(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await TriplesQueryService.launch("triples-query");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
*/
|
||||
|
||||
import { createClient, Graph } from "falkordb";
|
||||
import type { Term, Triple } from "@trustgraph/base";
|
||||
import { errorMessage, type Term, type Triple } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface FalkorDBQueryConfig {
|
||||
url?: string;
|
||||
|
|
@ -264,3 +266,61 @@ export class FalkorDBTriplesQuery {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FalkorDBTriplesQueryError extends S.TaggedErrorClass<FalkorDBTriplesQueryError>()(
|
||||
"FalkorDBTriplesQueryError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
cause: S.DefectWithStack,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface FalkorDBTriplesQueryServiceShape {
|
||||
readonly queryTriples: (
|
||||
s: Term | undefined,
|
||||
p: Term | undefined,
|
||||
o: Term | undefined,
|
||||
limit: number,
|
||||
) => Effect.Effect<ReadonlyArray<Triple>, FalkorDBTriplesQueryError>;
|
||||
}
|
||||
|
||||
export class FalkorDBTriplesQueryService extends Context.Service<
|
||||
FalkorDBTriplesQueryService,
|
||||
FalkorDBTriplesQueryServiceShape
|
||||
>()(
|
||||
"@trustgraph/flow/query/triples/falkordb/FalkorDBTriplesQueryService",
|
||||
) {}
|
||||
|
||||
const falkorDBTriplesQueryError = (operation: string, cause: unknown) =>
|
||||
new FalkorDBTriplesQueryError({
|
||||
operation,
|
||||
message: errorMessage(cause),
|
||||
cause,
|
||||
});
|
||||
|
||||
export const makeFalkorDBTriplesQueryService = (
|
||||
config: FalkorDBQueryConfig = {},
|
||||
): FalkorDBTriplesQueryServiceShape => {
|
||||
const query = new FalkorDBTriplesQuery(config);
|
||||
return {
|
||||
queryTriples: Effect.fn("FalkorDBTriplesQuery.queryTriples")((
|
||||
s: Term | undefined,
|
||||
p: Term | undefined,
|
||||
o: Term | undefined,
|
||||
limit: number,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => query.queryTriples(s, p, o, limit),
|
||||
catch: (cause) => falkorDBTriplesQueryError("query-triples", cause),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const FalkorDBTriplesQueryLive = (
|
||||
config: FalkorDBQueryConfig = {},
|
||||
): Layer.Layer<FalkorDBTriplesQueryService> =>
|
||||
Layer.succeed(
|
||||
FalkorDBTriplesQueryService,
|
||||
FalkorDBTriplesQueryService.of(makeFalkorDBTriplesQueryService(config)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,118 +1,166 @@
|
|||
/**
|
||||
* Document RAG service — FlowProcessor wrapper around the DocumentRag class.
|
||||
* Document RAG service.
|
||||
*
|
||||
* Consumes DocumentRagRequest messages, runs the document retrieval pipeline
|
||||
* (embed query → find similar chunks → synthesize answer), emits DocumentRagResponse.
|
||||
*
|
||||
* Each request gets its own DocumentRag instance for security isolation.
|
||||
* Consumes DocumentRagRequest messages, runs the document retrieval pipeline,
|
||||
* and emits DocumentRagResponse.
|
||||
*
|
||||
* Python reference: trustgraph-flow/trustgraph/retrieval/document_rag/
|
||||
*/
|
||||
|
||||
import {
|
||||
FlowProcessor,
|
||||
ConsumerSpec,
|
||||
FlowProcessor,
|
||||
ProducerSpec,
|
||||
RequestResponseSpec,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type DocumentRagRequest,
|
||||
type DocumentRagResponse,
|
||||
type TextCompletionRequest,
|
||||
type TextCompletionResponse,
|
||||
type EmbeddingsRequest,
|
||||
type EmbeddingsResponse,
|
||||
makeFlowProcessorProgram,
|
||||
type DocumentEmbeddingsRequest,
|
||||
type DocumentEmbeddingsResponse,
|
||||
type DocumentRagRequest,
|
||||
type DocumentRagResponse,
|
||||
type EffectRequestOptions,
|
||||
type EffectRequestResponse,
|
||||
type EmbeddingsRequest,
|
||||
type EmbeddingsResponse,
|
||||
type FlowContext,
|
||||
type FlowRequestOptions,
|
||||
type FlowRequestor,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type ProcessorConfig,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type Spec,
|
||||
type TextCompletionRequest,
|
||||
type TextCompletionResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { DocumentRag } from "./document-rag.js";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
DocumentRagEngine,
|
||||
DocumentRagEngineError,
|
||||
DocumentRagLive,
|
||||
makeDocumentRagEngine,
|
||||
type DocumentRagClients,
|
||||
} from "./document-rag.js";
|
||||
|
||||
export class DocumentRagService extends FlowProcessor {
|
||||
const toEffectRequestOptions = <TRes>(
|
||||
options: FlowRequestOptions<TRes> | undefined,
|
||||
): EffectRequestOptions<TRes> | undefined => {
|
||||
if (options === undefined) return undefined;
|
||||
return {
|
||||
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
||||
...(options.recipient === undefined
|
||||
? {}
|
||||
: {
|
||||
recipient: (response: TRes) =>
|
||||
Effect.promise(() => options.recipient?.(response) ?? Promise.resolve(true)),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const toPromiseRequestor = <TReq, TRes>(
|
||||
requestor: EffectRequestResponse<TReq, TRes>,
|
||||
): FlowRequestor<TReq, TRes> => ({
|
||||
request: (request, options) =>
|
||||
Effect.runPromise(requestor.request(request, toEffectRequestOptions(options))),
|
||||
stop: () => Effect.runPromise(requestor.stop),
|
||||
});
|
||||
|
||||
const onDocumentRagRequest = Effect.fn("DocumentRagService.onRequest")(function* (
|
||||
msg: DocumentRagRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<DocumentRagEngine>,
|
||||
) {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = yield* flowCtx.flow.producerEffect<DocumentRagResponse>("document-rag-response");
|
||||
const engine = yield* DocumentRagEngine;
|
||||
|
||||
const clients: DocumentRagClients = {
|
||||
llm: toPromiseRequestor(yield* flowCtx.flow.requestorEffect<TextCompletionRequest, TextCompletionResponse>("llm")),
|
||||
embeddings: toPromiseRequestor(yield* flowCtx.flow.requestorEffect<EmbeddingsRequest, EmbeddingsResponse>("embeddings")),
|
||||
docEmbeddings: toPromiseRequestor(
|
||||
yield* flowCtx.flow.requestorEffect<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>("doc-embeddings"),
|
||||
),
|
||||
prompt: toPromiseRequestor(yield* flowCtx.flow.requestorEffect<PromptRequest, PromptResponse>("prompt")),
|
||||
};
|
||||
|
||||
const response = yield* engine.query(
|
||||
clients,
|
||||
msg.query,
|
||||
{
|
||||
...(msg.collection !== undefined ? { collection: msg.collection } : {}),
|
||||
},
|
||||
).pipe(
|
||||
Effect.catch((error: DocumentRagEngineError) =>
|
||||
Effect.logError("[DocumentRag] Query failed", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
producer.send(requestId, {
|
||||
response: "",
|
||||
error: { type: "rag-error", message: error.message },
|
||||
}),
|
||||
),
|
||||
Effect.as(undefined),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (response === undefined) return;
|
||||
yield* producer.send(requestId, { response, endOfStream: true });
|
||||
});
|
||||
|
||||
export const makeDocumentRagSpecs = (): ReadonlyArray<Spec<DocumentRagEngine>> => [
|
||||
new ConsumerSpec<DocumentRagRequest, FlowResourceNotFoundError | MessagingDeliveryError, DocumentRagEngine>(
|
||||
"document-rag-request",
|
||||
onDocumentRagRequest,
|
||||
),
|
||||
new ProducerSpec<DocumentRagResponse>("document-rag-response"),
|
||||
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
|
||||
"llm",
|
||||
"text-completion-request",
|
||||
"text-completion-response",
|
||||
),
|
||||
new RequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
|
||||
"embeddings",
|
||||
"embeddings-request",
|
||||
"embeddings-response",
|
||||
),
|
||||
new RequestResponseSpec<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>(
|
||||
"doc-embeddings",
|
||||
"document-embeddings-request",
|
||||
"document-embeddings-response",
|
||||
),
|
||||
new RequestResponseSpec<PromptRequest, PromptResponse>(
|
||||
"prompt",
|
||||
"prompt-request",
|
||||
"prompt-response",
|
||||
),
|
||||
];
|
||||
|
||||
export class DocumentRagService extends FlowProcessor<DocumentRagEngine> {
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
// Consumer: document RAG requests
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<DocumentRagRequest>("document-rag-request", this.onRequest.bind(this)),
|
||||
);
|
||||
|
||||
// Producer: document RAG responses
|
||||
this.registerSpecification(new ProducerSpec<DocumentRagResponse>("document-rag-response"));
|
||||
|
||||
// Request-response clients
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
|
||||
"llm",
|
||||
"text-completion-request",
|
||||
"text-completion-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
|
||||
"embeddings",
|
||||
"embeddings-request",
|
||||
"embeddings-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>(
|
||||
"doc-embeddings",
|
||||
"document-embeddings-request",
|
||||
"document-embeddings-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<PromptRequest, PromptResponse>(
|
||||
"prompt",
|
||||
"prompt-request",
|
||||
"prompt-response",
|
||||
),
|
||||
);
|
||||
|
||||
console.log("[DocumentRag] Service initialized");
|
||||
for (const spec of makeDocumentRagSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
}
|
||||
|
||||
private async onRequest(
|
||||
msg: DocumentRagRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = flowCtx.flow.producer<DocumentRagResponse>("document-rag-response");
|
||||
|
||||
try {
|
||||
const documentRag = new DocumentRag({
|
||||
llm: flowCtx.flow.requestor<TextCompletionRequest, TextCompletionResponse>("llm"),
|
||||
embeddings: flowCtx.flow.requestor<EmbeddingsRequest, EmbeddingsResponse>("embeddings"),
|
||||
docEmbeddings: flowCtx.flow.requestor<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>("doc-embeddings"),
|
||||
prompt: flowCtx.flow.requestor<PromptRequest, PromptResponse>("prompt"),
|
||||
});
|
||||
|
||||
const response = await documentRag.query(msg.query, {
|
||||
...(msg.collection !== undefined ? { collection: msg.collection } : {}),
|
||||
});
|
||||
|
||||
await producer.send(requestId, { response, endOfStream: true });
|
||||
} catch (err) {
|
||||
console.error("[DocumentRag] Query failed:", err);
|
||||
await producer.send(requestId, {
|
||||
response: "",
|
||||
error: { type: "rag-error", message: String(err) },
|
||||
});
|
||||
}
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(DocumentRagEngine, DocumentRagEngine.of(makeDocumentRagEngine())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram({
|
||||
id: "document-rag",
|
||||
make: (config) => new DocumentRagService(config),
|
||||
specs: makeDocumentRagSpecs,
|
||||
layer: () => DocumentRagLive,
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await DocumentRagService.launch("document-rag");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
/**
|
||||
* Document RAG retrieval pipeline.
|
||||
*
|
||||
* Simpler than Graph RAG — embeds the query, finds similar document chunks,
|
||||
* and synthesizes an answer from the chunk content.
|
||||
*
|
||||
* Python reference: trustgraph-flow/trustgraph/retrieval/document_rag/
|
||||
*/
|
||||
|
||||
import type {
|
||||
FlowRequestor,
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse,
|
||||
EmbeddingsRequest,
|
||||
EmbeddingsResponse,
|
||||
DocumentEmbeddingsRequest,
|
||||
DocumentEmbeddingsResponse,
|
||||
EmbeddingsRequest,
|
||||
EmbeddingsResponse,
|
||||
FlowRequestor,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { errorMessage } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface DocumentRagClients {
|
||||
llm: FlowRequestor<TextCompletionRequest, TextCompletionResponse>;
|
||||
|
|
@ -28,55 +28,110 @@ export interface DocumentRagClients {
|
|||
|
||||
export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>;
|
||||
|
||||
export interface DocumentRagQueryOptions {
|
||||
readonly collection?: string;
|
||||
readonly streaming?: boolean;
|
||||
readonly chunkCallback?: ChunkCallback;
|
||||
}
|
||||
|
||||
export class DocumentRagEngineError extends S.TaggedErrorClass<DocumentRagEngineError>()(
|
||||
"DocumentRagEngineError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
cause: S.DefectWithStack,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface DocumentRagEngineShape {
|
||||
readonly query: (
|
||||
clients: DocumentRagClients,
|
||||
queryText: string,
|
||||
options?: DocumentRagQueryOptions,
|
||||
) => Effect.Effect<string, DocumentRagEngineError>;
|
||||
}
|
||||
|
||||
export class DocumentRagEngine extends Context.Service<DocumentRagEngine, DocumentRagEngineShape>()(
|
||||
"@trustgraph/flow/retrieval/document-rag/DocumentRagEngine",
|
||||
) {}
|
||||
|
||||
const documentRagError = (operation: string, cause: unknown) =>
|
||||
new DocumentRagEngineError({
|
||||
operation,
|
||||
cause,
|
||||
message: errorMessage(cause),
|
||||
});
|
||||
|
||||
export function makeDocumentRagEngine(): DocumentRagEngineShape {
|
||||
return {
|
||||
query: Effect.fn("DocumentRagEngine.query")((
|
||||
clients: DocumentRagClients,
|
||||
queryText: string,
|
||||
options?: DocumentRagQueryOptions,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => queryDocumentRag(clients, queryText, options),
|
||||
catch: (cause) => documentRagError("query", cause),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export const DocumentRagLive: Layer.Layer<DocumentRagEngine> = Layer.succeed(
|
||||
DocumentRagEngine,
|
||||
DocumentRagEngine.of(makeDocumentRagEngine()),
|
||||
);
|
||||
|
||||
export class DocumentRag {
|
||||
private readonly engine = makeDocumentRagEngine();
|
||||
private readonly clients: DocumentRagClients;
|
||||
|
||||
constructor(clients: DocumentRagClients) {
|
||||
this.clients = clients;
|
||||
}
|
||||
|
||||
async query(
|
||||
query(
|
||||
queryText: string,
|
||||
options?: {
|
||||
collection?: string;
|
||||
streaming?: boolean;
|
||||
chunkCallback?: ChunkCallback;
|
||||
},
|
||||
options?: DocumentRagQueryOptions,
|
||||
): Promise<string> {
|
||||
const collection = options?.collection ?? "default";
|
||||
|
||||
// Step 1: Embed the query
|
||||
const embResp = await this.clients.embeddings.request({ text: [queryText] });
|
||||
const vectors = (embResp as EmbeddingsResponse).vectors;
|
||||
|
||||
// Step 2: Find similar document chunks
|
||||
const docResp = await this.clients.docEmbeddings.request({
|
||||
vectors,
|
||||
limit: 10,
|
||||
collection,
|
||||
user: "default",
|
||||
});
|
||||
const chunks = (docResp as DocumentEmbeddingsResponse).chunks ?? [];
|
||||
console.log(`[DocumentRag] Found ${chunks.length} matching chunks`);
|
||||
|
||||
// Step 3: Build context from chunks
|
||||
const context = chunks
|
||||
.flatMap((c) =>
|
||||
c.content !== undefined && c.content.length > 0 ? [c.content] : [],
|
||||
)
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
// Step 4: Synthesize answer
|
||||
const promptResp = await this.clients.prompt.request({
|
||||
name: "document-rag-synthesize",
|
||||
variables: { query: queryText, context },
|
||||
});
|
||||
|
||||
const resp = await this.clients.llm.request({
|
||||
system: (promptResp as PromptResponse).system,
|
||||
prompt: (promptResp as PromptResponse).prompt,
|
||||
});
|
||||
|
||||
return (resp as TextCompletionResponse).response;
|
||||
return Effect.runPromise(this.engine.query(this.clients, queryText, options));
|
||||
}
|
||||
}
|
||||
|
||||
async function queryDocumentRag(
|
||||
clients: DocumentRagClients,
|
||||
queryText: string,
|
||||
options?: DocumentRagQueryOptions,
|
||||
): Promise<string> {
|
||||
const collection = options?.collection ?? "default";
|
||||
|
||||
const embResp = await clients.embeddings.request({ text: [queryText] });
|
||||
const vectors = embResp.vectors;
|
||||
|
||||
const docResp = await clients.docEmbeddings.request({
|
||||
vectors,
|
||||
limit: 10,
|
||||
collection,
|
||||
user: "default",
|
||||
});
|
||||
const chunks = docResp.chunks ?? [];
|
||||
console.log(`[DocumentRag] Found ${chunks.length} matching chunks`);
|
||||
|
||||
const context = chunks
|
||||
.flatMap((chunk) =>
|
||||
chunk.content !== undefined && chunk.content.length > 0 ? [chunk.content] : [],
|
||||
)
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
const promptResp = await clients.prompt.request({
|
||||
name: "document-rag-synthesize",
|
||||
variables: { query: queryText, context },
|
||||
});
|
||||
|
||||
const resp = await clients.llm.request({
|
||||
system: promptResp.system,
|
||||
prompt: promptResp.prompt,
|
||||
});
|
||||
|
||||
return resp.response;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,158 +1,197 @@
|
|||
/**
|
||||
* Graph RAG service — FlowProcessor wrapper around the GraphRag class.
|
||||
* Graph RAG service.
|
||||
*
|
||||
* Consumes GraphRagRequest messages from the agent/gateway, runs the full
|
||||
* Graph RAG pipeline (concept extraction → entity lookup → graph traversal →
|
||||
* edge scoring → answer synthesis), and emits GraphRagResponse.
|
||||
*
|
||||
* Each request gets its own GraphRag instance to prevent data leakage
|
||||
* across requests (security requirement from the Python implementation).
|
||||
* Graph RAG pipeline, and emits GraphRagResponse.
|
||||
*
|
||||
* Python reference: trustgraph-flow/trustgraph/retrieval/graph_rag/rag.py
|
||||
*/
|
||||
|
||||
import {
|
||||
FlowProcessor,
|
||||
ConsumerSpec,
|
||||
FlowProcessor,
|
||||
ProducerSpec,
|
||||
RequestResponseSpec,
|
||||
type ProcessorConfig,
|
||||
makeFlowProcessorProgram,
|
||||
type EffectRequestOptions,
|
||||
type EffectRequestResponse,
|
||||
type FlowContext,
|
||||
type GraphRagRequest,
|
||||
type GraphRagResponse,
|
||||
type TextCompletionRequest,
|
||||
type TextCompletionResponse,
|
||||
type EmbeddingsRequest,
|
||||
type EmbeddingsResponse,
|
||||
type FlowRequestOptions,
|
||||
type FlowRequestor,
|
||||
type FlowResourceNotFoundError,
|
||||
type GraphEmbeddingsRequest,
|
||||
type GraphEmbeddingsResponse,
|
||||
type TriplesQueryRequest,
|
||||
type TriplesQueryResponse,
|
||||
type GraphRagRequest,
|
||||
type GraphRagResponse,
|
||||
type EmbeddingsRequest,
|
||||
type EmbeddingsResponse,
|
||||
type MessagingDeliveryError,
|
||||
type ProcessorConfig,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type Spec,
|
||||
type TextCompletionRequest,
|
||||
type TextCompletionResponse,
|
||||
type TriplesQueryRequest,
|
||||
type TriplesQueryResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { GraphRag } from "./graph-rag.js";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
GraphRagEngine,
|
||||
GraphRagEngineError,
|
||||
GraphRagLive,
|
||||
makeGraphRagEngine,
|
||||
type GraphRagClients,
|
||||
type GraphRagConfig,
|
||||
} from "./graph-rag.js";
|
||||
|
||||
export class GraphRagService extends FlowProcessor {
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
const toEffectRequestOptions = <TRes>(
|
||||
options: FlowRequestOptions<TRes> | undefined,
|
||||
): EffectRequestOptions<TRes> | undefined => {
|
||||
if (options === undefined) return undefined;
|
||||
return {
|
||||
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
||||
...(options.recipient === undefined
|
||||
? {}
|
||||
: {
|
||||
recipient: (response: TRes) =>
|
||||
Effect.promise(() => options.recipient?.(response) ?? Promise.resolve(true)),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
// Consumer: graph RAG requests
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<GraphRagRequest>("graph-rag-request", this.onRequest.bind(this)),
|
||||
);
|
||||
const toPromiseRequestor = <TReq, TRes>(
|
||||
requestor: EffectRequestResponse<TReq, TRes>,
|
||||
): FlowRequestor<TReq, TRes> => ({
|
||||
request: (request, options) =>
|
||||
Effect.runPromise(requestor.request(request, toEffectRequestOptions(options))),
|
||||
stop: () => Effect.runPromise(requestor.stop),
|
||||
});
|
||||
|
||||
// Producer: graph RAG responses
|
||||
this.registerSpecification(new ProducerSpec<GraphRagResponse>("graph-rag-response"));
|
||||
const graphRagConfigFromRequest = (msg: GraphRagRequest): GraphRagConfig => ({
|
||||
...(msg.entityLimit !== undefined ? { entityLimit: msg.entityLimit } : {}),
|
||||
...(msg.tripleLimit !== undefined ? { tripleLimit: msg.tripleLimit } : {}),
|
||||
...(msg.maxSubgraphSize !== undefined ? { maxSubgraphSize: msg.maxSubgraphSize } : {}),
|
||||
...(msg.maxPathLength !== undefined ? { maxPathLength: msg.maxPathLength } : {}),
|
||||
});
|
||||
|
||||
// Request-response clients for the pipeline
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
|
||||
"llm",
|
||||
"text-completion-request",
|
||||
"text-completion-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
|
||||
"embeddings",
|
||||
"embeddings-request",
|
||||
"embeddings-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<GraphEmbeddingsRequest, GraphEmbeddingsResponse>(
|
||||
"graph-embeddings",
|
||||
"graph-embeddings-request",
|
||||
"graph-embeddings-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<TriplesQueryRequest, TriplesQueryResponse>(
|
||||
"triples",
|
||||
"triples-request",
|
||||
"triples-response",
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<PromptRequest, PromptResponse>(
|
||||
"prompt",
|
||||
"prompt-request",
|
||||
"prompt-response",
|
||||
),
|
||||
);
|
||||
const onGraphRagRequest = Effect.fn("GraphRagService.onRequest")(function* (
|
||||
msg: GraphRagRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext<GraphRagEngine>,
|
||||
) {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
console.log("[GraphRag] Service initialized");
|
||||
const producer = yield* flowCtx.flow.producerEffect<GraphRagResponse>("graph-rag-response");
|
||||
const engine = yield* GraphRagEngine;
|
||||
|
||||
yield* Effect.log(`[GraphRagService] Received request ${requestId}: "${msg.query?.slice(0, 60)}..." collection=${msg.collection}`);
|
||||
|
||||
const clients: GraphRagClients = {
|
||||
llm: toPromiseRequestor(yield* flowCtx.flow.requestorEffect<TextCompletionRequest, TextCompletionResponse>("llm")),
|
||||
embeddings: toPromiseRequestor(yield* flowCtx.flow.requestorEffect<EmbeddingsRequest, EmbeddingsResponse>("embeddings")),
|
||||
graphEmbeddings: toPromiseRequestor(
|
||||
yield* flowCtx.flow.requestorEffect<GraphEmbeddingsRequest, GraphEmbeddingsResponse>("graph-embeddings"),
|
||||
),
|
||||
triples: toPromiseRequestor(yield* flowCtx.flow.requestorEffect<TriplesQueryRequest, TriplesQueryResponse>("triples")),
|
||||
prompt: toPromiseRequestor(yield* flowCtx.flow.requestorEffect<PromptRequest, PromptResponse>("prompt")),
|
||||
};
|
||||
|
||||
const result = yield* engine.query(
|
||||
clients,
|
||||
msg.query,
|
||||
{
|
||||
...(msg.collection !== undefined ? { collection: msg.collection } : {}),
|
||||
},
|
||||
graphRagConfigFromRequest(msg),
|
||||
).pipe(
|
||||
Effect.catch((error: GraphRagEngineError) =>
|
||||
Effect.logError("[GraphRag] Query failed", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
producer.send(requestId, {
|
||||
response: "",
|
||||
error: { type: "rag-error", message: error.message },
|
||||
}),
|
||||
),
|
||||
Effect.as(undefined),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result === undefined) return;
|
||||
|
||||
const response: GraphRagResponse = {
|
||||
response: result.answer,
|
||||
endOfStream: true,
|
||||
};
|
||||
|
||||
if (result.subgraph.length > 0) {
|
||||
(response as Record<string, unknown>).message_type = "explain";
|
||||
(response as Record<string, unknown>).explain_id = `explain-${requestId}`;
|
||||
(response as Record<string, unknown>).explain_triples = result.subgraph;
|
||||
}
|
||||
|
||||
private async onRequest(
|
||||
msg: GraphRagRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
yield* producer.send(requestId, response);
|
||||
});
|
||||
|
||||
const producer = flowCtx.flow.producer<GraphRagResponse>("graph-rag-response");
|
||||
console.log(`[GraphRagService] Received request ${requestId}: "${msg.query?.slice(0, 60)}..." collection=${msg.collection}`);
|
||||
export const makeGraphRagSpecs = (): ReadonlyArray<Spec<GraphRagEngine>> => [
|
||||
new ConsumerSpec<GraphRagRequest, FlowResourceNotFoundError | MessagingDeliveryError, GraphRagEngine>(
|
||||
"graph-rag-request",
|
||||
onGraphRagRequest,
|
||||
),
|
||||
new ProducerSpec<GraphRagResponse>("graph-rag-response"),
|
||||
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
|
||||
"llm",
|
||||
"text-completion-request",
|
||||
"text-completion-response",
|
||||
),
|
||||
new RequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
|
||||
"embeddings",
|
||||
"embeddings-request",
|
||||
"embeddings-response",
|
||||
),
|
||||
new RequestResponseSpec<GraphEmbeddingsRequest, GraphEmbeddingsResponse>(
|
||||
"graph-embeddings",
|
||||
"graph-embeddings-request",
|
||||
"graph-embeddings-response",
|
||||
),
|
||||
new RequestResponseSpec<TriplesQueryRequest, TriplesQueryResponse>(
|
||||
"triples",
|
||||
"triples-request",
|
||||
"triples-response",
|
||||
),
|
||||
new RequestResponseSpec<PromptRequest, PromptResponse>(
|
||||
"prompt",
|
||||
"prompt-request",
|
||||
"prompt-response",
|
||||
),
|
||||
];
|
||||
|
||||
try {
|
||||
// Create a per-request GraphRag instance with flow clients
|
||||
const graphRag = new GraphRag(
|
||||
{
|
||||
llm: flowCtx.flow.requestor<TextCompletionRequest, TextCompletionResponse>("llm"),
|
||||
embeddings: flowCtx.flow.requestor<EmbeddingsRequest, EmbeddingsResponse>("embeddings"),
|
||||
graphEmbeddings: flowCtx.flow.requestor<GraphEmbeddingsRequest, GraphEmbeddingsResponse>("graph-embeddings"),
|
||||
triples: flowCtx.flow.requestor<TriplesQueryRequest, TriplesQueryResponse>("triples"),
|
||||
prompt: flowCtx.flow.requestor<PromptRequest, PromptResponse>("prompt"),
|
||||
},
|
||||
{
|
||||
...(msg.entityLimit !== undefined ? { entityLimit: msg.entityLimit } : {}),
|
||||
...(msg.tripleLimit !== undefined ? { tripleLimit: msg.tripleLimit } : {}),
|
||||
...(msg.maxSubgraphSize !== undefined
|
||||
? { maxSubgraphSize: msg.maxSubgraphSize }
|
||||
: {}),
|
||||
...(msg.maxPathLength !== undefined ? { maxPathLength: msg.maxPathLength } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
const result = await graphRag.query(msg.query, {
|
||||
...(msg.collection !== undefined ? { collection: msg.collection } : {}),
|
||||
});
|
||||
|
||||
// Send answer with explain data embedded in a SINGLE message.
|
||||
// Non-streaming callers (agent's RequestResponse) return the first
|
||||
// response — so the answer must be in that first (and only) message.
|
||||
// Streaming callers (gateway) extract explain data + answer from
|
||||
// the same message.
|
||||
const response: GraphRagResponse = {
|
||||
response: result.answer,
|
||||
endOfStream: true,
|
||||
};
|
||||
|
||||
if (result.subgraph.length > 0) {
|
||||
(response as Record<string, unknown>).message_type = "explain";
|
||||
(response as Record<string, unknown>).explain_id = `explain-${requestId}`;
|
||||
(response as Record<string, unknown>).explain_triples = result.subgraph;
|
||||
}
|
||||
|
||||
await producer.send(requestId, response);
|
||||
} catch (err) {
|
||||
console.error("[GraphRag] Query failed:", err);
|
||||
await producer.send(requestId, {
|
||||
response: "",
|
||||
error: { type: "rag-error", message: String(err) },
|
||||
});
|
||||
export class GraphRagService extends FlowProcessor<GraphRagEngine> {
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
for (const spec of makeGraphRagSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
}
|
||||
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(GraphRagEngine, GraphRagEngine.of(makeGraphRagEngine())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram({
|
||||
id: "graph-rag",
|
||||
make: (config) => new GraphRagService(config),
|
||||
specs: makeGraphRagSpecs,
|
||||
layer: () => GraphRagLive,
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await GraphRagService.launch("graph-rag");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,15 @@
|
|||
/**
|
||||
* Graph RAG retrieval pipeline.
|
||||
*
|
||||
* This is the core RAG pipeline that:
|
||||
* 1. Extracts concepts from the query
|
||||
* 2. Embeds concepts to find matching entities
|
||||
* 3. Traverses the knowledge graph from those entities
|
||||
* 4. Scores and filters edges
|
||||
* 5. Synthesizes an answer with the selected context
|
||||
*
|
||||
* Python reference: trustgraph-flow/trustgraph/retrieval/graph_rag/graph_rag.py
|
||||
*/
|
||||
|
||||
import type {
|
||||
EmbeddingsRequest,
|
||||
EmbeddingsResponse,
|
||||
FlowRequestor,
|
||||
GraphEmbeddingsRequest,
|
||||
GraphEmbeddingsResponse,
|
||||
FlowRequestor,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
Term,
|
||||
|
|
@ -26,6 +19,10 @@ import type {
|
|||
TriplesQueryRequest,
|
||||
TriplesQueryResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { errorMessage } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface GraphRagConfig {
|
||||
entityLimit?: number;
|
||||
|
|
@ -46,321 +43,373 @@ export interface GraphRagClients {
|
|||
|
||||
export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>;
|
||||
|
||||
export interface GraphRagQueryOptions {
|
||||
readonly collection?: string;
|
||||
readonly streaming?: boolean;
|
||||
readonly chunkCallback?: ChunkCallback;
|
||||
}
|
||||
|
||||
export interface GraphRagResult {
|
||||
answer: string;
|
||||
subgraph: Triple[];
|
||||
}
|
||||
|
||||
interface NormalizedGraphRagConfig {
|
||||
entityLimit: number;
|
||||
tripleLimit: number;
|
||||
maxSubgraphSize: number;
|
||||
maxPathLength: number;
|
||||
edgeScoreLimit: number;
|
||||
edgeLimit: number;
|
||||
}
|
||||
|
||||
export class GraphRagEngineError extends S.TaggedErrorClass<GraphRagEngineError>()(
|
||||
"GraphRagEngineError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
cause: S.DefectWithStack,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface GraphRagEngineShape {
|
||||
readonly query: (
|
||||
clients: GraphRagClients,
|
||||
queryText: string,
|
||||
options?: GraphRagQueryOptions,
|
||||
config?: GraphRagConfig,
|
||||
) => Effect.Effect<GraphRagResult, GraphRagEngineError>;
|
||||
}
|
||||
|
||||
export class GraphRagEngine extends Context.Service<GraphRagEngine, GraphRagEngineShape>()(
|
||||
"@trustgraph/flow/retrieval/graph-rag/GraphRagEngine",
|
||||
) {}
|
||||
|
||||
const graphRagError = (operation: string, cause: unknown) =>
|
||||
new GraphRagEngineError({
|
||||
operation,
|
||||
cause,
|
||||
message: errorMessage(cause),
|
||||
});
|
||||
|
||||
export function normalizeGraphRagConfig(config: GraphRagConfig = {}): NormalizedGraphRagConfig {
|
||||
return {
|
||||
entityLimit: config.entityLimit ?? 50,
|
||||
tripleLimit: config.tripleLimit ?? 30,
|
||||
maxSubgraphSize: config.maxSubgraphSize ?? 1000,
|
||||
maxPathLength: config.maxPathLength ?? 2,
|
||||
edgeScoreLimit: config.edgeScoreLimit ?? 50,
|
||||
edgeLimit: config.edgeLimit ?? 25,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeGraphRagEngine(): GraphRagEngineShape {
|
||||
return {
|
||||
query: Effect.fn("GraphRagEngine.query")((
|
||||
clients: GraphRagClients,
|
||||
queryText: string,
|
||||
options?: GraphRagQueryOptions,
|
||||
config?: GraphRagConfig,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => queryGraphRag(clients, queryText, options, config),
|
||||
catch: (cause) => graphRagError("query", cause),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export const GraphRagLive: Layer.Layer<GraphRagEngine> = Layer.succeed(
|
||||
GraphRagEngine,
|
||||
GraphRagEngine.of(makeGraphRagEngine()),
|
||||
);
|
||||
|
||||
export class GraphRag {
|
||||
private readonly engine = makeGraphRagEngine();
|
||||
private readonly clients: GraphRagClients;
|
||||
private config: Required<GraphRagConfig>;
|
||||
private readonly config: GraphRagConfig;
|
||||
|
||||
constructor(
|
||||
clients: GraphRagClients,
|
||||
config: GraphRagConfig = {},
|
||||
) {
|
||||
this.clients = clients;
|
||||
this.config = {
|
||||
entityLimit: config.entityLimit ?? 50,
|
||||
tripleLimit: config.tripleLimit ?? 30,
|
||||
maxSubgraphSize: config.maxSubgraphSize ?? 1000,
|
||||
maxPathLength: config.maxPathLength ?? 2,
|
||||
edgeScoreLimit: config.edgeScoreLimit ?? 50,
|
||||
edgeLimit: config.edgeLimit ?? 25,
|
||||
};
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async query(
|
||||
query(
|
||||
queryText: string,
|
||||
options?: {
|
||||
collection?: string;
|
||||
streaming?: boolean;
|
||||
chunkCallback?: ChunkCallback;
|
||||
},
|
||||
options?: GraphRagQueryOptions,
|
||||
): Promise<GraphRagResult> {
|
||||
console.log(`[GraphRag] Query: "${queryText.slice(0, 80)}..."`);
|
||||
|
||||
// Step 1: Extract concepts from the query via prompt + LLM
|
||||
const concepts = await this.extractConcepts(queryText);
|
||||
console.log(`[GraphRag] Step 1: extracted ${concepts.length} concepts: ${concepts.slice(0, 5).join(", ")}`);
|
||||
|
||||
// Step 2: Embed concepts concurrently
|
||||
const vectors = await this.getVectors(concepts);
|
||||
console.log(`[GraphRag] Step 2: got ${vectors.length} vectors (dim=${vectors[0]?.length ?? 0})`);
|
||||
|
||||
// Step 3: Find matching entities via graph embeddings
|
||||
const entities = await this.getEntities(vectors, options?.collection);
|
||||
console.log(`[GraphRag] Step 3: found ${entities.length} matching entities`);
|
||||
|
||||
// Step 4: Traverse the knowledge graph from entities
|
||||
const subgraph = await this.followEdges(entities, options?.collection);
|
||||
console.log(`[GraphRag] Step 4: traversed graph, ${subgraph.length} triples in subgraph`);
|
||||
|
||||
// Step 5: Score and filter edges via LLM
|
||||
const scoredEdges = await this.scoreEdges(queryText, subgraph);
|
||||
console.log(`[GraphRag] Step 5: scored down to ${scoredEdges.length} edges`);
|
||||
|
||||
// Step 6: Synthesize answer
|
||||
console.log(`[GraphRag] Step 6: synthesizing answer from ${scoredEdges.length} edges...`);
|
||||
const answer = await this.synthesize(
|
||||
queryText,
|
||||
scoredEdges,
|
||||
options?.chunkCallback,
|
||||
return Effect.runPromise(
|
||||
this.engine.query(this.clients, queryText, options, this.config),
|
||||
);
|
||||
console.log(`[GraphRag] Step 6: done (${answer.length} chars)`);
|
||||
|
||||
return { answer, subgraph: scoredEdges };
|
||||
}
|
||||
|
||||
private async extractConcepts(query: string): Promise<string[]> {
|
||||
const promptResp = await this.clients.prompt.request({
|
||||
name: "extract-concepts",
|
||||
variables: { query },
|
||||
});
|
||||
|
||||
const llmResp = await this.clients.llm.request({
|
||||
system: (promptResp as PromptResponse).system,
|
||||
prompt: (promptResp as PromptResponse).prompt,
|
||||
});
|
||||
|
||||
// Parse concepts from LLM response (newline-separated)
|
||||
return (llmResp as TextCompletionResponse).response
|
||||
.split("\n")
|
||||
.map((c) => c.trim())
|
||||
.filter((c) => c.length > 0);
|
||||
}
|
||||
|
||||
private async getVectors(concepts: string[]): Promise<number[][]> {
|
||||
const resp = await this.clients.embeddings.request({ text: concepts });
|
||||
return (resp as EmbeddingsResponse).vectors;
|
||||
}
|
||||
|
||||
private async getEntities(vectors: number[][], collection?: string): Promise<Term[]> {
|
||||
const resp = await this.clients.graphEmbeddings.request({
|
||||
vectors,
|
||||
user: "default",
|
||||
collection: collection ?? "default",
|
||||
limit: this.config.entityLimit,
|
||||
});
|
||||
return (resp as GraphEmbeddingsResponse).entities;
|
||||
}
|
||||
|
||||
private async followEdges(entities: Term[], collection?: string): Promise<Triple[]> {
|
||||
// BFS multi-hop traversal up to maxPathLength
|
||||
const visited = new Set<string>();
|
||||
const subgraph: Triple[] = [];
|
||||
|
||||
// Current frontier: the set of entities to expand at this depth level
|
||||
let currentLevel = new Set<string>(
|
||||
entities.map((e) => termToString(e)),
|
||||
);
|
||||
|
||||
for (let depth = 0; depth < this.config.maxPathLength; depth++) {
|
||||
if (currentLevel.size === 0 || subgraph.length >= this.config.maxSubgraphSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Filter out already-visited entities
|
||||
const unvisited = [...currentLevel].filter((e) => !visited.has(e));
|
||||
if (unvisited.length === 0) break;
|
||||
|
||||
// Batch triple queries for all unvisited entities at this depth
|
||||
// Query each entity as subject to get outgoing edges
|
||||
const queries = unvisited.map((entityStr) => {
|
||||
const term = stringToTerm(entityStr);
|
||||
const request: TriplesQueryRequest = {
|
||||
s: term,
|
||||
limit: this.config.tripleLimit,
|
||||
...(collection !== undefined ? { collection } : {}),
|
||||
};
|
||||
return this.clients.triples.request(request);
|
||||
});
|
||||
|
||||
const results = await Promise.all(queries);
|
||||
|
||||
const nextLevel = new Set<string>();
|
||||
|
||||
for (const result of results) {
|
||||
const triples = (result as TriplesQueryResponse).triples;
|
||||
for (const triple of triples) {
|
||||
subgraph.push(triple);
|
||||
|
||||
// Collect objects as next-level entities for further expansion
|
||||
// (only if we have more depth levels remaining)
|
||||
if (depth < this.config.maxPathLength - 1) {
|
||||
const objStr = termToString(triple.o);
|
||||
if (!visited.has(objStr)) {
|
||||
nextLevel.add(objStr);
|
||||
}
|
||||
}
|
||||
|
||||
if (subgraph.length >= this.config.maxSubgraphSize) {
|
||||
return subgraph;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark current level as visited and move to next
|
||||
for (const e of currentLevel) {
|
||||
visited.add(e);
|
||||
}
|
||||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
return subgraph.slice(0, this.config.maxSubgraphSize);
|
||||
}
|
||||
|
||||
private async scoreEdges(query: string, triples: Triple[]): Promise<Triple[]> {
|
||||
if (triples.length === 0) return [];
|
||||
|
||||
// If the subgraph is small enough, skip LLM scoring entirely
|
||||
// 500 triples is well within LLM context limits and avoids lossy scoring
|
||||
if (triples.length <= 500) {
|
||||
console.log(`[GraphRag] Skipping edge scoring — ${triples.length} triples fits in context directly`);
|
||||
return triples;
|
||||
}
|
||||
|
||||
// Build a numbered list of edges for the LLM to score
|
||||
const edgeDescriptions = triples.map((t, i) => ({
|
||||
id: String(i),
|
||||
s: termToString(t.s),
|
||||
p: termToString(t.p),
|
||||
o: termToString(t.o),
|
||||
}));
|
||||
|
||||
// Limit how many edges we send for scoring to avoid overflowing context
|
||||
const toScore = edgeDescriptions.slice(0, this.config.edgeScoreLimit);
|
||||
|
||||
const knowledgeJson = JSON.stringify(toScore, null, 2);
|
||||
|
||||
// Ask the LLM to score each edge for relevance to the query
|
||||
const promptResp = await this.clients.prompt.request({
|
||||
name: "kg-edge-scoring",
|
||||
variables: {
|
||||
query,
|
||||
knowledge: knowledgeJson,
|
||||
},
|
||||
});
|
||||
|
||||
const llmResp = await this.clients.llm.request({
|
||||
system: (promptResp as PromptResponse).system,
|
||||
prompt: (promptResp as PromptResponse).prompt,
|
||||
});
|
||||
|
||||
const responseText = (llmResp as TextCompletionResponse).response;
|
||||
console.log(`[GraphRag] Edge scoring LLM response (first 500 chars): ${responseText.slice(0, 500)}`);
|
||||
|
||||
// Parse scores from LLM response
|
||||
// Expected format: JSON array of { id: string, score: number }
|
||||
// or newline-separated JSON objects
|
||||
const scored: Array<{ id: string; score: number }> = [];
|
||||
|
||||
try {
|
||||
// Try parsing as a JSON array first
|
||||
const parsed = JSON.parse(responseText) as Array<{ id: string; score: number }>;
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const item of parsed) {
|
||||
if (
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
typeof item.id === "string" &&
|
||||
typeof item.score === "number"
|
||||
) {
|
||||
scored.push({ id: item.id, score: item.score });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to parsing line-by-line JSON objects
|
||||
for (const line of responseText.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0) continue;
|
||||
try {
|
||||
const obj = JSON.parse(trimmed) as { id?: string; score?: number };
|
||||
if (
|
||||
typeof obj === "object" &&
|
||||
obj !== null &&
|
||||
typeof obj.id === "string" &&
|
||||
typeof obj.score === "number"
|
||||
) {
|
||||
scored.push({ id: obj.id, score: obj.score });
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending and keep top N
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const topN = scored.slice(0, this.config.edgeLimit);
|
||||
// Map back to triples
|
||||
const result: Triple[] = [];
|
||||
for (const entry of topN) {
|
||||
const idx = parseInt(entry.id, 10);
|
||||
if (!isNaN(idx) && idx >= 0 && idx < triples.length) {
|
||||
result.push(triples[idx]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[GraphRag] Edge scoring: LLM returned ${scored.length} scores, keeping top ${topN.length}, mapped ${result.length} triples`);
|
||||
|
||||
// If scoring failed entirely, fall back to returning the first edgeLimit triples
|
||||
if (result.length === 0) {
|
||||
return triples.slice(0, this.config.edgeLimit);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async synthesize(
|
||||
query: string,
|
||||
edges: Triple[],
|
||||
chunkCallback?: ChunkCallback,
|
||||
): Promise<string> {
|
||||
// Format edges as context
|
||||
const context = edges
|
||||
.map((t) => `${termToString(t.s)} -> ${termToString(t.p)} -> ${termToString(t.o)}`)
|
||||
.join("\n");
|
||||
|
||||
const promptResp = await this.clients.prompt.request({
|
||||
name: "graph-rag-synthesize",
|
||||
variables: { query, context },
|
||||
});
|
||||
|
||||
if (chunkCallback !== undefined) {
|
||||
// Streaming response
|
||||
let fullText = "";
|
||||
await this.clients.llm.request(
|
||||
{
|
||||
system: (promptResp as PromptResponse).system,
|
||||
prompt: (promptResp as PromptResponse).prompt,
|
||||
streaming: true,
|
||||
},
|
||||
{
|
||||
recipient: async (resp) => {
|
||||
const r = resp as TextCompletionResponse;
|
||||
if (r.response.length > 0) {
|
||||
fullText += r.response;
|
||||
await chunkCallback(r.response, r.endOfStream === true);
|
||||
}
|
||||
return r.endOfStream === true;
|
||||
},
|
||||
},
|
||||
);
|
||||
return fullText;
|
||||
}
|
||||
|
||||
const resp = await this.clients.llm.request({
|
||||
system: (promptResp as PromptResponse).system,
|
||||
prompt: (promptResp as PromptResponse).prompt,
|
||||
});
|
||||
|
||||
return (resp as TextCompletionResponse).response;
|
||||
}
|
||||
}
|
||||
|
||||
function termToString(term: Term): string {
|
||||
async function queryGraphRag(
|
||||
clients: GraphRagClients,
|
||||
queryText: string,
|
||||
options?: GraphRagQueryOptions,
|
||||
rawConfig?: GraphRagConfig,
|
||||
): Promise<GraphRagResult> {
|
||||
const config = normalizeGraphRagConfig(rawConfig);
|
||||
console.log(`[GraphRag] Query: "${queryText.slice(0, 80)}..."`);
|
||||
|
||||
const concepts = await extractConcepts(clients, queryText);
|
||||
console.log(`[GraphRag] Step 1: extracted ${concepts.length} concepts: ${concepts.slice(0, 5).join(", ")}`);
|
||||
|
||||
const vectors = await getVectors(clients, concepts);
|
||||
console.log(`[GraphRag] Step 2: got ${vectors.length} vectors (dim=${vectors[0]?.length ?? 0})`);
|
||||
|
||||
const entities = await getEntities(clients, config, vectors, options?.collection);
|
||||
console.log(`[GraphRag] Step 3: found ${entities.length} matching entities`);
|
||||
|
||||
const subgraph = await followEdges(clients, config, entities, options?.collection);
|
||||
console.log(`[GraphRag] Step 4: traversed graph, ${subgraph.length} triples in subgraph`);
|
||||
|
||||
const scoredEdges = await scoreEdges(clients, config, queryText, subgraph);
|
||||
console.log(`[GraphRag] Step 5: scored down to ${scoredEdges.length} edges`);
|
||||
|
||||
console.log(`[GraphRag] Step 6: synthesizing answer from ${scoredEdges.length} edges...`);
|
||||
const answer = await synthesize(
|
||||
clients,
|
||||
queryText,
|
||||
scoredEdges,
|
||||
options?.chunkCallback,
|
||||
);
|
||||
console.log(`[GraphRag] Step 6: done (${answer.length} chars)`);
|
||||
|
||||
return { answer, subgraph: scoredEdges };
|
||||
}
|
||||
|
||||
async function extractConcepts(clients: GraphRagClients, query: string): Promise<string[]> {
|
||||
const promptResp = await clients.prompt.request({
|
||||
name: "extract-concepts",
|
||||
variables: { query },
|
||||
});
|
||||
|
||||
const llmResp = await clients.llm.request({
|
||||
system: promptResp.system,
|
||||
prompt: promptResp.prompt,
|
||||
});
|
||||
|
||||
return llmResp.response
|
||||
.split("\n")
|
||||
.map((concept) => concept.trim())
|
||||
.filter((concept) => concept.length > 0);
|
||||
}
|
||||
|
||||
async function getVectors(clients: GraphRagClients, concepts: string[]): Promise<number[][]> {
|
||||
const resp = await clients.embeddings.request({ text: concepts });
|
||||
return resp.vectors;
|
||||
}
|
||||
|
||||
async function getEntities(
|
||||
clients: GraphRagClients,
|
||||
config: NormalizedGraphRagConfig,
|
||||
vectors: number[][],
|
||||
collection?: string,
|
||||
): Promise<Term[]> {
|
||||
const resp = await clients.graphEmbeddings.request({
|
||||
vectors,
|
||||
user: "default",
|
||||
collection: collection ?? "default",
|
||||
limit: config.entityLimit,
|
||||
});
|
||||
return resp.entities;
|
||||
}
|
||||
|
||||
async function followEdges(
|
||||
clients: GraphRagClients,
|
||||
config: NormalizedGraphRagConfig,
|
||||
entities: Term[],
|
||||
collection?: string,
|
||||
): Promise<Triple[]> {
|
||||
const visited = new Set<string>();
|
||||
const subgraph: Triple[] = [];
|
||||
let currentLevel = new Set<string>(
|
||||
entities.map((entity) => termToString(entity)),
|
||||
);
|
||||
|
||||
for (let depth = 0; depth < config.maxPathLength; depth++) {
|
||||
if (currentLevel.size === 0 || subgraph.length >= config.maxSubgraphSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
const unvisited = [...currentLevel].filter((entity) => !visited.has(entity));
|
||||
if (unvisited.length === 0) break;
|
||||
|
||||
const queries = unvisited.map((entityStr) => {
|
||||
const term = stringToTerm(entityStr);
|
||||
const request: TriplesQueryRequest = {
|
||||
s: term,
|
||||
limit: config.tripleLimit,
|
||||
...(collection !== undefined ? { collection } : {}),
|
||||
};
|
||||
return clients.triples.request(request);
|
||||
});
|
||||
|
||||
const results = await Promise.all(queries);
|
||||
const nextLevel = new Set<string>();
|
||||
|
||||
for (const result of results) {
|
||||
for (const triple of result.triples) {
|
||||
subgraph.push(triple);
|
||||
|
||||
if (depth < config.maxPathLength - 1) {
|
||||
const objStr = termToString(triple.o);
|
||||
if (!visited.has(objStr)) {
|
||||
nextLevel.add(objStr);
|
||||
}
|
||||
}
|
||||
|
||||
if (subgraph.length >= config.maxSubgraphSize) {
|
||||
return subgraph;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entity of currentLevel) {
|
||||
visited.add(entity);
|
||||
}
|
||||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
return subgraph.slice(0, config.maxSubgraphSize);
|
||||
}
|
||||
|
||||
async function scoreEdges(
|
||||
clients: GraphRagClients,
|
||||
config: NormalizedGraphRagConfig,
|
||||
query: string,
|
||||
triples: Triple[],
|
||||
): Promise<Triple[]> {
|
||||
if (triples.length === 0) return [];
|
||||
|
||||
if (triples.length <= 500) {
|
||||
console.log(`[GraphRag] Skipping edge scoring - ${triples.length} triples fits in context directly`);
|
||||
return triples;
|
||||
}
|
||||
|
||||
const edgeDescriptions = triples.map((triple, index) => ({
|
||||
id: String(index),
|
||||
s: termToString(triple.s),
|
||||
p: termToString(triple.p),
|
||||
o: termToString(triple.o),
|
||||
}));
|
||||
|
||||
const toScore = edgeDescriptions.slice(0, config.edgeScoreLimit);
|
||||
const knowledgeJson = JSON.stringify(toScore, null, 2);
|
||||
|
||||
const promptResp = await clients.prompt.request({
|
||||
name: "kg-edge-scoring",
|
||||
variables: {
|
||||
query,
|
||||
knowledge: knowledgeJson,
|
||||
},
|
||||
});
|
||||
|
||||
const llmResp = await clients.llm.request({
|
||||
system: promptResp.system,
|
||||
prompt: promptResp.prompt,
|
||||
});
|
||||
|
||||
console.log(`[GraphRag] Edge scoring LLM response (first 500 chars): ${llmResp.response.slice(0, 500)}`);
|
||||
|
||||
const scored = parseScoredEdges(llmResp.response);
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const topN = scored.slice(0, config.edgeLimit);
|
||||
|
||||
const result: Triple[] = [];
|
||||
for (const entry of topN) {
|
||||
const idx = Number.parseInt(entry.id, 10);
|
||||
if (!Number.isNaN(idx) && idx >= 0 && idx < triples.length) {
|
||||
result.push(triples[idx]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[GraphRag] Edge scoring: LLM returned ${scored.length} scores, keeping top ${topN.length}, mapped ${result.length} triples`);
|
||||
|
||||
if (result.length === 0) {
|
||||
return triples.slice(0, config.edgeLimit);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function synthesize(
|
||||
clients: GraphRagClients,
|
||||
query: string,
|
||||
edges: Triple[],
|
||||
chunkCallback?: ChunkCallback,
|
||||
): Promise<string> {
|
||||
const context = edges
|
||||
.map((triple) => `${termToString(triple.s)} -> ${termToString(triple.p)} -> ${termToString(triple.o)}`)
|
||||
.join("\n");
|
||||
|
||||
const promptResp = await clients.prompt.request({
|
||||
name: "graph-rag-synthesize",
|
||||
variables: { query, context },
|
||||
});
|
||||
|
||||
if (chunkCallback !== undefined) {
|
||||
let fullText = "";
|
||||
await clients.llm.request(
|
||||
{
|
||||
system: promptResp.system,
|
||||
prompt: promptResp.prompt,
|
||||
streaming: true,
|
||||
},
|
||||
{
|
||||
recipient: async (resp) => {
|
||||
if (resp.response.length > 0) {
|
||||
fullText += resp.response;
|
||||
await chunkCallback(resp.response, resp.endOfStream === true);
|
||||
}
|
||||
return resp.endOfStream === true;
|
||||
},
|
||||
},
|
||||
);
|
||||
return fullText;
|
||||
}
|
||||
|
||||
const resp = await clients.llm.request({
|
||||
system: promptResp.system,
|
||||
prompt: promptResp.prompt,
|
||||
});
|
||||
|
||||
return resp.response;
|
||||
}
|
||||
|
||||
const ScoredEdge = S.Struct({
|
||||
id: S.String,
|
||||
score: S.Number,
|
||||
});
|
||||
const ScoredEdgesFromJson = S.Array(ScoredEdge).pipe(S.fromJsonString);
|
||||
const ScoredEdgeFromJson = ScoredEdge.pipe(S.fromJsonString);
|
||||
const decodeScoredEdges = S.decodeUnknownOption(ScoredEdgesFromJson);
|
||||
const decodeScoredEdge = S.decodeUnknownOption(ScoredEdgeFromJson);
|
||||
|
||||
function parseScoredEdges(responseText: string): Array<typeof ScoredEdge.Type> {
|
||||
const parsedArray = decodeScoredEdges(responseText);
|
||||
if (O.isSome(parsedArray)) {
|
||||
return Array.from(parsedArray.value);
|
||||
}
|
||||
|
||||
const scored: Array<typeof ScoredEdge.Type> = [];
|
||||
for (const line of responseText.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0) continue;
|
||||
const parsedLine = decodeScoredEdge(trimmed);
|
||||
if (O.isSome(parsedLine)) {
|
||||
scored.push(parsedLine.value);
|
||||
}
|
||||
}
|
||||
return scored;
|
||||
}
|
||||
|
||||
export function termToString(term: Term): string {
|
||||
switch (term.type) {
|
||||
case "IRI":
|
||||
return term.iri;
|
||||
|
|
@ -373,7 +422,7 @@ function termToString(term: Term): string {
|
|||
}
|
||||
}
|
||||
|
||||
function stringToTerm(value: string): Term {
|
||||
export function stringToTerm(value: string): Term {
|
||||
if (value.startsWith("http://") || value.startsWith("https://")) {
|
||||
return { type: "IRI", iri: value };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,83 +15,112 @@ import {
|
|||
RequestResponseSpec,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type MessagingTimeoutError,
|
||||
type EntityContexts,
|
||||
type EmbeddingsRequest,
|
||||
type EmbeddingsResponse,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { QdrantGraphEmbeddingsStore } from "./qdrant-graph.js";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
QdrantGraphEmbeddingsStoreLive,
|
||||
QdrantGraphEmbeddingsStoreService,
|
||||
makeQdrantGraphEmbeddingsStoreService,
|
||||
type QdrantGraphEmbeddingsConfig,
|
||||
type QdrantGraphEmbeddingsStoreError,
|
||||
} from "./qdrant-graph.js";
|
||||
|
||||
export class GraphEmbeddingsStoreService extends FlowProcessor {
|
||||
private store: QdrantGraphEmbeddingsStore;
|
||||
type GraphEmbeddingsStoreRequirements = QdrantGraphEmbeddingsStoreService;
|
||||
type GraphEmbeddingsStoreError =
|
||||
| FlowResourceNotFoundError
|
||||
| MessagingDeliveryError
|
||||
| MessagingTimeoutError
|
||||
| QdrantGraphEmbeddingsStoreError;
|
||||
|
||||
const onGraphEmbeddingsStoreMessage = Effect.fn("GraphEmbeddingsStoreService.onMessage")(function* (
|
||||
msg: EntityContexts,
|
||||
_properties: Record<string, string>,
|
||||
flowCtx: FlowContext<GraphEmbeddingsStoreRequirements>,
|
||||
): Effect.fn.Return<void, GraphEmbeddingsStoreError, GraphEmbeddingsStoreRequirements> {
|
||||
if (msg.entities.length === 0) return;
|
||||
|
||||
const embeddingsClient =
|
||||
yield* flowCtx.flow.requestorEffect<EmbeddingsRequest, EmbeddingsResponse>("embeddings-client");
|
||||
|
||||
const user = msg.metadata?.user ?? "default";
|
||||
const collection = msg.metadata?.collection ?? "default";
|
||||
const texts = msg.entities.map((entity) => entity.context);
|
||||
|
||||
const embResponse = yield* embeddingsClient.request({ text: texts });
|
||||
if (embResponse.error !== undefined) {
|
||||
yield* Effect.logError("[GraphEmbeddingsStore] Embeddings error", {
|
||||
error: embResponse.error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const entities = msg.entities.map((entity, index) => ({
|
||||
entity: entity.entity,
|
||||
vector: embResponse.vectors[index],
|
||||
chunkId: entity.chunkId,
|
||||
}));
|
||||
const store = yield* QdrantGraphEmbeddingsStoreService;
|
||||
|
||||
yield* store.store({ user, collection, entities });
|
||||
|
||||
yield* Effect.log(
|
||||
`[GraphEmbeddingsStore] Stored ${entities.length} embeddings for ${user}/${collection}`,
|
||||
);
|
||||
});
|
||||
|
||||
export const makeGraphEmbeddingsStoreSpecs = (): ReadonlyArray<Spec<GraphEmbeddingsStoreRequirements>> => [
|
||||
new ConsumerSpec<EntityContexts, GraphEmbeddingsStoreError, GraphEmbeddingsStoreRequirements>(
|
||||
"store-graph-embeddings-input",
|
||||
onGraphEmbeddingsStoreMessage,
|
||||
),
|
||||
new RequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
|
||||
"embeddings-client",
|
||||
"embeddings-request",
|
||||
"embeddings-response",
|
||||
),
|
||||
];
|
||||
|
||||
export class GraphEmbeddingsStoreService extends FlowProcessor<GraphEmbeddingsStoreRequirements> {
|
||||
private readonly store = makeQdrantGraphEmbeddingsStoreService();
|
||||
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
this.store = new QdrantGraphEmbeddingsStore();
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<EntityContexts>(
|
||||
"store-graph-embeddings-input",
|
||||
this.onMessage.bind(this),
|
||||
),
|
||||
);
|
||||
this.registerSpecification(
|
||||
new RequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
|
||||
"embeddings-client",
|
||||
"embeddings-request",
|
||||
"embeddings-response",
|
||||
),
|
||||
);
|
||||
for (const spec of makeGraphEmbeddingsStoreSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
console.log("[GraphEmbeddingsStore] Service initialized");
|
||||
}
|
||||
|
||||
private async onMessage(
|
||||
msg: EntityContexts,
|
||||
_properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
if (msg.entities.length === 0) return;
|
||||
|
||||
const embeddingsClient =
|
||||
flowCtx.flow.requestor<EmbeddingsRequest, EmbeddingsResponse>("embeddings-client");
|
||||
|
||||
const user = msg.metadata?.user ?? "default";
|
||||
const collection = msg.metadata?.collection ?? "default";
|
||||
|
||||
// Get text contexts for vectorization
|
||||
const texts = msg.entities.map((e) => e.context);
|
||||
|
||||
// Call embeddings service
|
||||
const embResponse = await embeddingsClient.request({ text: texts });
|
||||
if (embResponse.error !== undefined) {
|
||||
console.error(
|
||||
"[GraphEmbeddingsStore] Embeddings error:",
|
||||
embResponse.error.message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store entity+vector pairs
|
||||
const entities = msg.entities.map((e, i) => ({
|
||||
entity: e.entity,
|
||||
vector: embResponse.vectors[i],
|
||||
chunkId: e.chunkId,
|
||||
}));
|
||||
|
||||
await this.store.store({ user, collection, entities });
|
||||
|
||||
console.log(
|
||||
`[GraphEmbeddingsStore] Stored ${entities.length} embeddings for ${user}/${collection}`,
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(
|
||||
QdrantGraphEmbeddingsStoreService,
|
||||
QdrantGraphEmbeddingsStoreService.of(this.store),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<
|
||||
ProcessorConfig & QdrantGraphEmbeddingsConfig,
|
||||
never,
|
||||
GraphEmbeddingsStoreRequirements
|
||||
>({
|
||||
id: "graph-embeddings-store",
|
||||
make: (config) => new GraphEmbeddingsStoreService(config),
|
||||
specs: () => makeGraphEmbeddingsStoreSpecs(),
|
||||
layer: (config) => QdrantGraphEmbeddingsStoreLive(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await GraphEmbeddingsStoreService.launch("graph-embeddings-store");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@
|
|||
*/
|
||||
|
||||
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||
import type { Term } from "@trustgraph/base";
|
||||
import { errorMessage, type Term } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface QdrantGraphEmbeddingsConfig {
|
||||
url?: string;
|
||||
|
|
@ -127,3 +129,67 @@ export class QdrantGraphEmbeddingsStore {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class QdrantGraphEmbeddingsStoreError extends S.TaggedErrorClass<QdrantGraphEmbeddingsStoreError>()(
|
||||
"QdrantGraphEmbeddingsStoreError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
cause: S.DefectWithStack,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface QdrantGraphEmbeddingsStoreServiceShape {
|
||||
readonly store: (
|
||||
message: GraphEmbeddingsMessage,
|
||||
) => Effect.Effect<void, QdrantGraphEmbeddingsStoreError>;
|
||||
readonly deleteCollection: (
|
||||
user: string,
|
||||
collection: string,
|
||||
) => Effect.Effect<void, QdrantGraphEmbeddingsStoreError>;
|
||||
}
|
||||
|
||||
export class QdrantGraphEmbeddingsStoreService extends Context.Service<
|
||||
QdrantGraphEmbeddingsStoreService,
|
||||
QdrantGraphEmbeddingsStoreServiceShape
|
||||
>()(
|
||||
"@trustgraph/flow/storage/embeddings/qdrant-graph/QdrantGraphEmbeddingsStoreService",
|
||||
) {}
|
||||
|
||||
const qdrantGraphEmbeddingsStoreError = (operation: string, cause: unknown) =>
|
||||
new QdrantGraphEmbeddingsStoreError({
|
||||
operation,
|
||||
message: errorMessage(cause),
|
||||
cause,
|
||||
});
|
||||
|
||||
export const makeQdrantGraphEmbeddingsStoreService = (
|
||||
config: QdrantGraphEmbeddingsConfig = {},
|
||||
): QdrantGraphEmbeddingsStoreServiceShape => {
|
||||
const store = new QdrantGraphEmbeddingsStore(config);
|
||||
return {
|
||||
store: Effect.fn("QdrantGraphEmbeddingsStore.store")(function* (message) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => store.store(message),
|
||||
catch: (cause) => qdrantGraphEmbeddingsStoreError("store", cause),
|
||||
});
|
||||
}),
|
||||
deleteCollection: Effect.fn("QdrantGraphEmbeddingsStore.deleteCollection")(function* (
|
||||
user,
|
||||
collection,
|
||||
) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => store.deleteCollection(user, collection),
|
||||
catch: (cause) => qdrantGraphEmbeddingsStoreError("delete-collection", cause),
|
||||
});
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const QdrantGraphEmbeddingsStoreLive = (
|
||||
config: QdrantGraphEmbeddingsConfig = {},
|
||||
): Layer.Layer<QdrantGraphEmbeddingsStoreService> =>
|
||||
Layer.succeed(
|
||||
QdrantGraphEmbeddingsStoreService,
|
||||
QdrantGraphEmbeddingsStoreService.of(makeQdrantGraphEmbeddingsStoreService(config)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,47 +14,72 @@ import {
|
|||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type Triples,
|
||||
type Spec,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { FalkorDBTriplesStore } from "./falkordb.js";
|
||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
FalkorDBTriplesStoreLive,
|
||||
FalkorDBTriplesStoreService,
|
||||
makeFalkorDBTriplesStoreService,
|
||||
type FalkorDBConfig,
|
||||
type FalkorDBTriplesStoreError,
|
||||
} from "./falkordb.js";
|
||||
|
||||
export class TriplesStoreService extends FlowProcessor {
|
||||
private store: FalkorDBTriplesStore;
|
||||
const onStoreTriplesMessage = Effect.fn("TriplesStoreService.onMessage")(function* (
|
||||
msg: Triples,
|
||||
_properties: Record<string, string>,
|
||||
_flowCtx: FlowContext<FalkorDBTriplesStoreService>,
|
||||
): Effect.fn.Return<void, FalkorDBTriplesStoreError, FalkorDBTriplesStoreService> {
|
||||
if (msg.triples.length === 0) return;
|
||||
|
||||
const user = msg.metadata?.user ?? "default";
|
||||
const collection = msg.metadata?.collection ?? "default";
|
||||
const store = yield* FalkorDBTriplesStoreService;
|
||||
|
||||
yield* store.storeTriples(msg.triples, user, collection);
|
||||
|
||||
yield* Effect.log(
|
||||
`[TriplesStore] Stored ${msg.triples.length} triples for ${user}/${collection}`,
|
||||
);
|
||||
});
|
||||
|
||||
export const makeTriplesStoreSpecs = (): ReadonlyArray<Spec<FalkorDBTriplesStoreService>> => [
|
||||
new ConsumerSpec<Triples, FalkorDBTriplesStoreError, FalkorDBTriplesStoreService>(
|
||||
"store-triples-input",
|
||||
onStoreTriplesMessage,
|
||||
),
|
||||
];
|
||||
|
||||
export class TriplesStoreService extends FlowProcessor<FalkorDBTriplesStoreService> {
|
||||
private readonly store = makeFalkorDBTriplesStoreService();
|
||||
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
this.store = new FalkorDBTriplesStore();
|
||||
|
||||
this.registerSpecification(
|
||||
ConsumerSpec.fromPromise<Triples>("store-triples-input", this.onMessage.bind(this)),
|
||||
);
|
||||
for (const spec of makeTriplesStoreSpecs()) {
|
||||
this.registerSpecification(spec);
|
||||
}
|
||||
|
||||
console.log("[TriplesStore] Service initialized");
|
||||
}
|
||||
|
||||
private async onMessage(
|
||||
msg: Triples,
|
||||
_properties: Record<string, string>,
|
||||
_flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
if (msg.triples.length === 0) return;
|
||||
|
||||
const user = msg.metadata?.user ?? "default";
|
||||
const collection = msg.metadata?.collection ?? "default";
|
||||
|
||||
await this.store.storeTriples(msg.triples, user, collection);
|
||||
|
||||
console.log(
|
||||
`[TriplesStore] Stored ${msg.triples.length} triples for ${user}/${collection}`,
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(
|
||||
FalkorDBTriplesStoreService,
|
||||
FalkorDBTriplesStoreService.of(this.store),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
export const program = makeFlowProcessorProgram<ProcessorConfig & FalkorDBConfig, never, FalkorDBTriplesStoreService>({
|
||||
id: "triples-store",
|
||||
make: (config) => new TriplesStoreService(config),
|
||||
specs: () => makeTriplesStoreSpecs(),
|
||||
layer: (config) => FalkorDBTriplesStoreLive(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await TriplesStoreService.launch("triples-store");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@
|
|||
*/
|
||||
|
||||
import { createClient, Graph } from "falkordb";
|
||||
import type { Term, Triple } from "@trustgraph/base";
|
||||
import { errorMessage, type Term, type Triple } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface FalkorDBConfig {
|
||||
url?: string;
|
||||
|
|
@ -130,3 +132,71 @@ export class FalkorDBTriplesStore {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class FalkorDBTriplesStoreError extends S.TaggedErrorClass<FalkorDBTriplesStoreError>()(
|
||||
"FalkorDBTriplesStoreError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
cause: S.DefectWithStack,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface FalkorDBTriplesStoreServiceShape {
|
||||
readonly storeTriples: (
|
||||
triples: ReadonlyArray<Triple>,
|
||||
user: string,
|
||||
collection: string,
|
||||
) => Effect.Effect<void, FalkorDBTriplesStoreError>;
|
||||
readonly deleteCollection: (
|
||||
user: string,
|
||||
collection: string,
|
||||
) => Effect.Effect<void, FalkorDBTriplesStoreError>;
|
||||
}
|
||||
|
||||
export class FalkorDBTriplesStoreService extends Context.Service<
|
||||
FalkorDBTriplesStoreService,
|
||||
FalkorDBTriplesStoreServiceShape
|
||||
>()(
|
||||
"@trustgraph/flow/storage/triples/falkordb/FalkorDBTriplesStoreService",
|
||||
) {}
|
||||
|
||||
const falkorDBTriplesStoreError = (operation: string, cause: unknown) =>
|
||||
new FalkorDBTriplesStoreError({
|
||||
operation,
|
||||
message: errorMessage(cause),
|
||||
cause,
|
||||
});
|
||||
|
||||
export const makeFalkorDBTriplesStoreService = (
|
||||
config: FalkorDBConfig = {},
|
||||
): FalkorDBTriplesStoreServiceShape => {
|
||||
const store = new FalkorDBTriplesStore(config);
|
||||
return {
|
||||
storeTriples: Effect.fn("FalkorDBTriplesStore.storeTriples")((
|
||||
triples: ReadonlyArray<Triple>,
|
||||
user: string,
|
||||
collection: string,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => store.storeTriples(Array.from(triples), user, collection),
|
||||
catch: (cause) => falkorDBTriplesStoreError("store-triples", cause),
|
||||
})),
|
||||
deleteCollection: Effect.fn("FalkorDBTriplesStore.deleteCollection")((
|
||||
user: string,
|
||||
collection: string,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => store.deleteCollection(user, collection),
|
||||
catch: (cause) => falkorDBTriplesStoreError("delete-collection", cause),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const FalkorDBTriplesStoreLive = (
|
||||
config: FalkorDBConfig = {},
|
||||
): Layer.Layer<FalkorDBTriplesStoreService> =>
|
||||
Layer.succeed(
|
||||
FalkorDBTriplesStoreService,
|
||||
FalkorDBTriplesStoreService.of(makeFalkorDBTriplesStoreService(config)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,12 +13,26 @@
|
|||
"dependencies": {
|
||||
"@trustgraph/base": "workspace:*",
|
||||
"@trustgraph/client": "workspace:*",
|
||||
"effect": "4.0.0-beta.65",
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
"@effect/ai-anthropic": "4.0.0-beta.74",
|
||||
"@effect/ai-openai": "4.0.0-beta.74",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.74",
|
||||
"@effect/atom-react": "4.0.0-beta.74",
|
||||
"@effect/openapi-generator": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-browser": "4.0.0-beta.74",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"@effect/tsgo": "0.13.0",
|
||||
"@effect/sql-pg": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-node": "4.0.0-beta.74",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@modelcontextprotocol/sdk": "^1.8.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.1.6"
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export { createMcpServer, run } from "./server.js";
|
||||
export * from "./server-effect.js";
|
||||
|
|
|
|||
1726
ts/packages/mcp/src/server-effect.ts
Normal file
1726
ts/packages/mcp/src/server-effect.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -418,7 +418,7 @@ export function createMcpServer(config: {
|
|||
|
||||
export async function run(): Promise<void> {
|
||||
const { server, socket } = createMcpServer({
|
||||
gatewayUrl: process.env.GATEWAY_URL ?? "ws://localhost:8088/api/v1/socket",
|
||||
gatewayUrl: process.env.GATEWAY_URL ?? "ws://localhost:8088/api/v1/rpc",
|
||||
user: process.env.USER_ID ?? "mcp",
|
||||
flowId: process.env.FLOW_ID ?? "default",
|
||||
...(process.env.GATEWAY_SECRET !== undefined
|
||||
|
|
|
|||
|
|
@ -20,19 +20,8 @@ server {
|
|||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# WebSocket proxy (client connects to /api/socket, gateway listens on /api/v1/socket)
|
||||
location /api/socket {
|
||||
set $upstream_gateway gateway;
|
||||
proxy_pass http://$upstream_gateway:8088/api/v1/socket;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# WebSocket proxy (direct v1 path)
|
||||
location /api/v1/socket {
|
||||
# Effect RPC WebSocket proxy
|
||||
location /api/v1/rpc {
|
||||
set $upstream_gateway gateway;
|
||||
proxy_pass http://$upstream_gateway:8088;
|
||||
proxy_http_version 1.1;
|
||||
|
|
@ -41,4 +30,13 @@ server {
|
|||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# Browser OTLP proxy (client posts /otel/v1/*, collector listens on 4318)
|
||||
location /otel/ {
|
||||
set $upstream_otel otel-collector;
|
||||
rewrite ^/otel/(.*)$ /$1 break;
|
||||
proxy_pass http://$upstream_otel:4318;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"qa:browser": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.75.0",
|
||||
|
|
@ -19,10 +20,26 @@
|
|||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.6.0",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"zustand": "^5.0.0"
|
||||
"zustand": "^5.0.0",
|
||||
"@effect/platform-node": "4.0.0-beta.74",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
"@effect/ai-anthropic": "4.0.0-beta.74",
|
||||
"@effect/ai-openai": "4.0.0-beta.74",
|
||||
"@effect/ai-openrouter": "4.0.0-beta.74",
|
||||
"@effect/atom-react": "4.0.0-beta.74",
|
||||
"@effect/openapi-generator": "4.0.0-beta.74",
|
||||
"@effect/opentelemetry": "4.0.0-beta.74",
|
||||
"@effect/platform-browser": "4.0.0-beta.74",
|
||||
"@effect/platform-bun": "4.0.0-beta.74",
|
||||
"@effect/tsgo": "0.13.0",
|
||||
"@effect/sql-pg": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@effect/sql-sqlite-node": "4.0.0-beta.74",
|
||||
"@effect/vitest": "4.0.0-beta.74"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@effect/vitest": "4.0.0-beta.74",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
|
|
|
|||
41
ts/packages/workbench/playwright.config.ts
Normal file
41
ts/packages/workbench/playwright.config.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const port = Number(process.env.WORKBENCH_QA_PORT ?? 5174);
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/workbench-qa",
|
||||
outputDir: "../../.playwright/workbench/test-results",
|
||||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 3 : undefined,
|
||||
reporter: [["list"], ["html", { outputFolder: "../../.playwright/workbench/report", open: "never" }]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
webServer: {
|
||||
command: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`,
|
||||
cwd: ".",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "desktop",
|
||||
use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } },
|
||||
},
|
||||
{
|
||||
name: "tablet",
|
||||
use: { ...devices["iPad (gen 7)"], browserName: "chromium" },
|
||||
},
|
||||
{
|
||||
name: "mobile",
|
||||
use: { ...devices["Pixel 5"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
1673
ts/packages/workbench/src/atoms/workbench.ts
Normal file
1673
ts/packages/workbench/src/atoms/workbench.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,220 +1,90 @@
|
|||
import { lazy, Suspense } from "react";
|
||||
import { useAtom, useAtomValue } from "@effect/atom-react";
|
||||
import { Network, ChevronRight, ChevronDown, Loader2 } from "lucide-react";
|
||||
import * as Atom from "effect/unstable/reactivity/Atom";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Network,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Maximize,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
explainTriplesAtom,
|
||||
flowIdAtom,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
} from "@/atoms/workbench";
|
||||
import {
|
||||
triplesToGraph,
|
||||
localName,
|
||||
hashColor,
|
||||
type GraphNode,
|
||||
type GraphLink,
|
||||
} from "@/lib/graph-utils";
|
||||
import type { ExplainEvent, Triple } from "@trustgraph/client";
|
||||
import type { ForceGraphMethods, ForceGraphProps } from "react-force-graph-2d";
|
||||
import type { ExplainEvent } from "@trustgraph/client";
|
||||
import type { ForceGraphProps } from "react-force-graph-2d";
|
||||
import type * as React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy-load ForceGraph2D (shares the same chunk as the graph page)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<any, any> & { ref?: React.Ref<any> }>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<GraphNode, GraphLink>>;
|
||||
const explainExpandedAtom = Atom.make<Record<string, boolean>>({}).pipe(Atom.keepAlive);
|
||||
|
||||
interface ExplainGraphProps {
|
||||
explainEvents: ExplainEvent[];
|
||||
collection: string;
|
||||
}
|
||||
|
||||
function paintNode(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) {
|
||||
const radius = Math.max(2.5, Math.sqrt(node.degree + 1) * 2);
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = node.color ?? "#5b80ff";
|
||||
ctx.fill();
|
||||
const fontSize = Math.max(9 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const isLight = document.documentElement.classList.contains("light");
|
||||
ctx.fillStyle = isLight ? "rgba(24,24,27,0.85)" : "rgba(250,250,250,0.85)";
|
||||
ctx.fillText(node.label, x, y + radius + 1);
|
||||
}
|
||||
|
||||
function paintLink(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) {
|
||||
if (globalScale < 1.5) return;
|
||||
const source = link.source as unknown as GraphNode;
|
||||
const target = link.target as unknown as GraphNode;
|
||||
if (source.x === undefined || source.y === undefined || target.x === undefined || target.y === undefined) return;
|
||||
const midX = (source.x + target.x) / 2;
|
||||
const midY = (source.y + target.y) / 2;
|
||||
const fontSize = Math.max(7 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(161,161,170,0.6)";
|
||||
ctx.fillText(link.label, midX, midY);
|
||||
}
|
||||
|
||||
export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) {
|
||||
const socket = useSocket();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [triples, setTriples] = useState<Triple[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
|
||||
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
// Track container width for the force graph
|
||||
useEffect(() => {
|
||||
if (!expanded || containerRef.current === null) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry !== undefined) setContainerWidth(Math.floor(entry.contentRect.width));
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
return () => ro.disconnect();
|
||||
}, [expanded]);
|
||||
|
||||
// Load triples when first expanded — use inline triples if available, otherwise fetch
|
||||
useEffect(() => {
|
||||
if (!expanded || fetched) return;
|
||||
setFetched(true);
|
||||
|
||||
// Check if any explain events have inline triples
|
||||
const inlineTriples = explainEvents.flatMap((ev) => ev.explainTriples ?? []);
|
||||
if (inlineTriples.length > 0) {
|
||||
setTriples(inlineTriples);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to fetching from named graph
|
||||
const graphUris = explainEvents.filter(
|
||||
(ev): ev is ExplainEvent & { explainGraph: string } =>
|
||||
ev.explainGraph !== undefined && ev.explainGraph.length > 0,
|
||||
);
|
||||
if (graphUris.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const flow = socket.flow(flowId);
|
||||
|
||||
Promise.all(
|
||||
graphUris.map((ev) =>
|
||||
flow
|
||||
.triplesQuery(undefined, undefined, undefined, 500, collection, ev.explainGraph)
|
||||
.catch(() => [] as Triple[]),
|
||||
),
|
||||
)
|
||||
.then((results) => {
|
||||
setTriples(results.flat());
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [expanded, fetched, explainEvents, socket, flowId, collection]);
|
||||
|
||||
// Build graph data
|
||||
const { data: graphData, typeMap } = useMemo(
|
||||
() => triplesToGraph(triples),
|
||||
[triples],
|
||||
);
|
||||
|
||||
// Auto-fit once data loads
|
||||
const hasAutoFit = useRef(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
graphData.nodes.length > 0 &&
|
||||
fgRef.current !== undefined &&
|
||||
hasAutoFit.current === false
|
||||
) {
|
||||
hasAutoFit.current = true;
|
||||
const timer = setTimeout(() => fgRef.current?.zoomToFit(400, 20), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [graphData.nodes.length]);
|
||||
|
||||
// Node painting (simplified version of graph page)
|
||||
const paintNode = useCallback(
|
||||
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const radius = Math.max(2.5, Math.sqrt(node.degree + 1) * 2);
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = node.color ?? "#5b80ff";
|
||||
ctx.fill();
|
||||
|
||||
const fontSize = Math.max(9 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const isLight = document.documentElement.classList.contains("light");
|
||||
ctx.fillStyle = isLight
|
||||
? "rgba(24,24,27,0.85)"
|
||||
: "rgba(250,250,250,0.85)";
|
||||
ctx.fillText(node.label, x, y + radius + 1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Link label painting
|
||||
const paintLink = useCallback(
|
||||
(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
if (globalScale < 1.5) return;
|
||||
const src = link.source as unknown as GraphNode;
|
||||
const tgt = link.target as unknown as GraphNode;
|
||||
if (
|
||||
src.x === undefined ||
|
||||
src.y === undefined ||
|
||||
tgt.x === undefined ||
|
||||
tgt.y === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const midX = ((src.x ?? 0) + (tgt.x ?? 0)) / 2;
|
||||
const midY = ((src.y ?? 0) + (tgt.y ?? 0)) / 2;
|
||||
|
||||
const fontSize = Math.max(7 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(161,161,170,0.6)";
|
||||
ctx.fillText(link.label, midX, midY);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Compute unique types for mini legend
|
||||
const uniqueTypes = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const [, typeUri] of typeMap) {
|
||||
const name = localName(typeUri);
|
||||
if (!seen.has(name)) {
|
||||
seen.set(name, typeUri);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.entries());
|
||||
}, [typeMap]);
|
||||
const flowId = useAtomValue(flowIdAtom);
|
||||
const [expandedMap, setExpandedMap] = useAtom(explainExpandedAtom);
|
||||
const key = `${flowId}:${collection}:${explainEvents.map((event) => event.explainGraph ?? event.explainId).join("|")}`;
|
||||
const expanded = expandedMap[key];
|
||||
const result = useAtomValue(explainTriplesAtom({ events: explainEvents, flowId, collection }));
|
||||
const triples = resultData(result, []);
|
||||
const loading = expanded && resultLoading(result, triples);
|
||||
const error = resultError(result);
|
||||
const { data: graphData, typeMap } = triplesToGraph(triples);
|
||||
const uniqueTypes = Array.from(new Set(Array.from(typeMap.values()).map(localName))).sort();
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-md border border-border/50">
|
||||
{/* Toggle header */}
|
||||
<button
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
onClick={() => setExpandedMap({ ...expandedMap, [key]: !expanded })}
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-fg-muted hover:bg-surface-100/50"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
{expanded ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
<Network className="h-3 w-3 shrink-0 text-brand-400" />
|
||||
<span>View source graph</span>
|
||||
<Badge variant="info">{explainEvents.length} subgraph{explainEvents.length > 1 ? "s" : ""}</Badge>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="border-t border-border/50">
|
||||
{loading && (
|
||||
|
|
@ -223,100 +93,32 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) {
|
|||
Loading source graph...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error !== null && (
|
||||
<p className="px-3 py-3 text-xs text-error">
|
||||
Failed to load graph: {error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error !== null && <p className="px-3 py-3 text-xs text-error">Failed to load graph: {error}</p>}
|
||||
{!loading && error === null && graphData.nodes.length === 0 && (
|
||||
<p className="px-3 py-4 text-center text-xs text-fg-subtle">
|
||||
No graph data available for this query.
|
||||
</p>
|
||||
<p className="px-3 py-4 text-center text-xs text-fg-subtle">No graph data available for this query.</p>
|
||||
)}
|
||||
|
||||
{!loading && graphData.nodes.length > 0 && (
|
||||
<>
|
||||
{/* Graph info bar */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 text-[10px] text-fg-subtle">
|
||||
<span>
|
||||
{graphData.nodes.length} nodes, {graphData.links.length} edges
|
||||
</span>
|
||||
<button
|
||||
onClick={() => fgRef.current?.zoomToFit(400, 20)}
|
||||
className="rounded p-1 hover:bg-surface-200 hover:text-fg"
|
||||
title="Fit to view"
|
||||
aria-label="Fit to view"
|
||||
>
|
||||
<Maximize className="h-3 w-3" />
|
||||
</button>
|
||||
<span>{graphData.nodes.length} nodes, {graphData.links.length} edges</span>
|
||||
<div className="flex gap-1">
|
||||
{uniqueTypes.slice(0, 4).map((type) => <Badge key={type} variant="info">{type}</Badge>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini graph canvas */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative bg-surface-0"
|
||||
style={{ height: 280 }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-fg-subtle" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="relative h-[280px] overflow-hidden bg-surface-0">
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center text-xs text-fg-subtle">Loading graph...</div>}>
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
graphData={graphData}
|
||||
width={600}
|
||||
height={280}
|
||||
backgroundColor="rgba(0,0,0,0)"
|
||||
nodeCanvasObject={paintNode}
|
||||
nodePointerAreaPaint={(node: GraphNode, color, ctx) => {
|
||||
const radius = Math.max(
|
||||
2.5,
|
||||
Math.sqrt(node.degree + 1) * 2,
|
||||
);
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
node.x ?? 0,
|
||||
node.y ?? 0,
|
||||
radius + 2,
|
||||
0,
|
||||
2 * Math.PI,
|
||||
);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}}
|
||||
linkCanvasObjectMode={() => "after"}
|
||||
linkCanvasObject={paintLink}
|
||||
linkColor={() => "rgba(91,128,255,0.25)"}
|
||||
linkDirectionalArrowLength={3}
|
||||
linkDirectionalArrowRelPos={0.85}
|
||||
backgroundColor="transparent"
|
||||
{...(containerWidth > 0 ? { width: containerWidth } : {})}
|
||||
height={280}
|
||||
linkColor={() => "rgba(120,120,140,0.32)"}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Mini type legend */}
|
||||
{uniqueTypes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 border-t border-border/50 px-3 py-2">
|
||||
{uniqueTypes.slice(0, 8).map(([name]) => (
|
||||
<div key={name} className="flex items-center gap-1.5 text-[10px] text-fg-subtle">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: hashColor(name) }}
|
||||
/>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
))}
|
||||
{uniqueTypes.length > 8 && (
|
||||
<span className="text-[10px] text-fg-subtle">
|
||||
+{uniqueTypes.length - 8} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import { Copy, Check, Trash2, RotateCcw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copiedMessageIdAtom, copyMessageAtom } from "@/atoms/workbench";
|
||||
|
||||
interface MessageActionsProps {
|
||||
content: string;
|
||||
messageId: string;
|
||||
isLastAssistant: boolean;
|
||||
onDelete: () => void;
|
||||
onRegenerate?: () => void;
|
||||
|
|
@ -11,38 +13,25 @@ interface MessageActionsProps {
|
|||
|
||||
export function MessageActions({
|
||||
content,
|
||||
messageId,
|
||||
isLastAssistant,
|
||||
onDelete,
|
||||
onRegenerate,
|
||||
}: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copiedMessageId = useAtomValue(copiedMessageIdAtom);
|
||||
const copyMessage = useAtomSet(copyMessageAtom);
|
||||
const copied = copiedMessageId === messageId;
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback for insecure contexts
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = content;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, [content]);
|
||||
const handleCopy = () => {
|
||||
copyMessage({ id: messageId, content });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-8 right-2 z-10 flex items-center gap-0.5",
|
||||
"mt-1 flex w-fit items-center gap-0.5 lg:absolute lg:-top-8 lg:right-2 lg:z-10 lg:mt-0",
|
||||
"rounded-lg border border-border bg-surface-200 px-1 py-0.5 shadow-sm",
|
||||
"pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100",
|
||||
"opacity-100 transition-opacity lg:pointer-events-none lg:opacity-0 lg:group-hover:pointer-events-auto lg:group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { useAtomValue } from "@effect/atom-react";
|
||||
import { Workflow, Database } from "lucide-react";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { flowIdAtom, settingsAtom } from "@/atoms/workbench";
|
||||
|
||||
/**
|
||||
* Compact badge showing the active flow and collection.
|
||||
* Will be expanded later into a popover picker.
|
||||
*/
|
||||
export function FlowSelector() {
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const flowId = useAtomValue(flowIdAtom);
|
||||
const collection = useAtomValue(settingsAtom).collection;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg-muted">
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { useAtomValue } from "@effect/atom-react";
|
||||
import { Outlet } from "react-router";
|
||||
import { WifiOff } from "lucide-react";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { FlowSelector } from "./flow-selector";
|
||||
import { GlowBackground } from "./glow-background";
|
||||
import { useProgressStore } from "@/hooks/use-progress-store";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { connectionStateAtom, isLoadingAtom } from "@/atoms/workbench";
|
||||
|
||||
/**
|
||||
* Top loading bar -- shown when any global activity is in progress.
|
||||
*/
|
||||
function LoadingBar() {
|
||||
const isLoading = useProgressStore((s) => s.isLoading);
|
||||
const isLoading = useAtomValue(isLoadingAtom);
|
||||
|
||||
if (!isLoading) return null;
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ function LoadingBar() {
|
|||
* Root layout: fixed sidebar + scrollable main content area with a top bar.
|
||||
*/
|
||||
export function RootLayout() {
|
||||
const connectionState = useConnectionState();
|
||||
const connectionState = useAtomValue(connectionStateAtom);
|
||||
const isDisconnected =
|
||||
connectionState.status === "failed" ||
|
||||
connectionState.status === "reconnecting";
|
||||
|
|
@ -50,7 +50,7 @@ export function RootLayout() {
|
|||
<GlowBackground />
|
||||
|
||||
{/* Top bar */}
|
||||
<header className="relative z-10 flex h-14 shrink-0 items-center justify-end border-b border-border bg-surface-50/80 backdrop-blur-sm px-6">
|
||||
<header className="relative z-10 flex h-14 shrink-0 items-center justify-end border-b border-border bg-surface-50/80 px-3 backdrop-blur-sm sm:px-6">
|
||||
<FlowSelector />
|
||||
</header>
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ export function RootLayout() {
|
|||
)}
|
||||
|
||||
{/* Page content */}
|
||||
<main id="main-content" className="relative z-10 flex-1 overflow-y-auto p-6">
|
||||
<main id="main-content" className="relative z-10 flex-1 overflow-y-auto p-3 sm:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useAtom, useAtomValue } from "@effect/atom-react";
|
||||
import { NavLink } from "react-router";
|
||||
import {
|
||||
MessageSquareText,
|
||||
|
|
@ -16,10 +17,13 @@ import {
|
|||
} from "lucide-react";
|
||||
import { BeepGraphLogo } from "./beep-graph-logo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useFlows } from "@/hooks/use-flows";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import {
|
||||
connectionStateAtom,
|
||||
flowIdAtom,
|
||||
flowsAtom,
|
||||
resultData,
|
||||
settingsAtom,
|
||||
} from "@/atoms/workbench";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nav item
|
||||
|
|
@ -33,18 +37,23 @@ interface NavItemProps {
|
|||
|
||||
function NavItem({ to, icon: Icon, label }: NavItemProps) {
|
||||
return (
|
||||
<NavLink to={to} className="w-full rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1 focus-visible:ring-offset-surface-50">
|
||||
<NavLink
|
||||
to={to}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="w-full rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-1 focus-visible:ring-offset-surface-50"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
"flex items-center justify-center rounded-lg px-2 py-2 text-sm font-medium transition-colors sm:justify-start sm:gap-3 sm:px-3",
|
||||
isActive
|
||||
? "bg-brand-600/20 text-brand-400"
|
||||
: "text-fg-muted hover:bg-surface-200 hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
<span className="hidden truncate sm:inline">{label}</span>
|
||||
</div>
|
||||
)}
|
||||
</NavLink>
|
||||
|
|
@ -56,7 +65,7 @@ function NavItem({ to, icon: Icon, label }: NavItemProps) {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ConnectionBadge() {
|
||||
const state = useConnectionState();
|
||||
const state = useAtomValue(connectionStateAtom);
|
||||
|
||||
const isConnected =
|
||||
state.status === "connected" ||
|
||||
|
|
@ -103,10 +112,9 @@ function ConnectionBadge() {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FlowSelectorDropdown() {
|
||||
const { flows } = useFlows();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const setFlowId = useSessionStore((s) => s.setFlowId);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const flows = resultData(useAtomValue(flowsAtom), []);
|
||||
const [flowId, setFlowId] = useAtom(flowIdAtom);
|
||||
const collection = useAtomValue(settingsAtom).collection;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-3">
|
||||
|
|
@ -148,26 +156,26 @@ function FlowSelectorDropdown() {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function Sidebar() {
|
||||
const { featureSwitches } = useSettings((s) => s.settings);
|
||||
const { featureSwitches } = useAtomValue(settingsAtom);
|
||||
|
||||
return (
|
||||
<aside aria-label="Sidebar" className="flex h-screen w-sidebar shrink-0 flex-col border-r border-border bg-surface-50">
|
||||
<aside aria-label="Sidebar" className="flex h-screen w-sidebar-collapsed shrink-0 flex-col border-r border-border bg-surface-50 sm:w-sidebar">
|
||||
{/* Logo area */}
|
||||
<div className="flex h-14 items-center gap-2.5 px-4">
|
||||
<div className="flex h-14 items-center justify-center gap-2.5 px-2 sm:justify-start sm:px-4">
|
||||
<BeepGraphLogo className="h-7 w-7 shrink-0 text-brand-400" />
|
||||
<span className="text-lg font-bold text-fg">Beep Graph</span>
|
||||
<span className="hidden text-lg font-bold text-fg sm:inline">Beep Graph</span>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-3 border-t border-border" />
|
||||
|
||||
{/* Flow & collection selectors */}
|
||||
<div className="py-3">
|
||||
<div className="hidden py-3 sm:block">
|
||||
<FlowSelectorDropdown />
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-3 border-t border-border" />
|
||||
<div className="hidden mx-3 border-t border-border sm:block" />
|
||||
|
||||
{/* Navigation links */}
|
||||
<nav aria-label="Main navigation" className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2 py-3">
|
||||
|
|
@ -185,7 +193,7 @@ export function Sidebar() {
|
|||
</nav>
|
||||
|
||||
{/* Footer: connection badge */}
|
||||
<div className="border-t border-border px-2 py-2">
|
||||
<div className="hidden border-t border-border px-2 py-2 sm:block">
|
||||
<ConnectionBadge />
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNotification, type NotificationType } from "@/providers/notification-provider";
|
||||
import { notificationsAtom, removeNotificationAtom, type Notification } from "@/atoms/workbench";
|
||||
|
||||
const typeStyles: Record<NotificationType, string> = {
|
||||
const typeStyles: Record<Notification["type"], string> = {
|
||||
success: "border-success/40 bg-success/10 text-success",
|
||||
error: "border-error/40 bg-error/10 text-error",
|
||||
warning: "border-warning/40 bg-warning/10 text-warning",
|
||||
|
|
@ -13,8 +14,8 @@ const typeStyles: Record<NotificationType, string> = {
|
|||
* Renders the active notification stack in the bottom-right corner.
|
||||
*/
|
||||
export function NotificationToasts() {
|
||||
const notifications = useNotification((s) => s.notifications);
|
||||
const removeNotification = useNotification((s) => s.removeNotification);
|
||||
const notifications = useAtomValue(notificationsAtom);
|
||||
const removeNotification = useAtomSet(removeNotificationAtom);
|
||||
|
||||
if (notifications.length === 0) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,4 @@
|
|||
import {
|
||||
type ReactNode,
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
} from "react";
|
||||
import type { KeyboardEvent, MouseEvent, ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -19,11 +12,6 @@ interface DialogProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple modal dialog built with Tailwind.
|
||||
* Renders a backdrop overlay + centered content panel.
|
||||
* Includes focus trap, auto-focus, and Escape to close.
|
||||
*/
|
||||
export function Dialog({
|
||||
open,
|
||||
onClose,
|
||||
|
|
@ -32,103 +20,24 @@ export function Dialog({
|
|||
footer,
|
||||
className,
|
||||
}: DialogProps) {
|
||||
const titleId = useId();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Save the element that triggered the dialog so we can restore focus on close
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
triggerRef.current = document.activeElement as HTMLElement | null;
|
||||
} else if (triggerRef.current !== null) {
|
||||
triggerRef.current.focus();
|
||||
triggerRef.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Auto-focus first focusable element when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open || dialogRef.current === null) return;
|
||||
const focusable = Array.from(
|
||||
dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter(
|
||||
(el) =>
|
||||
el.hidden === false &&
|
||||
!(el as HTMLButtonElement).disabled &&
|
||||
el.offsetParent !== null &&
|
||||
window.getComputedStyle(el).display !== "none",
|
||||
);
|
||||
// Focus the first input/textarea if available, otherwise the close button
|
||||
const firstInput = focusable.find(
|
||||
(el) => el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT",
|
||||
);
|
||||
(firstInput ?? focusable[0])?.focus();
|
||||
}, [open]);
|
||||
|
||||
// Focus trap — keep Tab within the dialog
|
||||
useEffect(() => {
|
||||
if (!open || dialogRef.current === null) return;
|
||||
const dialog = dialogRef.current;
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
const focusable = Array.from(
|
||||
dialog.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter(
|
||||
(el) =>
|
||||
el.hidden === false &&
|
||||
!(el as HTMLButtonElement).disabled &&
|
||||
el.offsetParent !== null &&
|
||||
window.getComputedStyle(el).display !== "none",
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleTab);
|
||||
return () => window.removeEventListener("keydown", handleTab);
|
||||
}, [open]);
|
||||
|
||||
const handleBackdrop = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const titleId = `dialog-title-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||
const handleBackdrop = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleBackdrop}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useRef, useEffect, type TextareaHTMLAttributes } from "react";
|
||||
import type { CSSProperties, TextareaHTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AutoTextareaProps
|
||||
|
|
@ -14,31 +14,21 @@ export function AutoTextarea({
|
|||
maxRows = 6,
|
||||
className,
|
||||
value,
|
||||
style,
|
||||
...props
|
||||
}: AutoTextareaProps) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el === null) return;
|
||||
|
||||
// Reset height so scrollHeight is recalculated
|
||||
el.style.height = "auto";
|
||||
|
||||
// Compute line height from computed styles
|
||||
const style = window.getComputedStyle(el);
|
||||
const lineHeight = parseFloat(style.lineHeight) || 20;
|
||||
const maxHeight = lineHeight * maxRows;
|
||||
|
||||
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
|
||||
}, [value, maxRows]);
|
||||
const textareaStyle: CSSProperties & { fieldSizing?: "content" } = {
|
||||
...style,
|
||||
fieldSizing: "content",
|
||||
maxHeight: `calc(${maxRows}lh + 1.5rem)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
style={textareaStyle}
|
||||
className={cn(
|
||||
"w-full resize-none rounded-lg border border-border bg-surface-100 px-4 py-3 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500",
|
||||
"w-full resize-none overflow-y-auto rounded-lg border border-border bg-surface-100 px-4 py-3 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500",
|
||||
className,
|
||||
)}
|
||||
rows={1}
|
||||
|
|
|
|||
|
|
@ -1,284 +0,0 @@
|
|||
import { useCallback, useRef } from "react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import {
|
||||
useConversation,
|
||||
nextMessageId,
|
||||
type ChatMessage,
|
||||
} from "./use-conversation";
|
||||
import { useSessionStore } from "./use-session-store";
|
||||
import { useProgressStore } from "./use-progress-store";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import type { StreamingMetadata, ExplainEvent } from "@trustgraph/client";
|
||||
|
||||
function metadataFrom(metadata: StreamingMetadata | undefined): ChatMessage["metadata"] | undefined {
|
||||
if (metadata === undefined) return undefined;
|
||||
|
||||
const result: NonNullable<ChatMessage["metadata"]> = {};
|
||||
if (metadata.model !== undefined) result.model = metadata.model;
|
||||
if (metadata.in_token !== undefined) result.inTokens = metadata.in_token;
|
||||
if (metadata.out_token !== undefined) result.outTokens = metadata.out_token;
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function withoutActivePhase(message: ChatMessage): ChatMessage {
|
||||
const next = { ...message };
|
||||
delete next.activePhase;
|
||||
return next;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseChatReturn {
|
||||
submitMessage: (opts: { input: string }) => void;
|
||||
cancelRequest: () => void;
|
||||
regenerateLastMessage: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates sending a chat message through the selected RAG / agent
|
||||
* pipeline and accumulates streamed chunks into the conversation store.
|
||||
*/
|
||||
export function useChat(): UseChatReturn {
|
||||
const socket = useSocket();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const chatMode = useConversation((s) => s.chatMode);
|
||||
const addMessage = useConversation((s) => s.addMessage);
|
||||
const updateLastMessage = useConversation((s) => s.updateLastMessage);
|
||||
const setInput = useConversation((s) => s.setInput);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const cancelRequest = useCallback(() => {
|
||||
if (abortControllerRef.current !== null) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
updateLastMessage((prev) =>
|
||||
withoutActivePhase({
|
||||
...prev,
|
||||
content: prev.content.length > 0 ? prev.content : "(Cancelled)",
|
||||
isStreaming: false,
|
||||
}),
|
||||
);
|
||||
removeActivity("Chat request");
|
||||
}, [updateLastMessage, removeActivity]);
|
||||
|
||||
const submitMessage = useCallback(
|
||||
({ input }: { input: string }) => {
|
||||
if (input.trim().length === 0) return;
|
||||
|
||||
// Abort any in-flight request
|
||||
if (abortControllerRef.current !== null) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const activityLabel = "Chat request";
|
||||
|
||||
// 1. Add the user message
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextMessageId(),
|
||||
role: "user",
|
||||
content: input,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
addMessage(userMsg);
|
||||
setInput("");
|
||||
|
||||
// 2. Add a placeholder assistant message for streaming
|
||||
const assistantId = nextMessageId();
|
||||
const isAgent = chatMode === "agent";
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: assistantId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: Date.now(),
|
||||
isStreaming: true,
|
||||
...(isAgent
|
||||
? {
|
||||
agentPhases: { think: "", observe: "", answer: "" },
|
||||
activePhase: "think" as const,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
addMessage(assistantMsg);
|
||||
addActivity(activityLabel);
|
||||
|
||||
const flow = socket.flow(flowId);
|
||||
|
||||
// Collect explainability events during streaming
|
||||
const explainEvents: ExplainEvent[] = [];
|
||||
const onExplain = (event: ExplainEvent) => {
|
||||
explainEvents.push(event);
|
||||
};
|
||||
|
||||
// Attach collected explain events to the message on completion
|
||||
const attachExplainEvents = () => {
|
||||
if (explainEvents.length > 0) {
|
||||
updateLastMessage((prev) => ({
|
||||
...prev,
|
||||
explainEvents: [...explainEvents],
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Shared handler for streaming responses (graph-rag / document-rag)
|
||||
const onChunk = (
|
||||
chunk: string,
|
||||
complete: boolean,
|
||||
metadata?: StreamingMetadata,
|
||||
) => {
|
||||
updateLastMessage((prev) => {
|
||||
const next: ChatMessage = {
|
||||
...prev,
|
||||
content: prev.content + chunk,
|
||||
isStreaming: !complete,
|
||||
};
|
||||
const finalMetadata = complete ? metadataFrom(metadata) : undefined;
|
||||
return finalMetadata !== undefined
|
||||
? { ...next, metadata: finalMetadata }
|
||||
: next;
|
||||
});
|
||||
|
||||
if (complete) {
|
||||
attachExplainEvents();
|
||||
removeActivity(activityLabel);
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (error: string) => {
|
||||
updateLastMessage((prev) =>
|
||||
withoutActivePhase({
|
||||
...prev,
|
||||
content: prev.content.length > 0 ? prev.content : `Error: ${error}`,
|
||||
isStreaming: false,
|
||||
}),
|
||||
);
|
||||
removeActivity(activityLabel);
|
||||
};
|
||||
|
||||
// 3. Dispatch based on chat mode
|
||||
switch (chatMode) {
|
||||
case "graph-rag":
|
||||
flow.graphRagStreaming(input, onChunk, onError, undefined, collection, onExplain);
|
||||
break;
|
||||
|
||||
case "document-rag":
|
||||
flow.documentRagStreaming(input, onChunk, onError, undefined, collection, onExplain);
|
||||
break;
|
||||
|
||||
case "agent": {
|
||||
// Agent has separate think / observe / answer streams.
|
||||
// We track each phase in agentPhases and display the answer
|
||||
// as the main content.
|
||||
|
||||
flow.agent(
|
||||
input,
|
||||
// think
|
||||
(chunk, complete) => {
|
||||
updateLastMessage((prev) => {
|
||||
const phases = prev.agentPhases ?? {
|
||||
think: "",
|
||||
observe: "",
|
||||
answer: "",
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
think: phases.think + chunk,
|
||||
},
|
||||
...(complete ? {} : { activePhase: "think" as const }),
|
||||
};
|
||||
});
|
||||
},
|
||||
// observe
|
||||
(chunk, complete) => {
|
||||
updateLastMessage((prev) => {
|
||||
const phases = prev.agentPhases ?? {
|
||||
think: "",
|
||||
observe: "",
|
||||
answer: "",
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
observe: phases.observe + chunk,
|
||||
},
|
||||
...(complete ? {} : { activePhase: "observe" as const }),
|
||||
};
|
||||
});
|
||||
},
|
||||
// answer
|
||||
(chunk, complete, metadata) => {
|
||||
updateLastMessage((prev) => {
|
||||
const phases = prev.agentPhases ?? {
|
||||
think: "",
|
||||
observe: "",
|
||||
answer: "",
|
||||
};
|
||||
const newAnswer = phases.answer + chunk;
|
||||
const next: ChatMessage = {
|
||||
...prev,
|
||||
content: newAnswer,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
answer: newAnswer,
|
||||
},
|
||||
...(complete ? {} : { activePhase: "answer" as const }),
|
||||
isStreaming: !complete,
|
||||
};
|
||||
const finalMetadata = complete ? metadataFrom(metadata) : undefined;
|
||||
const withMetadata = finalMetadata !== undefined
|
||||
? { ...next, metadata: finalMetadata }
|
||||
: next;
|
||||
return complete ? withoutActivePhase(withMetadata) : withMetadata;
|
||||
});
|
||||
if (complete) {
|
||||
attachExplainEvents();
|
||||
removeActivity(activityLabel);
|
||||
}
|
||||
},
|
||||
// error
|
||||
onError,
|
||||
// explainability
|
||||
onExplain,
|
||||
// collection
|
||||
collection,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
socket,
|
||||
flowId,
|
||||
chatMode,
|
||||
collection,
|
||||
addMessage,
|
||||
updateLastMessage,
|
||||
setInput,
|
||||
addActivity,
|
||||
removeActivity,
|
||||
],
|
||||
);
|
||||
|
||||
const regenerateLastMessage = useCallback(() => {
|
||||
const msgs = useConversation.getState().messages;
|
||||
const lastAssistant = [...msgs].reverse().find((m) => m.role === "assistant");
|
||||
const lastUser = [...msgs].reverse().find((m) => m.role === "user");
|
||||
if (lastAssistant !== undefined && lastUser !== undefined) {
|
||||
useConversation.getState().deleteMessage(lastAssistant.id);
|
||||
submitMessage({ input: lastUser.content });
|
||||
}
|
||||
}, [submitMessage]);
|
||||
|
||||
return { submitMessage, cancelRequest, regenerateLastMessage };
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { ExplainEvent } from "@trustgraph/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ChatMode = "graph-rag" | "document-rag" | "agent";
|
||||
|
||||
export type MessageRole = "user" | "assistant" | "system";
|
||||
|
||||
/** Phase labels for agent-mode messages */
|
||||
export type AgentPhase = "think" | "observe" | "answer";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
/** Timestamp (epoch ms) */
|
||||
timestamp: number;
|
||||
/** If true the message is still being streamed */
|
||||
isStreaming?: boolean;
|
||||
/** Optional metadata attached on completion */
|
||||
metadata?: {
|
||||
model?: string;
|
||||
inTokens?: number;
|
||||
outTokens?: number;
|
||||
};
|
||||
/** Agent-mode phases with their accumulated content */
|
||||
agentPhases?: {
|
||||
think: string;
|
||||
observe: string;
|
||||
answer: string;
|
||||
};
|
||||
/** Indicates the current active phase during streaming */
|
||||
activePhase?: AgentPhase;
|
||||
/** Explainability events received during streaming (graph URIs for source subgraphs) */
|
||||
explainEvents?: ExplainEvent[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ConversationState {
|
||||
messages: ChatMessage[];
|
||||
input: string;
|
||||
chatMode: ChatMode;
|
||||
|
||||
setInput: (value: string) => void;
|
||||
setChatMode: (mode: ChatMode) => void;
|
||||
|
||||
addMessage: (message: ChatMessage) => void;
|
||||
|
||||
/**
|
||||
* Update the last message in the list (used during streaming to append
|
||||
* chunks). The `updater` receives the current last message and must
|
||||
* return the replacement.
|
||||
*/
|
||||
updateLastMessage: (
|
||||
updater: (prev: ChatMessage) => ChatMessage,
|
||||
) => void;
|
||||
|
||||
deleteMessage: (id: string) => void;
|
||||
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
let _nextMsgId = 0;
|
||||
export function nextMessageId(): string {
|
||||
return `msg-${++_nextMsgId}-${Date.now()}`;
|
||||
}
|
||||
|
||||
export const useConversation = create<ConversationState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
messages: [] as ChatMessage[],
|
||||
input: "",
|
||||
chatMode: "graph-rag" as ChatMode,
|
||||
|
||||
setInput: (value) => set({ input: value }),
|
||||
setChatMode: (mode) => set({ chatMode: mode }),
|
||||
|
||||
addMessage: (message) =>
|
||||
set((state) => ({ messages: [...state.messages, message] })),
|
||||
|
||||
updateLastMessage: (updater) =>
|
||||
set((state) => {
|
||||
if (state.messages.length === 0) return state;
|
||||
const last = state.messages[state.messages.length - 1]!;
|
||||
const updated = updater(last);
|
||||
return {
|
||||
messages: [...state.messages.slice(0, -1), updated],
|
||||
};
|
||||
}),
|
||||
|
||||
deleteMessage: (id) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.filter((m) => m.id !== id),
|
||||
})),
|
||||
|
||||
clearMessages: () => set({ messages: [] }),
|
||||
}),
|
||||
{
|
||||
name: "tg-conversation",
|
||||
// Only persist messages and chatMode, not input or transient state
|
||||
partialize: (state) => {
|
||||
const MAX_PERSISTED_MESSAGES = 200;
|
||||
const filtered = state.messages.filter((m) => m.isStreaming !== true);
|
||||
return {
|
||||
messages: filtered.slice(-MAX_PERSISTED_MESSAGES),
|
||||
chatMode: state.chatMode,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { useProgressStore } from "./use-progress-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FlowSummary {
|
||||
id: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UseFlowsReturn {
|
||||
flows: FlowSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
/** Refresh the flow list from the server */
|
||||
getFlows: () => Promise<void>;
|
||||
/** Start a new flow */
|
||||
startFlow: (
|
||||
id: string,
|
||||
blueprintName: string,
|
||||
description: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
/** Stop a running flow */
|
||||
stopFlow: (id: string) => Promise<void>;
|
||||
/** Fetch a single flow definition */
|
||||
getFlow: (id: string) => Promise<FlowSummary>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useFlows(): UseFlowsReturn {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const [flows, setFlows] = useState<FlowSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const getFlows = useCallback(async () => {
|
||||
const act = "Load flows";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
|
||||
const ids: string[] = await socket.flows().getFlows();
|
||||
const results = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const def = await socket.flows().getFlow(id);
|
||||
return { id, ...def } as FlowSummary;
|
||||
}),
|
||||
);
|
||||
|
||||
setFlows(results);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
console.error("useFlows.getFlows error:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, addActivity, removeActivity]);
|
||||
|
||||
const startFlow = useCallback(
|
||||
async (
|
||||
id: string,
|
||||
blueprintName: string,
|
||||
description: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => {
|
||||
const act = `Start flow ${id}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket.flows().startFlow(id, blueprintName, description, parameters);
|
||||
// Refresh list after starting
|
||||
await getFlows();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, getFlows],
|
||||
);
|
||||
|
||||
const stopFlow = useCallback(
|
||||
async (id: string) => {
|
||||
const act = `Stop flow ${id}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket.flows().stopFlow(id);
|
||||
await getFlows();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, getFlows],
|
||||
);
|
||||
|
||||
const getFlow = useCallback(
|
||||
async (id: string): Promise<FlowSummary> => {
|
||||
const def = await socket.flows().getFlow(id);
|
||||
return { id, ...def } as FlowSummary;
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
// Auto-load flows when the connection becomes ready
|
||||
useEffect(() => {
|
||||
if (
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated"
|
||||
) {
|
||||
getFlows();
|
||||
}
|
||||
}, [connectionState.status, getFlows]);
|
||||
|
||||
return { flows, loading, error, getFlows, startFlow, stopFlow, getFlow };
|
||||
}
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useProgressStore } from "./use-progress-store";
|
||||
import type { DocumentMetadata } from "@trustgraph/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProcessingMetadata {
|
||||
id: string;
|
||||
"document-id": string;
|
||||
flow: string;
|
||||
collection: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UploadProgress {
|
||||
phase: "preparing" | "uploading" | "finalizing";
|
||||
chunksTotal: number;
|
||||
chunksUploaded: number;
|
||||
bytesTotal: number;
|
||||
bytesUploaded: number;
|
||||
}
|
||||
|
||||
export interface UseLibraryReturn {
|
||||
documents: DocumentMetadata[];
|
||||
processing: ProcessingMetadata[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
/** Refresh the documents list */
|
||||
getDocuments: () => Promise<void>;
|
||||
/** Upload a new document (auto-selects simple vs chunked based on size) */
|
||||
uploadDocument: (
|
||||
document: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
id?: string,
|
||||
) => Promise<void>;
|
||||
/** Upload a large document using chunked upload with progress tracking */
|
||||
uploadDocumentChunked: (
|
||||
base64Content: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
onProgress?: (progress: UploadProgress) => void,
|
||||
) => Promise<void>;
|
||||
/** Remove a document */
|
||||
removeDocument: (id: string, collection?: string) => Promise<void>;
|
||||
/** Get the list of currently-processing documents */
|
||||
getProcessing: () => Promise<void>;
|
||||
/** Fetch full metadata for a single document */
|
||||
getDocumentMetadata: (documentId: string) => Promise<DocumentMetadata | null>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useLibrary(): UseLibraryReturn {
|
||||
const socket = useSocket();
|
||||
const user = useSettings((s) => s.settings.user);
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const [documents, setDocuments] = useState<DocumentMetadata[]>([]);
|
||||
const [processing, setProcessing] = useState<ProcessingMetadata[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const getDocuments = useCallback(async () => {
|
||||
const act = "Load documents";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
const docs = await socket.librarian().getDocuments();
|
||||
setDocuments(docs);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
console.error("useLibrary.getDocuments error:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, addActivity, removeActivity]);
|
||||
|
||||
const uploadDocument = useCallback(
|
||||
async (
|
||||
document: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
id?: string,
|
||||
) => {
|
||||
const act = "Upload document";
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket
|
||||
.librarian()
|
||||
.loadDocument(document, mimeType, title, comments, tags, id);
|
||||
// Refresh list after upload
|
||||
await getDocuments();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, getDocuments],
|
||||
);
|
||||
|
||||
const removeDocument = useCallback(
|
||||
async (id: string, collection?: string) => {
|
||||
const act = "Remove document";
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket.librarian().removeDocument(id, collection);
|
||||
await getDocuments();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, getDocuments],
|
||||
);
|
||||
|
||||
const uploadDocumentChunked = useCallback(
|
||||
async (
|
||||
base64Content: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
onProgress?: (progress: UploadProgress) => void,
|
||||
) => {
|
||||
const act = "Upload document (chunked)";
|
||||
try {
|
||||
addActivity(act);
|
||||
const lib = socket.librarian();
|
||||
const totalSize = base64Content.length;
|
||||
|
||||
onProgress?.({
|
||||
phase: "preparing",
|
||||
chunksTotal: 0,
|
||||
chunksUploaded: 0,
|
||||
bytesTotal: totalSize,
|
||||
bytesUploaded: 0,
|
||||
});
|
||||
|
||||
// Begin the upload session
|
||||
const beginResp = await lib.beginUpload(
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
kind: mimeType,
|
||||
title,
|
||||
comments,
|
||||
tags,
|
||||
user,
|
||||
},
|
||||
totalSize,
|
||||
);
|
||||
|
||||
const uploadId = beginResp["upload-id"];
|
||||
const chunkSize = beginResp["chunk-size"];
|
||||
const totalChunks = beginResp["total-chunks"];
|
||||
|
||||
// Upload chunks sequentially
|
||||
let bytesUploaded = 0;
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize;
|
||||
const end = Math.min(start + chunkSize, totalSize);
|
||||
const chunk = base64Content.slice(start, end);
|
||||
|
||||
await lib.uploadChunk(uploadId, i, chunk);
|
||||
bytesUploaded += chunk.length;
|
||||
|
||||
onProgress?.({
|
||||
phase: "uploading",
|
||||
chunksTotal: totalChunks,
|
||||
chunksUploaded: i + 1,
|
||||
bytesTotal: totalSize,
|
||||
bytesUploaded,
|
||||
});
|
||||
}
|
||||
|
||||
// Finalize
|
||||
onProgress?.({
|
||||
phase: "finalizing",
|
||||
chunksTotal: totalChunks,
|
||||
chunksUploaded: totalChunks,
|
||||
bytesTotal: totalSize,
|
||||
bytesUploaded: totalSize,
|
||||
});
|
||||
|
||||
await lib.completeUpload(uploadId);
|
||||
await getDocuments();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, getDocuments],
|
||||
);
|
||||
|
||||
const getProcessing = useCallback(async () => {
|
||||
const act = "Load processing";
|
||||
try {
|
||||
addActivity(act);
|
||||
const procs = await socket.librarian().getProcessing();
|
||||
setProcessing(procs as ProcessingMetadata[]);
|
||||
} catch (err) {
|
||||
console.error("useLibrary.getProcessing error:", err);
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, addActivity, removeActivity]);
|
||||
|
||||
const getDocumentMetadata = useCallback(
|
||||
async (documentId: string): Promise<DocumentMetadata | null> => {
|
||||
try {
|
||||
return await socket.librarian().getDocumentMetadata(documentId);
|
||||
} catch (err) {
|
||||
console.error("useLibrary.getDocumentMetadata error:", err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
return {
|
||||
documents,
|
||||
processing,
|
||||
loading,
|
||||
error,
|
||||
getDocuments,
|
||||
uploadDocument,
|
||||
uploadDocumentChunked,
|
||||
removeDocument,
|
||||
getProcessing,
|
||||
getDocumentMetadata,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { useProgressStore } from "./use-progress-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface McpServerConfig {
|
||||
url: string;
|
||||
"remote-name"?: string;
|
||||
"auth-token"?: string;
|
||||
}
|
||||
|
||||
export interface McpServerEntry {
|
||||
key: string;
|
||||
config: McpServerConfig;
|
||||
}
|
||||
|
||||
export interface ToolArgument {
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ToolConfig {
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
"mcp-tool"?: string;
|
||||
group?: string[];
|
||||
arguments?: ToolArgument[];
|
||||
}
|
||||
|
||||
export interface ToolEntry {
|
||||
key: string;
|
||||
config: ToolConfig;
|
||||
}
|
||||
|
||||
export interface UseMcpConfigReturn {
|
||||
servers: McpServerEntry[];
|
||||
tools: ToolEntry[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
loadServers: () => Promise<void>;
|
||||
saveServer: (key: string, config: McpServerConfig) => Promise<void>;
|
||||
deleteServer: (key: string) => Promise<void>;
|
||||
|
||||
loadTools: () => Promise<void>;
|
||||
saveTool: (key: string, config: ToolConfig) => Promise<void>;
|
||||
deleteTool: (key: string) => Promise<void>;
|
||||
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useMcpConfig(): UseMcpConfigReturn {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const [servers, setServers] = useState<McpServerEntry[]>([]);
|
||||
const [tools, setTools] = useState<ToolEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadServers = useCallback(async () => {
|
||||
try {
|
||||
const raw = await socket.config().getValues("mcp");
|
||||
const entries: McpServerEntry[] = [];
|
||||
for (const item of raw as { key: string; value: string }[]) {
|
||||
try {
|
||||
entries.push({ key: item.key, config: JSON.parse(item.value) });
|
||||
} catch {
|
||||
console.warn(`[useMcpConfig] Failed to parse MCP server config: ${item.key}`);
|
||||
}
|
||||
}
|
||||
setServers(entries);
|
||||
} catch (err) {
|
||||
console.error("[useMcpConfig] loadServers error:", err);
|
||||
throw err;
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const loadTools = useCallback(async () => {
|
||||
try {
|
||||
const raw = await socket.config().getValues("tool");
|
||||
const entries: ToolEntry[] = [];
|
||||
for (const item of raw as { key: string; value: string }[]) {
|
||||
try {
|
||||
entries.push({ key: item.key, config: JSON.parse(item.value) });
|
||||
} catch {
|
||||
console.warn(`[useMcpConfig] Failed to parse tool config: ${item.key}`);
|
||||
}
|
||||
}
|
||||
setTools(entries);
|
||||
} catch (err) {
|
||||
console.error("[useMcpConfig] loadTools error:", err);
|
||||
throw err;
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const act = "Load MCP config";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
await Promise.all([loadServers(), loadTools()]);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [addActivity, removeActivity, loadServers, loadTools]);
|
||||
|
||||
const saveServer = useCallback(
|
||||
async (key: string, config: McpServerConfig) => {
|
||||
const act = `Save MCP server ${key}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket
|
||||
.config()
|
||||
.putConfig([{ type: "mcp", key, value: JSON.stringify(config) }]);
|
||||
await loadServers();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, loadServers],
|
||||
);
|
||||
|
||||
const deleteServer = useCallback(
|
||||
async (key: string) => {
|
||||
const act = `Delete MCP server ${key}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket.config().deleteConfig({ type: "mcp", key });
|
||||
await loadServers();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, loadServers],
|
||||
);
|
||||
|
||||
const saveTool = useCallback(
|
||||
async (key: string, config: ToolConfig) => {
|
||||
const act = `Save tool ${key}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket
|
||||
.config()
|
||||
.putConfig([{ type: "tool", key, value: JSON.stringify(config) }]);
|
||||
await loadTools();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, loadTools],
|
||||
);
|
||||
|
||||
const deleteTool = useCallback(
|
||||
async (key: string) => {
|
||||
const act = `Delete tool ${key}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket.config().deleteConfig({ type: "tool", key });
|
||||
await loadTools();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, loadTools],
|
||||
);
|
||||
|
||||
// Auto-load when connection becomes ready
|
||||
useEffect(() => {
|
||||
if (
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated"
|
||||
) {
|
||||
refresh();
|
||||
}
|
||||
}, [connectionState.status, refresh]);
|
||||
|
||||
return {
|
||||
servers,
|
||||
tools,
|
||||
loading,
|
||||
error,
|
||||
loadServers,
|
||||
saveServer,
|
||||
deleteServer,
|
||||
loadTools,
|
||||
saveTool,
|
||||
deleteTool,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ProgressState {
|
||||
/** Set of currently-running activity labels */
|
||||
activities: Set<string>;
|
||||
|
||||
/** Derived: true when at least one activity is running */
|
||||
isLoading: boolean;
|
||||
|
||||
addActivity: (label: string) => void;
|
||||
removeActivity: (label: string) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const useProgressStore = create<ProgressState>()((set) => ({
|
||||
activities: new Set<string>(),
|
||||
isLoading: false,
|
||||
|
||||
addActivity: (label) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.activities);
|
||||
next.add(label);
|
||||
return { activities: next, isLoading: next.size > 0 };
|
||||
}),
|
||||
|
||||
removeActivity: (label) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.activities);
|
||||
next.delete(label);
|
||||
return { activities: next, isLoading: next.size > 0 };
|
||||
}),
|
||||
}));
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
|
||||
export function usePrompts() {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const [prompts, setPrompts] = useState<Array<{ id: string; name?: string; description?: string }>>([]);
|
||||
const [systemPrompt, setSystemPrompt] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadPrompts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const list = await socket.config().getPrompts();
|
||||
setPrompts(Array.isArray(list) ? list : []);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
console.error("Failed to load prompts:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const loadSystemPrompt = useCallback(async () => {
|
||||
try {
|
||||
const sp = await socket.config().getSystemPrompt();
|
||||
setSystemPrompt(typeof sp === "string" ? sp : JSON.stringify(sp, null, 2));
|
||||
} catch (err) {
|
||||
console.error("Failed to load system prompt:", err);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const getPrompt = useCallback(async (id: string) => {
|
||||
return socket.config().getPrompt(id);
|
||||
}, [socket]);
|
||||
|
||||
// Auto-load when connected
|
||||
useEffect(() => {
|
||||
const connected =
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated";
|
||||
if (connected) {
|
||||
loadPrompts();
|
||||
loadSystemPrompt();
|
||||
}
|
||||
}, [connectionState.status, loadPrompts, loadSystemPrompt]);
|
||||
|
||||
return { prompts, systemPrompt, loading, error, loadPrompts, loadSystemPrompt, getPrompt };
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimal flow description kept in session state after selection. */
|
||||
export interface FlowInfo {
|
||||
id: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
/** Currently-selected flow id */
|
||||
flowId: string;
|
||||
/** Cached flow definition for the selected flow */
|
||||
flow: FlowInfo | null;
|
||||
|
||||
setFlowId: (id: string) => void;
|
||||
setFlow: (flow: FlowInfo | null) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const useSessionStore = create<SessionState>()((set) => ({
|
||||
flowId: "default",
|
||||
flow: null,
|
||||
|
||||
setFlowId: (id) => set({ flowId: id }),
|
||||
setFlow: (flow) => set({ flow }),
|
||||
}));
|
||||
|
|
@ -1,37 +1,23 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RegistryProvider, useAtomMount } from "@effect/atom-react";
|
||||
import App from "@/App";
|
||||
import { SocketProvider } from "@/providers/socket-provider";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { connectionStateAtom, themeClassAtom } from "@/atoms/workbench";
|
||||
import { getWorkbenchQaInitialValues } from "@/qa/initial-values";
|
||||
import "@/index.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
/**
|
||||
* AppRoot reads settings from the Zustand store and passes them
|
||||
* into the SocketProvider so the WebSocket connection is configured
|
||||
* before any child component mounts.
|
||||
*/
|
||||
function AppRoot() {
|
||||
const settings = useSettings((s) => s.settings);
|
||||
useAtomMount(themeClassAtom);
|
||||
useAtomMount(connectionStateAtom);
|
||||
|
||||
return (
|
||||
<SocketProvider
|
||||
user={settings.user}
|
||||
{...(settings.apiKey.length > 0 ? { apiKey: settings.apiKey } : {})}
|
||||
{...(settings.gatewayUrl.length > 0 ? { socketUrl: settings.gatewayUrl } : {})}
|
||||
>
|
||||
<App />
|
||||
</SocketProvider>
|
||||
);
|
||||
return <App />;
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RegistryProvider defaultIdleTTL={1_000} initialValues={getWorkbenchQaInitialValues()}>
|
||||
<AppRoot />
|
||||
</QueryClientProvider>
|
||||
</RegistryProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { useAtom, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import {
|
||||
MessageSquareText,
|
||||
Send,
|
||||
|
|
@ -20,47 +15,49 @@ import {
|
|||
} from "lucide-react";
|
||||
import Markdown from "react-markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConversation, type ChatMessage } from "@/hooks/use-conversation";
|
||||
import { useChat } from "@/hooks/use-chat";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useProgressStore } from "@/hooks/use-progress-store";
|
||||
import {
|
||||
agentPhaseExpandedAtom,
|
||||
cancelChatAtom,
|
||||
clearMessagesAtom,
|
||||
conversationAtom,
|
||||
deleteMessageAtom,
|
||||
isLoadingAtom,
|
||||
regenerateLastMessageAtom,
|
||||
setChatModeAtom,
|
||||
setConversationInputAtom,
|
||||
settingsAtom,
|
||||
submitMessageAtom,
|
||||
type ChatMessage,
|
||||
} from "@/atoms/workbench";
|
||||
import { AutoTextarea } from "@/components/ui/textarea";
|
||||
import { MessageActions } from "@/components/chat/message-actions";
|
||||
import { ExplainGraph } from "@/components/chat/explain-graph";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MODES = [
|
||||
{ value: "graph-rag" as const, label: "Graph RAG" },
|
||||
{ value: "document-rag" as const, label: "Doc RAG" },
|
||||
{ value: "agent" as const, label: "Agent" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent phase section (collapsible)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentPhaseBlock({
|
||||
messageId,
|
||||
phase,
|
||||
icon,
|
||||
label,
|
||||
content,
|
||||
isActive,
|
||||
}: {
|
||||
messageId: string;
|
||||
phase: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
content: string;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const [manualToggle, setManualToggle] = useState<boolean | null>(null);
|
||||
|
||||
const [expandedMap, setExpandedMap] = useAtom(agentPhaseExpandedAtom);
|
||||
const key = `${messageId}:${phase}`;
|
||||
if (content.length === 0 && !isActive) return null;
|
||||
|
||||
// Auto-expand while actively streaming; user can override
|
||||
const expanded = manualToggle ?? isActive;
|
||||
const expanded = expandedMap[key] ?? isActive;
|
||||
|
||||
const phaseColors: Record<string, string> = {
|
||||
think: "border-amber-500/30 bg-amber-500/5",
|
||||
|
|
@ -75,40 +72,22 @@ function AgentPhaseBlock({
|
|||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border",
|
||||
phaseColors[phase] ?? "border-border bg-surface-100",
|
||||
)}
|
||||
>
|
||||
<div className={cn("rounded-md border", phaseColors[phase] ?? "border-border bg-surface-100")}>
|
||||
<button
|
||||
onClick={() => setManualToggle((prev) => !(prev ?? isActive))}
|
||||
onClick={() => setExpandedMap({ ...expandedMap, [key]: !expanded })}
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-fg-muted"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
{expanded ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
{icon}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5",
|
||||
badgeColors[phase] ?? "bg-surface-200 text-fg-muted",
|
||||
)}
|
||||
>
|
||||
<span className={cn("rounded px-1.5 py-0.5", badgeColors[phase] ?? "bg-surface-200 text-fg-muted")}>
|
||||
{label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<Loader2 className="ml-auto h-3 w-3 animate-spin text-fg-subtle" />
|
||||
)}
|
||||
{isActive && <Loader2 className="ml-auto h-3 w-3 animate-spin text-fg-subtle" />}
|
||||
</button>
|
||||
{expanded && (content.length > 0 || isActive) && (
|
||||
<div className="border-t border-border/50 px-3 py-2 text-xs leading-relaxed text-fg-muted">
|
||||
<p className="whitespace-pre-wrap">
|
||||
{content.length > 0 ? content : isActive ? "..." : ""}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap">{content.length > 0 ? content : isActive ? "..." : ""}</p>
|
||||
{isActive && content.length > 0 && (
|
||||
<span className="mt-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-amber-400" />
|
||||
)}
|
||||
|
|
@ -118,168 +97,146 @@ function AgentPhaseBlock({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single message bubble
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MessageBubble({ msg, collection }: { msg: ChatMessage; collection: string }) {
|
||||
function MessageBubble({
|
||||
msg,
|
||||
collection,
|
||||
isLastAssistant,
|
||||
}: {
|
||||
msg: ChatMessage;
|
||||
collection: string;
|
||||
isLastAssistant: boolean;
|
||||
}) {
|
||||
const deleteMessage = useAtomSet(deleteMessageAtom);
|
||||
const regenerateLastMessage = useAtomSet(regenerateLastMessageAtom);
|
||||
const isUser = msg.role === "user";
|
||||
const agentPhases = msg.agentPhases;
|
||||
const isError = !isUser && msg.content.startsWith("Error:");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-3 text-sm leading-relaxed",
|
||||
isUser
|
||||
? "ml-auto max-w-[80%] bg-brand-700/30 text-fg"
|
||||
: isError
|
||||
? "mr-auto max-w-[80%] border border-error/30 bg-error/10 text-error"
|
||||
: "mr-auto max-w-[80%] bg-surface-100 text-fg",
|
||||
)}
|
||||
>
|
||||
{/* Agent phase blocks (only for agent messages) */}
|
||||
{agentPhases !== undefined && (
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<AgentPhaseBlock
|
||||
phase="think"
|
||||
icon={<Brain className="h-3 w-3" />}
|
||||
label="Thinking"
|
||||
content={agentPhases.think}
|
||||
isActive={msg.activePhase === "think"}
|
||||
/>
|
||||
<AgentPhaseBlock
|
||||
phase="observe"
|
||||
icon={<Eye className="h-3 w-3" />}
|
||||
label="Observing"
|
||||
content={agentPhases.observe}
|
||||
isActive={msg.activePhase === "observe"}
|
||||
/>
|
||||
{agentPhases.answer.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 px-1 pt-1 text-xs text-emerald-400">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
<span className="font-medium">Answer</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="group relative">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-3 text-sm leading-relaxed",
|
||||
isUser
|
||||
? "ml-auto max-w-[80%] bg-brand-700/30 text-fg"
|
||||
: isError
|
||||
? "mr-auto max-w-[80%] border border-error/30 bg-error/10 text-error"
|
||||
: "mr-auto max-w-[80%] bg-surface-100 text-fg",
|
||||
)}
|
||||
>
|
||||
{agentPhases !== undefined && (
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<AgentPhaseBlock
|
||||
messageId={msg.id}
|
||||
phase="think"
|
||||
icon={<Brain className="h-3 w-3" />}
|
||||
label="Thinking"
|
||||
content={agentPhases.think}
|
||||
isActive={msg.activePhase === "think"}
|
||||
/>
|
||||
<AgentPhaseBlock
|
||||
messageId={msg.id}
|
||||
phase="observe"
|
||||
icon={<Eye className="h-3 w-3" />}
|
||||
label="Observing"
|
||||
content={agentPhases.observe}
|
||||
isActive={msg.activePhase === "observe"}
|
||||
/>
|
||||
{agentPhases.answer.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 px-1 pt-1 text-xs text-emerald-400">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
<span className="font-medium">Answer</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content (markdown for assistant, plain for user) */}
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
) : isError ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none text-fg prose-headings:text-fg prose-strong:text-fg prose-p:my-1 prose-a:text-brand-400 prose-pre:bg-surface-200 prose-pre:text-fg prose-code:text-brand-300">
|
||||
<Markdown>{msg.content.length > 0 ? msg.content : msg.isStreaming === true ? "" : "(empty)"}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
) : isError ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none text-fg prose-headings:text-fg prose-strong:text-fg prose-p:my-1 prose-a:text-brand-400 prose-pre:bg-surface-200 prose-pre:text-fg prose-code:text-brand-300">
|
||||
<Markdown>{msg.content.length > 0 ? msg.content : msg.isStreaming === true ? "" : "(empty)"}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming indicator */}
|
||||
{msg.isStreaming === true && (
|
||||
<span className="mt-1 inline-block h-2 w-2 animate-pulse rounded-full bg-brand-400" />
|
||||
)}
|
||||
{msg.isStreaming === true && (
|
||||
<span className="mt-1 inline-block h-2 w-2 animate-pulse rounded-full bg-brand-400" />
|
||||
)}
|
||||
|
||||
{/* Token metadata */}
|
||||
{msg.metadata !== undefined && (
|
||||
<div className="mt-2 flex items-center gap-3 text-[10px] text-fg-subtle">
|
||||
{msg.metadata.model !== undefined && msg.metadata.model.length > 0 && <span>{msg.metadata.model}</span>}
|
||||
{msg.metadata.inTokens != null && (
|
||||
<span>in: {msg.metadata.inTokens}</span>
|
||||
)}
|
||||
{msg.metadata.outTokens != null && (
|
||||
<span>out: {msg.metadata.outTokens}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.metadata !== undefined && (
|
||||
<div className="mt-2 flex items-center gap-3 text-[10px] text-fg-subtle">
|
||||
{msg.metadata.model !== undefined && msg.metadata.model.length > 0 && <span>{msg.metadata.model}</span>}
|
||||
{msg.metadata.inTokens != null && <span>in: {msg.metadata.inTokens}</span>}
|
||||
{msg.metadata.outTokens != null && <span>out: {msg.metadata.outTokens}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explainability graph */}
|
||||
{!isUser && !isError && msg.isStreaming !== true && msg.explainEvents !== undefined && msg.explainEvents.length > 0 && (
|
||||
<ExplainGraph explainEvents={msg.explainEvents} collection={collection} />
|
||||
{!isUser && !isError && msg.isStreaming !== true && msg.explainEvents !== undefined && msg.explainEvents.length > 0 && (
|
||||
<ExplainGraph explainEvents={msg.explainEvents} collection={collection} />
|
||||
)}
|
||||
</div>
|
||||
{!isUser && (
|
||||
<MessageActions
|
||||
messageId={msg.id}
|
||||
content={msg.content}
|
||||
isLastAssistant={isLastAssistant}
|
||||
onDelete={() => deleteMessage(msg.id)}
|
||||
onRegenerate={() => regenerateLastMessage()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ChatPage() {
|
||||
const messages = useConversation((s) => s.messages);
|
||||
const input = useConversation((s) => s.input);
|
||||
const chatMode = useConversation((s) => s.chatMode);
|
||||
const setInput = useConversation((s) => s.setInput);
|
||||
const setChatMode = useConversation((s) => s.setChatMode);
|
||||
const clearMessages = useConversation((s) => s.clearMessages);
|
||||
const { submitMessage, cancelRequest, regenerateLastMessage } = useChat();
|
||||
const deleteMessage = useConversation((s) => s.deleteMessage);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const isLoading = useProgressStore((s) => s.isLoading);
|
||||
const conversation = useAtomValue(conversationAtom);
|
||||
const collection = useAtomValue(settingsAtom).collection;
|
||||
const isLoading = useAtomValue(isLoadingAtom);
|
||||
const setInput = useAtomSet(setConversationInputAtom);
|
||||
const setChatMode = useAtomSet(setChatModeAtom);
|
||||
const clearMessages = useAtomSet(clearMessagesAtom);
|
||||
const submitMessage = useAtomSet(submitMessageAtom);
|
||||
const cancelRequest = useAtomSet(cancelChatAtom);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Elapsed time counter while loading
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setElapsed(0);
|
||||
return;
|
||||
const handleSubmit = () => {
|
||||
if (conversation.input.trim().length > 0) {
|
||||
submitMessage({ input: conversation.input });
|
||||
}
|
||||
const interval = setInterval(() => setElapsed((e) => e + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isLoading]);
|
||||
};
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (input.trim().length > 0) {
|
||||
submitMessage({ input });
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}, [input, submitMessage]);
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
const lastAssistantId = [...conversation.messages].reverse().find((message) => message.role === "assistant")?.id;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageSquareText className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Chat</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-muted">
|
||||
{collection}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Mode selector */}
|
||||
<div role="group" aria-label="Chat mode" className="flex rounded-lg border border-border bg-surface-100 p-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-lg border border-border bg-surface-100 p-1">
|
||||
{MODES.map((mode) => (
|
||||
<button
|
||||
type="button"
|
||||
key={mode.value}
|
||||
onClick={() => setChatMode(mode.value)}
|
||||
aria-pressed={chatMode === mode.value}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
||||
chatMode === mode.value
|
||||
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
conversation.chatMode === mode.value
|
||||
? "bg-brand-600 text-white"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
: "text-fg-muted hover:bg-surface-200 hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{mode.label}
|
||||
|
|
@ -287,84 +244,68 @@ export default function ChatPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { cancelRequest(); clearMessages(); }}
|
||||
className="rounded-lg p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
title="Clear messages"
|
||||
aria-label="Clear messages"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
{conversation.messages.length > 0 && (
|
||||
<button
|
||||
onClick={() => clearMessages(null)}
|
||||
className="rounded-lg border border-border p-2 text-fg-subtle hover:bg-surface-200 hover:text-error"
|
||||
aria-label="Clear conversation"
|
||||
title="Clear conversation"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pb-4 pt-10">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-fg-subtle">
|
||||
<MessageSquareText className="mb-3 h-10 w-10 opacity-30" />
|
||||
<p>Send a message to start a conversation.</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Mode: <span className="text-fg-muted">{MODES.find((m) => m.value === chatMode)?.label ?? chatMode}</span>
|
||||
</p>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-border bg-surface-50 p-4">
|
||||
{conversation.messages.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<MessageSquareText className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-sm text-fg-subtle">Start a conversation with TrustGraph.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{conversation.messages.map((message) => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
msg={message}
|
||||
collection={collection}
|
||||
isLastAssistant={message.id === lastAssistantId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, idx) => {
|
||||
const isLastAssistant =
|
||||
msg.role === "assistant" &&
|
||||
idx === messages.length - 1;
|
||||
|
||||
return (
|
||||
<div key={msg.id} className="group relative">
|
||||
{msg.isStreaming !== true && (
|
||||
<MessageActions
|
||||
content={msg.content}
|
||||
isLastAssistant={isLastAssistant}
|
||||
onDelete={() => deleteMessage(msg.id)}
|
||||
{...(isLastAssistant ? { onRegenerate: regenerateLastMessage } : {})}
|
||||
/>
|
||||
)}
|
||||
<MessageBubble msg={msg} collection={collection} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 pb-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Processing... {elapsed}s</span>
|
||||
<button
|
||||
onClick={cancelRequest}
|
||||
className="flex items-center gap-1 rounded-lg px-3 py-1 text-xs text-red-400 hover:bg-surface-200"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Cancel
|
||||
</button>
|
||||
<div className="mt-4 rounded-lg border border-border bg-surface-50 p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<AutoTextarea
|
||||
value={conversation.input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Ask using ${MODES.find((mode) => mode.value === conversation.chatMode)?.label ?? "TrustGraph"}...`}
|
||||
disabled={isLoading}
|
||||
maxRows={8}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<button
|
||||
onClick={() => cancelRequest(null)}
|
||||
className="rounded-lg border border-border p-3 text-fg-muted hover:bg-error/10 hover:text-error"
|
||||
aria-label="Cancel request"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={conversation.input.trim().length === 0}
|
||||
className="rounded-lg bg-brand-600 p-3 text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div className="flex items-end gap-2 border-t border-border pt-4">
|
||||
<AutoTextarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your message... (Enter to send, Shift+Enter for new line)"
|
||||
aria-label="Chat message"
|
||||
maxRows={6}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={input.trim().length === 0 || isLoading}
|
||||
aria-label="Send message"
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-brand-600 text-white transition-colors hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
Workflow,
|
||||
Plus,
|
||||
|
|
@ -7,579 +7,201 @@ import {
|
|||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlows, type FlowSummary } from "@/hooks/use-flows";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import {
|
||||
activeActionAtom,
|
||||
encodeJsonUnknownString,
|
||||
flowBlueprintAtom,
|
||||
flowBlueprintsAtom,
|
||||
flowExpandedAtom,
|
||||
flowsAtom,
|
||||
flowsStartDialogOpenAtom,
|
||||
parseJsonUnknown,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
startFlowAtom,
|
||||
startFlowFormAtom,
|
||||
stopFlowAtom,
|
||||
} from "@/atoms/workbench";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start flow dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
function StartFlowDialog() {
|
||||
const [open, setOpen] = useAtom(flowsStartDialogOpenAtom);
|
||||
const [form, setForm] = useAtom(startFlowFormAtom);
|
||||
const startFlow = useAtomSet(startFlowAtom);
|
||||
const blueprintsResult = useAtomValue(flowBlueprintsAtom);
|
||||
const blueprints = resultData(blueprintsResult, []);
|
||||
const blueprintDetail = resultData(useAtomValue(flowBlueprintAtom(form.blueprint)), null) as Record<string, unknown> | null;
|
||||
const loadingBlueprints = resultLoading(blueprintsResult, blueprints);
|
||||
const isValid = form.id.trim().length > 0 && form.blueprint.length > 0 && form.description.trim().length > 0;
|
||||
|
||||
function StartFlowDialog({
|
||||
open,
|
||||
onClose,
|
||||
onStart,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onStart: (
|
||||
id: string,
|
||||
blueprint: string,
|
||||
description: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const socket = useSocket();
|
||||
const [blueprints, setBlueprints] = useState<string[]>([]);
|
||||
const [loadingBlueprints, setLoadingBlueprints] = useState(false);
|
||||
const [id, setId] = useState("");
|
||||
const [blueprint, setBlueprint] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [paramsJson, setParamsJson] = useState("{}");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [paramsError, setParamsError] = useState<string | null>(null);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [blueprintDef, setBlueprintDef] = useState<Record<string, unknown> | null>(null);
|
||||
const [loadingDef, setLoadingDef] = useState(false);
|
||||
const [defExpanded, setDefExpanded] = useState(false);
|
||||
|
||||
// Fetch blueprints when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoadingBlueprints(true);
|
||||
socket
|
||||
.flows()
|
||||
.getFlowBlueprints()
|
||||
.then((names) => {
|
||||
const list = names ?? [];
|
||||
setBlueprints(list);
|
||||
if (list.length > 0 && blueprint.length === 0) {
|
||||
setBlueprint(list[0]!);
|
||||
}
|
||||
})
|
||||
.catch(() => setBlueprints([]))
|
||||
.finally(() => setLoadingBlueprints(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, socket]);
|
||||
|
||||
// Fetch blueprint definition when selection changes
|
||||
useEffect(() => {
|
||||
if (blueprint.length === 0) {
|
||||
setBlueprintDef(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoadingDef(true);
|
||||
setBlueprintDef(null);
|
||||
socket
|
||||
.flows()
|
||||
.getFlowBlueprint(blueprint)
|
||||
.then((def) => {
|
||||
if (cancelled) return;
|
||||
setBlueprintDef(def);
|
||||
// Pre-populate parameters with defaults from the definition
|
||||
const paramsDef =
|
||||
def?.parameters ?? def?.params ?? def?.["parameters"] ?? def?.["params"];
|
||||
if (paramsDef !== undefined && paramsDef !== null && typeof paramsDef === "object") {
|
||||
const defaults: Record<string, unknown> = {};
|
||||
const params = paramsDef as Record<string, unknown>;
|
||||
for (const [key, val] of Object.entries(params)) {
|
||||
if (val !== null && typeof val === "object" && "default" in (val as Record<string, unknown>)) {
|
||||
defaults[key] = (val as Record<string, unknown>).default;
|
||||
}
|
||||
}
|
||||
if (Object.keys(defaults).length > 0) {
|
||||
setParamsJson(JSON.stringify(defaults, null, 2));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled === false) setBlueprintDef(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled === false) setLoadingDef(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [blueprint, socket]);
|
||||
|
||||
const reset = () => {
|
||||
setId("");
|
||||
setBlueprint("");
|
||||
setDescription("");
|
||||
setParamsJson("{}");
|
||||
setParamsError(null);
|
||||
setSubmitting(false);
|
||||
setSubmitted(false);
|
||||
setBlueprintDef(null);
|
||||
setLoadingDef(false);
|
||||
setDefExpanded(false);
|
||||
const close = () => {
|
||||
setForm({
|
||||
id: "",
|
||||
blueprint: "",
|
||||
description: "",
|
||||
paramsJson: "{}",
|
||||
submitting: false,
|
||||
paramsError: null,
|
||||
submitted: false,
|
||||
definitionExpanded: false,
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitted(true);
|
||||
if (!isValid) return;
|
||||
|
||||
let params: Record<string, unknown> = {};
|
||||
try {
|
||||
params = JSON.parse(paramsJson);
|
||||
setParamsError(null);
|
||||
} catch {
|
||||
setParamsError("Invalid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onStart(id, blueprint, description, params);
|
||||
reset();
|
||||
onClose();
|
||||
} catch {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = id.trim().length > 0 && blueprint.length > 0 && description.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (!submitting) {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onClose={close}
|
||||
title="Start Flow"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
disabled={submitting}
|
||||
onClick={close}
|
||||
disabled={form.submitting}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setForm({ ...form, submitted: true });
|
||||
if (!isValid) return;
|
||||
const parameters = parseJsonUnknown(form.paramsJson);
|
||||
if (parameters === undefined || typeof parameters !== "object" || parameters === null || Array.isArray(parameters)) {
|
||||
setForm({ ...form, paramsError: "Invalid JSON", submitted: true });
|
||||
return;
|
||||
}
|
||||
startFlow({
|
||||
id: form.id.trim(),
|
||||
blueprint: form.blueprint,
|
||||
description: form.description.trim(),
|
||||
parameters: parameters as Record<string, unknown>,
|
||||
});
|
||||
close();
|
||||
}}
|
||||
disabled={form.submitting}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{form.submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Start
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Flow ID */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label htmlFor="flow-id" className="block text-sm font-medium text-fg-muted">
|
||||
Flow ID <span className="text-error">*</span>
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Flow ID</span>
|
||||
<input
|
||||
value={form.id}
|
||||
onChange={(event) => setForm({ ...form, id: event.target.value })}
|
||||
placeholder="my-flow-id"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{form.submitted && form.id.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Flow ID is required</p>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id="flow-id"
|
||||
type="text"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
placeholder="my-flow-id"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{submitted && id.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Flow ID is required</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blueprint name */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label htmlFor="flow-blueprint" className="block text-sm font-medium text-fg-muted">
|
||||
Blueprint <span className="text-error">*</span>
|
||||
</label>
|
||||
{loadingBlueprints ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprints...
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="flow-blueprint"
|
||||
value={blueprint}
|
||||
onChange={(e) => setBlueprint(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a blueprint
|
||||
</option>
|
||||
{blueprints.map((bp) => (
|
||||
<option key={bp} value={bp}>
|
||||
{bp}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{submitted && blueprint.length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Blueprint is required</p>
|
||||
)}
|
||||
|
||||
{/* Blueprint details info section */}
|
||||
{loadingDef && (
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprint details...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{blueprintDef !== null && !loadingDef && (
|
||||
<div className="mt-2 rounded-lg border border-border bg-surface-50 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-fg-muted">
|
||||
<Info className="h-3.5 w-3.5 text-brand-400" />
|
||||
Blueprint Details
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Blueprint</span>
|
||||
{loadingBlueprints ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprints...
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={form.blueprint}
|
||||
onChange={(event) => setForm({ ...form, blueprint: event.target.value })}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">Select a blueprint</option>
|
||||
{blueprints.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{form.submitted && form.blueprint.length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Blueprint is required</p>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Description from definition */}
|
||||
{(blueprintDef.description !== undefined || blueprintDef.desc !== undefined) && (
|
||||
<p className="mt-1.5 text-xs text-fg-muted">
|
||||
{String(blueprintDef.description ?? blueprintDef.desc)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Parameters schema */}
|
||||
{(() => {
|
||||
const paramsDef =
|
||||
blueprintDef.parameters ??
|
||||
blueprintDef.params ??
|
||||
blueprintDef["parameters"] ??
|
||||
blueprintDef["params"];
|
||||
if (paramsDef === undefined || paramsDef === null || typeof paramsDef !== "object") {
|
||||
return null;
|
||||
}
|
||||
const entries = Object.entries(paramsDef as Record<string, unknown>);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium text-fg-muted">Parameters</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
{entries.map(([name, schema]) => {
|
||||
const s = schema as Record<string, unknown> | null;
|
||||
const type = s?.type !== undefined ? String(s.type) : undefined;
|
||||
const defaultVal = s !== null && "default" in s ? s.default : undefined;
|
||||
const desc = s?.description !== undefined ? String(s.description) : undefined;
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="flex flex-wrap items-baseline gap-x-2 text-xs"
|
||||
>
|
||||
<span className="font-mono font-medium text-fg">{name}</span>
|
||||
{type !== undefined && (
|
||||
<span className="rounded bg-surface-200 px-1 py-0.5 text-[10px] text-fg-subtle">
|
||||
{type}
|
||||
</span>
|
||||
)}
|
||||
{defaultVal !== undefined && (
|
||||
<span className="text-fg-subtle">
|
||||
default: <span className="font-mono">{JSON.stringify(defaultVal)}</span>
|
||||
</span>
|
||||
)}
|
||||
{desc !== undefined && <span className="text-fg-subtle">- {desc}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Raw JSON toggle */}
|
||||
{blueprintDetail !== null && (
|
||||
<div className="rounded-lg border border-border bg-surface-50 p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDefExpanded((p) => !p)}
|
||||
className="mt-2 flex items-center gap-1 text-[11px] text-fg-subtle hover:text-fg-muted"
|
||||
onClick={() => setForm({ ...form, definitionExpanded: !form.definitionExpanded })}
|
||||
className="flex w-full items-center gap-1.5 text-left text-xs font-medium text-fg-muted"
|
||||
>
|
||||
{defExpanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
Raw definition
|
||||
{form.definitionExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<Info className="h-3.5 w-3.5 text-brand-400" />
|
||||
Blueprint Details
|
||||
</button>
|
||||
{defExpanded && (
|
||||
<pre className="mt-1 max-h-40 overflow-auto rounded border border-border bg-surface-100 p-2 font-mono text-[11px] text-fg-subtle">
|
||||
{JSON.stringify(blueprintDef, null, 2)}
|
||||
{form.definitionExpanded && (
|
||||
<pre className="mt-2 max-h-48 overflow-auto rounded-md bg-surface-100 p-2 font-mono text-[10px] text-fg-muted">
|
||||
{encodeJsonUnknownString(blueprintDetail)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label htmlFor="flow-description" className="block text-sm font-medium text-fg-muted">
|
||||
Description <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="flow-description"
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Human-readable description"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{submitted && description.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Description is required</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Parameters (JSON) */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="flow-params" className="block text-sm font-medium text-fg-muted">
|
||||
Parameters (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
id="flow-params"
|
||||
value={paramsJson}
|
||||
onChange={(e) => {
|
||||
setParamsJson(e.target.value);
|
||||
setParamsError(null);
|
||||
}}
|
||||
rows={4}
|
||||
className={cn(
|
||||
"w-full resize-none rounded-lg border bg-surface-100 px-3 py-2 font-mono text-xs text-fg placeholder:text-fg-subtle focus:outline-none focus:ring-1",
|
||||
paramsError !== null
|
||||
? "border-error focus:border-error focus:ring-error"
|
||||
: "border-border focus:border-brand-500 focus:ring-brand-500",
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Description</span>
|
||||
<input
|
||||
value={form.description}
|
||||
onChange={(event) => setForm({ ...form, description: event.target.value })}
|
||||
placeholder="What this flow does"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{form.submitted && form.description.trim().length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-400">Description is required</p>
|
||||
)}
|
||||
/>
|
||||
{paramsError !== null && (
|
||||
<p className="text-xs text-error">{paramsError}</p>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium text-fg-muted">Parameters JSON</span>
|
||||
<textarea
|
||||
value={form.paramsJson}
|
||||
onChange={(event) => setForm({ ...form, paramsJson: event.target.value, paramsError: null })}
|
||||
rows={6}
|
||||
className="w-full resize-none rounded-lg border border-border bg-surface-100 px-3 py-2 font-mono text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{form.paramsError !== null && <p className="mt-1 text-xs text-red-400">{form.paramsError}</p>}
|
||||
</label>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stop flow confirm dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StopFlowDialog({
|
||||
open,
|
||||
flowId,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
flowId: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
}) {
|
||||
const [stopping, setStopping] = useState(false);
|
||||
|
||||
const handleStop = async () => {
|
||||
setStopping(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (!stopping) onClose();
|
||||
}}
|
||||
title="Stop Flow"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={stopping}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={stopping}
|
||||
className="flex items-center gap-2 rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{stopping && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-error" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
Are you sure you want to stop flow{" "}
|
||||
<span className="font-mono font-medium text-fg">{flowId}</span>?
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flow detail row (expandable)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FlowRow({
|
||||
flow,
|
||||
onStop,
|
||||
}: {
|
||||
flow: FlowSummary;
|
||||
onStop: (id: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Determine all the extra keys beyond id/description
|
||||
const detailKeys = Object.keys(flow).filter(
|
||||
(k) => k !== "id" && k !== "description",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="cursor-pointer hover:bg-surface-100/50"
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-fg-subtle" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-fg-subtle" />
|
||||
)}
|
||||
<span className="font-mono text-sm text-fg">{flow.id}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-fg-muted">
|
||||
{(flow.description ?? "").length > 0 ? flow.description : "--"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="success">Running</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStop(flow.id);
|
||||
}}
|
||||
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error"
|
||||
title="Stop flow"
|
||||
aria-label={`Stop flow ${flow.id}`}
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Detail row */}
|
||||
{expanded && detailKeys.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="bg-surface-50 px-8 py-3">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
|
||||
{detailKeys.map((key) => (
|
||||
<div key={key}>
|
||||
<span className="font-medium text-fg-muted">{key}: </span>
|
||||
<span className="text-fg-subtle">
|
||||
{typeof flow[key] === "object"
|
||||
? JSON.stringify(flow[key])
|
||||
: String(flow[key] ?? "")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flows page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function FlowsPage() {
|
||||
const { flows, loading, error, getFlows, startFlow, stopFlow } = useFlows();
|
||||
const notify = useNotification();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [stopTarget, setStopTarget] = useState<string | null>(null);
|
||||
|
||||
// Auto-refresh every 10 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
getFlows();
|
||||
}, 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [getFlows]);
|
||||
|
||||
// Also refresh on window focus
|
||||
useEffect(() => {
|
||||
const handler = () => getFlows();
|
||||
window.addEventListener("focus", handler);
|
||||
return () => window.removeEventListener("focus", handler);
|
||||
}, [getFlows]);
|
||||
|
||||
const handleStart = async (
|
||||
id: string,
|
||||
blueprint: string,
|
||||
description: string,
|
||||
params: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
await startFlow(id, blueprint, description, params);
|
||||
notify.success("Flow started", `Flow "${id}" has been started.`);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to start flow",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
throw err; // re-throw so dialog stays open
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
if (stopTarget === null || stopTarget.length === 0) return;
|
||||
try {
|
||||
await stopFlow(stopTarget);
|
||||
notify.success("Flow stopped", `Flow "${stopTarget}" has been stopped.`);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to stop flow",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
setStopTarget(null);
|
||||
};
|
||||
const flowsResult = useAtomValue(flowsAtom);
|
||||
const refreshFlows = useAtomRefresh(flowsAtom);
|
||||
const [expanded, setExpanded] = useAtom(flowExpandedAtom);
|
||||
const setStartOpen = useAtomSet(flowsStartDialogOpenAtom);
|
||||
const stopFlow = useAtomSet(stopFlowAtom);
|
||||
const actionInProgress = useAtomValue(activeActionAtom);
|
||||
const flows = resultData(flowsResult, []);
|
||||
const loading = resultLoading(flowsResult, flows);
|
||||
const error = resultError(flowsResult);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Workflow className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Flows</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-muted">
|
||||
{flows.length} active
|
||||
</span>
|
||||
{!loading && <Badge>{flows.length} flow{flows.length !== 1 ? "s" : ""}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => getFlows()}
|
||||
onClick={refreshFlows}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
|
|
@ -587,16 +209,15 @@ export default function FlowsPage() {
|
|||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-500"
|
||||
onClick={() => setStartOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Start Flow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading && flows.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
|
|
@ -605,7 +226,7 @@ export default function FlowsPage() {
|
|||
)}
|
||||
|
||||
{error !== null && (
|
||||
<p role="alert" className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -613,50 +234,51 @@ export default function FlowsPage() {
|
|||
{!loading && error === null && flows.length === 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<Workflow className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No flows configured.</p>
|
||||
<p className="mt-1 text-xs text-fg-subtle">
|
||||
Click "Start Flow" to create one.
|
||||
</p>
|
||||
<p className="text-fg-subtle">No flows are running.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{flows.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-border bg-surface-100 text-fg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">ID</th>
|
||||
<th className="px-4 py-3 font-medium">Description</th>
|
||||
<th className="px-4 py-3 font-medium">Status</th>
|
||||
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{flows.map((flow) => (
|
||||
<FlowRow
|
||||
key={flow.id}
|
||||
flow={flow}
|
||||
onStop={(id) => setStopTarget(id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-3">
|
||||
{flows.map((flow) => {
|
||||
const isExpanded = expanded[flow.id] === true;
|
||||
return (
|
||||
<div key={flow.id} className="rounded-lg border border-border bg-surface-50">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<button
|
||||
onClick={() => setExpanded({ ...expanded, [flow.id]: !isExpanded })}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4 text-fg-subtle" /> : <ChevronRight className="h-4 w-4 text-fg-subtle" />}
|
||||
<span className="truncate font-mono text-sm font-medium text-fg">{flow.id}</span>
|
||||
{flow.description !== undefined && (
|
||||
<span className="truncate text-xs text-fg-muted">{flow.description}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => stopFlow(flow.id)}
|
||||
disabled={actionInProgress === flow.id}
|
||||
aria-label={`Stop flow ${flow.id}`}
|
||||
className="flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium text-error hover:bg-error/10 disabled:opacity-40"
|
||||
>
|
||||
{actionInProgress === flow.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Square className="h-3.5 w-3.5" />}
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
<pre className="max-h-96 overflow-auto rounded-md bg-surface-100 p-3 font-mono text-xs text-fg-muted">
|
||||
{encodeJsonUnknownString(flow)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<StartFlowDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onStart={handleStart}
|
||||
/>
|
||||
|
||||
<StopFlowDialog
|
||||
open={stopTarget !== null}
|
||||
flowId={stopTarget ?? ""}
|
||||
onClose={() => setStopTarget(null)}
|
||||
onConfirm={handleStop}
|
||||
/>
|
||||
<StartFlowDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,37 @@
|
|||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useAtom, useAtomRefresh, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
Rotate3d,
|
||||
Search,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize,
|
||||
Loader2,
|
||||
X,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useProgressStore } from "@/hooks/use-progress-store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Triple, Term } from "@trustgraph/client";
|
||||
import {
|
||||
termValue,
|
||||
flowIdAtom,
|
||||
graphTriplesAtom,
|
||||
graphViewAtom,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
settingsAtom,
|
||||
} from "@/atoms/workbench";
|
||||
import type { Triple } from "@trustgraph/client";
|
||||
import {
|
||||
localName,
|
||||
hashColor,
|
||||
triplesToGraph,
|
||||
RDFS_LABEL,
|
||||
RDF_TYPE,
|
||||
termValue,
|
||||
type GraphNode,
|
||||
type GraphLink,
|
||||
} from "@/lib/graph-utils";
|
||||
import type { ForceGraphProps } from "react-force-graph-2d";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy-load ForceGraph2D to keep bundle size down
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type {
|
||||
ForceGraphMethods,
|
||||
ForceGraphProps,
|
||||
} from "react-force-graph-2d";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<any, any> & { ref?: React.Ref<any> }>;
|
||||
|
||||
// Graph helpers imported from @/lib/graph-utils
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node detail panel
|
||||
// ---------------------------------------------------------------------------
|
||||
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<GraphNode, GraphLink>>;
|
||||
|
||||
function NodeDetailPanel({
|
||||
nodeId,
|
||||
|
|
@ -68,672 +46,243 @@ function NodeDetailPanel({
|
|||
labelMap: Map<string, string>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Find triples where this node is subject or object
|
||||
const related = useMemo(() => {
|
||||
const outbound: { predicate: string; object: string; objectLabel: string }[] = [];
|
||||
const inbound: { predicate: string; subject: string; subjectLabel: string }[] = [];
|
||||
const outbound: { predicate: string; object: string; objectLabel: string }[] = [];
|
||||
const inbound: { predicate: string; subject: string; subjectLabel: string }[] = [];
|
||||
|
||||
for (const t of triples) {
|
||||
const sVal = termValue(t.s);
|
||||
const pVal = termValue(t.p);
|
||||
const oVal = termValue(t.o);
|
||||
|
||||
if (pVal === RDFS_LABEL || pVal === RDF_TYPE) continue;
|
||||
|
||||
if (sVal === nodeId) {
|
||||
outbound.push({
|
||||
predicate: labelMap.get(pVal) ?? localName(pVal),
|
||||
object: oVal,
|
||||
objectLabel: labelMap.get(oVal) ?? localName(oVal),
|
||||
});
|
||||
}
|
||||
if (oVal === nodeId) {
|
||||
inbound.push({
|
||||
predicate: labelMap.get(pVal) ?? localName(pVal),
|
||||
subject: sVal,
|
||||
subjectLabel: labelMap.get(sVal) ?? localName(sVal),
|
||||
});
|
||||
}
|
||||
for (const triple of triples) {
|
||||
const subject = termValue(triple.s);
|
||||
const predicate = termValue(triple.p);
|
||||
const object = termValue(triple.o);
|
||||
if (predicate === RDFS_LABEL || predicate === RDF_TYPE) continue;
|
||||
if (subject === nodeId) {
|
||||
outbound.push({
|
||||
predicate: labelMap.get(predicate) ?? localName(predicate),
|
||||
object,
|
||||
objectLabel: labelMap.get(object) ?? localName(object),
|
||||
});
|
||||
}
|
||||
return { outbound, inbound };
|
||||
}, [nodeId, triples, labelMap]);
|
||||
if (object === nodeId) {
|
||||
inbound.push({
|
||||
predicate: labelMap.get(predicate) ?? localName(predicate),
|
||||
subject,
|
||||
subjectLabel: labelMap.get(subject) ?? localName(subject),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-80 shrink-0 flex-col border-l border-border bg-surface-50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h3 className="truncate text-sm font-semibold text-fg">{label}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
aria-label="Close detail panel"
|
||||
>
|
||||
<aside className="absolute right-4 top-4 z-20 max-h-[calc(100%-2rem)] w-96 overflow-y-auto rounded-lg border border-border bg-surface-50 shadow-xl">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold text-fg">{label}</h2>
|
||||
<p className="break-all font-mono text-[10px] text-fg-subtle">{nodeId}</p>
|
||||
</div>
|
||||
<button onClick={onClose} aria-label="Close node details" className="rounded p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<p className="mb-3 truncate font-mono text-[10px] text-fg-subtle">
|
||||
{nodeId}
|
||||
</p>
|
||||
|
||||
{/* Outbound relationships */}
|
||||
{related.outbound.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-fg-muted">
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
Outbound ({related.outbound.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{related.outbound.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1.5 rounded bg-surface-100 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<Badge variant="default">{r.predicate}</Badge>
|
||||
<span className="truncate text-fg-muted">{r.objectLabel}</span>
|
||||
<div className="space-y-4 p-4">
|
||||
{outbound.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-subtle">Outgoing</h3>
|
||||
<div className="space-y-2">
|
||||
{outbound.map((edge, index) => (
|
||||
<div key={`${edge.object}-${index}`} className="rounded-md bg-surface-100 p-2 text-xs">
|
||||
<p className="text-fg-muted">{edge.predicate}</p>
|
||||
<p className="mt-0.5 break-all font-mono text-fg">{edge.objectLabel}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Inbound relationships */}
|
||||
{related.inbound.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-fg-muted">
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
Inbound ({related.inbound.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{related.inbound.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1.5 rounded bg-surface-100 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span className="truncate text-fg-muted">{r.subjectLabel}</span>
|
||||
<Badge variant="default">{r.predicate}</Badge>
|
||||
{inbound.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-fg-subtle">Incoming</h3>
|
||||
<div className="space-y-2">
|
||||
{inbound.map((edge, index) => (
|
||||
<div key={`${edge.subject}-${index}`} className="rounded-md bg-surface-100 p-2 text-xs">
|
||||
<p className="text-fg-muted">{edge.predicate}</p>
|
||||
<p className="mt-0.5 break-all font-mono text-fg">{edge.subjectLabel}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{related.outbound.length === 0 && related.inbound.length === 0 && (
|
||||
<p className="text-xs text-fg-subtle">No relationships found.</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph page
|
||||
// ---------------------------------------------------------------------------
|
||||
function paintNode(showLabels: boolean) {
|
||||
return (node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.2);
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = node.color ?? "#5b80ff";
|
||||
ctx.fill();
|
||||
if (!showLabels || globalScale < 0.7) return;
|
||||
const fontSize = Math.max(10 / globalScale, 2);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const light = document.documentElement.classList.contains("light");
|
||||
ctx.fillStyle = light ? "rgba(24,24,27,0.85)" : "rgba(250,250,250,0.85)";
|
||||
ctx.fillText(node.label, x, y + radius + 1);
|
||||
};
|
||||
}
|
||||
|
||||
function paintLink(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) {
|
||||
if (globalScale < 1.5) return;
|
||||
const source = link.source as unknown as GraphNode;
|
||||
const target = link.target as unknown as GraphNode;
|
||||
if (source.x === undefined || source.y === undefined || target.x === undefined || target.y === undefined) return;
|
||||
const midX = (source.x + target.x) / 2;
|
||||
const midY = (source.y + target.y) / 2;
|
||||
const fontSize = Math.max(8 / globalScale, 2);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(161,161,170,0.65)";
|
||||
ctx.fillText(link.label, midX, midY);
|
||||
}
|
||||
|
||||
export default function GraphPage() {
|
||||
const socket = useSocket();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const [triples, setTriples] = useState<Triple[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
|
||||
// Query filters
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [subjectFilter, setSubjectFilter] = useState("");
|
||||
const [predicateFilter, setPredicateFilter] = useState("");
|
||||
const [objectFilter, setObjectFilter] = useState("");
|
||||
const [tripleLimit, setTripleLimit] = useState(2000);
|
||||
const [showLegend, setShowLegend] = useState(false);
|
||||
const hasActiveFilters =
|
||||
subjectFilter.length > 0 ||
|
||||
predicateFilter.length > 0 ||
|
||||
objectFilter.length > 0;
|
||||
|
||||
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [containerSize, setContainerSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
// Auto-fit tracking — declared early so fetchTriples can reset it
|
||||
const hasAutoFit = useRef(false);
|
||||
|
||||
// Ref callback — attaches ResizeObserver when the container mounts
|
||||
const containerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
// Disconnect previous observer
|
||||
if (roRef.current !== null) {
|
||||
roRef.current.disconnect();
|
||||
roRef.current = null;
|
||||
}
|
||||
if (el === null) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry !== undefined) {
|
||||
const { width, height } = entry.contentRect;
|
||||
setContainerSize({ width: Math.floor(width), height: Math.floor(height) });
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
roRef.current = ro;
|
||||
}, []);
|
||||
|
||||
// Fetch triples with optional filters
|
||||
const fetchTriples = useCallback(async () => {
|
||||
const act = "Load graph";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
hasAutoFit.current = false;
|
||||
|
||||
const flow = socket.flow(flowId);
|
||||
const s: Term | undefined = subjectFilter.length > 0 ? { t: "i", i: subjectFilter } : undefined;
|
||||
const p: Term | undefined = predicateFilter.length > 0 ? { t: "i", i: predicateFilter } : undefined;
|
||||
const o: Term | undefined = objectFilter.length > 0 ? { t: "i", i: objectFilter } : undefined;
|
||||
|
||||
const result = await flow.triplesQuery(
|
||||
s,
|
||||
p,
|
||||
o,
|
||||
tripleLimit,
|
||||
collection,
|
||||
);
|
||||
setTriples(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, flowId, collection, subjectFilter, predicateFilter, objectFilter, tripleLimit, addActivity, removeActivity]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTriples();
|
||||
}, [fetchTriples]);
|
||||
|
||||
// Build graph
|
||||
const { data: graphData, labelMap, typeMap } = useMemo(
|
||||
() => triplesToGraph(Array.isArray(triples) ? triples : []),
|
||||
[triples],
|
||||
);
|
||||
|
||||
// Unique types for legend
|
||||
const uniqueTypes = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const [, typeUri] of typeMap) {
|
||||
const name = localName(typeUri);
|
||||
if (!seen.has(name)) {
|
||||
seen.set(name, typeUri);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.entries());
|
||||
}, [typeMap]);
|
||||
|
||||
// Search filter -- highlight matching nodes
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchingIds = useMemo(() => {
|
||||
if (searchLower.length === 0) return new Set<string>();
|
||||
return new Set(
|
||||
graphData.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
n.label.toLowerCase().includes(searchLower) ||
|
||||
n.id.toLowerCase().includes(searchLower),
|
||||
)
|
||||
.map((n) => n.id),
|
||||
);
|
||||
}, [graphData.nodes, searchLower]);
|
||||
|
||||
const selectedLabel = selectedNode !== null
|
||||
? labelMap.get(selectedNode) ?? localName(selectedNode)
|
||||
: "";
|
||||
|
||||
// Auto-fit graph to view once data loads
|
||||
useEffect(() => {
|
||||
if (
|
||||
graphData.nodes.length > 0 &&
|
||||
fgRef.current !== undefined &&
|
||||
hasAutoFit.current === false
|
||||
) {
|
||||
hasAutoFit.current = true;
|
||||
// Wait for force simulation to settle briefly before fitting
|
||||
const timer = setTimeout(() => fgRef.current?.zoomToFit(400, 40), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [graphData.nodes.length]);
|
||||
|
||||
// Zoom helpers
|
||||
const zoomIn = () => fgRef.current?.zoom(2, 300);
|
||||
const zoomOut = () => fgRef.current?.zoom(0.5, 300);
|
||||
const zoomFit = () =>
|
||||
fgRef.current?.zoomToFit(400, 40);
|
||||
|
||||
// Node paint callback — with glow effect
|
||||
const paintNode = useCallback(
|
||||
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const isSelected = node.id === selectedNode;
|
||||
const isMatch = matchingIds.size > 0 && matchingIds.has(node.id);
|
||||
const dim = matchingIds.size > 0 && !isMatch && !isSelected;
|
||||
|
||||
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.5);
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
|
||||
const baseColor = dim
|
||||
? "rgba(100,100,100,0.3)"
|
||||
: isSelected
|
||||
? "#fbbf24"
|
||||
: isMatch
|
||||
? "#22c55e"
|
||||
: node.color ?? "#5b80ff";
|
||||
|
||||
// Outer glow (only when not dimmed)
|
||||
if (!dim) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = baseColor;
|
||||
ctx.shadowBlur = isSelected ? 16 : 8;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = baseColor;
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Node circle (crisp, on top of glow)
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = baseColor;
|
||||
ctx.fill();
|
||||
|
||||
// Inner highlight (subtle white dot for depth)
|
||||
if (!dim && radius > 3) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x - radius * 0.25, y - radius * 0.25, radius * 0.3, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = "rgba(255,255,255,0.2)";
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (isSelected || isMatch) {
|
||||
ctx.strokeStyle = isSelected ? "#fbbf24" : "#22c55e";
|
||||
ctx.lineWidth = 1.5 / globalScale;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Label
|
||||
const fontSize = Math.max(10 / globalScale, 2);
|
||||
ctx.font = `600 ${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const isLight = document.documentElement.classList.contains("light");
|
||||
ctx.fillStyle = dim
|
||||
? "rgba(100,100,100,0.3)"
|
||||
: isLight
|
||||
? "rgba(24,24,27,0.9)"
|
||||
: "rgba(250,250,250,0.9)";
|
||||
ctx.fillText(node.label, x, y + radius + 2);
|
||||
},
|
||||
[selectedNode, matchingIds],
|
||||
);
|
||||
|
||||
// Link label painting
|
||||
const paintLink = useCallback(
|
||||
(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
if (globalScale < 1.5) return; // only show labels when zoomed in enough
|
||||
|
||||
const src = link.source as unknown as GraphNode;
|
||||
const tgt = link.target as unknown as GraphNode;
|
||||
if (
|
||||
src.x === undefined ||
|
||||
src.y === undefined ||
|
||||
tgt.x === undefined ||
|
||||
tgt.y === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const midX = ((src.x ?? 0) + (tgt.x ?? 0)) / 2;
|
||||
const midY = ((src.y ?? 0) + (tgt.y ?? 0)) / 2;
|
||||
|
||||
const fontSize = Math.max(8 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(161,161,170,0.7)";
|
||||
ctx.fillText(link.label, midX, midY);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const flowId = useAtomValue(flowIdAtom);
|
||||
const collection = useAtomValue(settingsAtom).collection;
|
||||
const [view, setView] = useAtom(graphViewAtom);
|
||||
const triplesResult = useAtomValue(graphTriplesAtom({ flowId, collection, limit: view.nodeLimit }));
|
||||
const refresh = useAtomRefresh(graphTriplesAtom({ flowId, collection, limit: view.nodeLimit }));
|
||||
const triples = resultData(triplesResult, []);
|
||||
const loading = resultLoading(triplesResult, triples);
|
||||
const error = resultError(triplesResult);
|
||||
const { data, labelMap, typeMap } = triplesToGraph(triples);
|
||||
const search = view.searchTerm.trim().toLowerCase();
|
||||
const graphData = search.length === 0
|
||||
? data
|
||||
: (() => {
|
||||
const nodes = data.nodes.filter((node) => node.label.toLowerCase().includes(search) || node.id.toLowerCase().includes(search));
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
return {
|
||||
nodes,
|
||||
links: data.links.filter((link) => {
|
||||
const source = typeof link.source === "string" ? link.source : (link.source as GraphNode).id;
|
||||
const target = typeof link.target === "string" ? link.target : (link.target as GraphNode).id;
|
||||
return nodeIds.has(source) && nodeIds.has(target);
|
||||
}),
|
||||
};
|
||||
})();
|
||||
const selectedNode = view.selectedNodeId !== null
|
||||
? data.nodes.find((node) => node.id === view.selectedNodeId)
|
||||
: undefined;
|
||||
const uniqueTypes = Array.from(new Set(Array.from(typeMap.values()).map(localName))).sort();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Rotate3d className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Graph</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-muted">
|
||||
{graphData.nodes.length} nodes, {graphData.links.length} edges
|
||||
</span>
|
||||
<Badge>{graphData.nodes.length} nodes</Badge>
|
||||
<Badge>{graphData.links.length} edges</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-subtle" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-surface-100 px-3 py-2">
|
||||
<Search className="h-4 w-4 text-fg-subtle" />
|
||||
<input
|
||||
id="graph-search"
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search nodes..."
|
||||
aria-label="Search nodes"
|
||||
className="w-48 rounded-lg border border-border bg-surface-100 py-1.5 pl-8 pr-3 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
value={view.searchTerm}
|
||||
onChange={(event) => setView({ ...view, searchTerm: event.target.value })}
|
||||
placeholder="Search graph..."
|
||||
className="w-48 bg-transparent text-sm text-fg placeholder:text-fg-subtle focus:outline-none"
|
||||
/>
|
||||
{searchTerm.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="flex rounded-lg border border-border bg-surface-100">
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
className="px-2 py-1.5 text-fg-muted hover:text-fg"
|
||||
title="Zoom in"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
className="border-l border-r border-border px-2 py-1.5 text-fg-muted hover:text-fg"
|
||||
title="Zoom out"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={zoomFit}
|
||||
className="px-2 py-1.5 text-fg-muted hover:text-fg"
|
||||
title="Fit to view"
|
||||
aria-label="Fit to view"
|
||||
>
|
||||
<Maximize className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters((p) => !p)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors",
|
||||
showFilters || hasActiveFilters
|
||||
? "border-brand-500/50 bg-brand-600/10 text-brand-400"
|
||||
: "border-border text-fg-muted hover:bg-surface-200",
|
||||
)}
|
||||
title="Query filters"
|
||||
aria-label="Toggle query filters"
|
||||
aria-expanded={showFilters}
|
||||
<select
|
||||
value={view.nodeLimit}
|
||||
onChange={(event) => setView({ ...view, nodeLimit: Number(event.target.value) })}
|
||||
aria-label="Node limit"
|
||||
className="rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none"
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
Filters
|
||||
{hasActiveFilters && !showFilters && (
|
||||
<span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-brand-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Legend toggle */}
|
||||
{uniqueTypes.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowLegend((p) => !p)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors",
|
||||
showLegend
|
||||
? "border-brand-500/50 bg-brand-600/10 text-brand-400"
|
||||
: "border-border text-fg-muted hover:bg-surface-200",
|
||||
)}
|
||||
title="Type legend"
|
||||
aria-label="Toggle type legend"
|
||||
aria-expanded={showLegend}
|
||||
>
|
||||
Legend
|
||||
</button>
|
||||
)}
|
||||
|
||||
{[100, 250, 500, 1000].map((limit) => <option key={limit} value={limit}>{limit}</option>)}
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchTriples}
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
aria-label="Refresh graph"
|
||||
className="rounded-lg border border-border px-3 py-2 text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Rotate3d className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Reload
|
||||
<RefreshCwIcon loading={loading} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
{showFilters && (
|
||||
<div className="mb-4 rounded-lg border border-border bg-surface-50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 text-xs font-medium text-fg-muted">
|
||||
<Filter className="h-3 w-3" />
|
||||
Query Filters
|
||||
</h3>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubjectFilter("");
|
||||
setPredicateFilter("");
|
||||
setObjectFilter("");
|
||||
}}
|
||||
className="text-xs text-brand-400 hover:text-brand-300"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="filter-subject" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
id="filter-subject"
|
||||
type="text"
|
||||
value={subjectFilter}
|
||||
onChange={(e) => setSubjectFilter(e.target.value)}
|
||||
placeholder="URI filter..."
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="filter-predicate" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Predicate
|
||||
</label>
|
||||
<input
|
||||
id="filter-predicate"
|
||||
type="text"
|
||||
value={predicateFilter}
|
||||
onChange={(e) => setPredicateFilter(e.target.value)}
|
||||
placeholder="URI filter..."
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="filter-object" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Object
|
||||
</label>
|
||||
<input
|
||||
id="filter-object"
|
||||
type="text"
|
||||
value={objectFilter}
|
||||
onChange={(e) => setObjectFilter(e.target.value)}
|
||||
placeholder="URI filter..."
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="filter-limit" className="text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Limit
|
||||
</label>
|
||||
<input
|
||||
id="filter-limit"
|
||||
type="range"
|
||||
min={100}
|
||||
max={5000}
|
||||
step={100}
|
||||
value={tripleLimit}
|
||||
onChange={(e) => setTripleLimit(Number(e.target.value))}
|
||||
className="w-24 accent-brand-500"
|
||||
/>
|
||||
<span className="text-xs text-fg-muted">{tripleLimit}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchTriples}
|
||||
disabled={loading}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">{error}</p>
|
||||
)}
|
||||
|
||||
{loading && triples.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
<span className="text-fg-subtle">Loading graph data...</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => setView({ ...view, showLabels: !view.showLabels })}
|
||||
className={cn("flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs", view.showLabels ? "bg-brand-600/10 text-brand-400" : "text-fg-muted")}
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
Labels
|
||||
</button>
|
||||
{uniqueTypes.slice(0, 8).map((type) => <Badge key={type} variant="info">{type}</Badge>)}
|
||||
</div>
|
||||
|
||||
{!loading && graphData.nodes.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-border">
|
||||
<div className="text-center">
|
||||
<Rotate3d className="mx-auto mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No graph data in this collection.</p>
|
||||
<p className="mt-1 text-xs text-fg-subtle">
|
||||
Upload documents and process them to populate the knowledge graph.
|
||||
</p>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-surface-50">
|
||||
{loading && triples.length === 0 && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-surface-50">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
<span className="text-fg-subtle">Loading graph...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{graphData.nodes.length > 0 && (
|
||||
<div className="relative flex flex-1 overflow-hidden rounded-lg border border-border">
|
||||
{/* Graph canvas */}
|
||||
<div ref={containerRef} className="relative min-w-0 flex-1 bg-surface-0">
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center"><Loader2 className="h-5 w-5 animate-spin text-fg-subtle" /></div>}>
|
||||
{!loading && graphData.nodes.length === 0 && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||
<Rotate3d className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No graph triples available.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{graphData.nodes.length > 0 && (
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center text-fg-subtle">Loading graph renderer...</div>}>
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
graphData={graphData}
|
||||
nodeCanvasObject={paintNode}
|
||||
nodePointerAreaPaint={(node: GraphNode, color, ctx) => {
|
||||
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.5);
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x ?? 0, node.y ?? 0, radius + 2, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}}
|
||||
width={900}
|
||||
height={650}
|
||||
backgroundColor="rgba(0,0,0,0)"
|
||||
nodeCanvasObject={paintNode(view.showLabels)}
|
||||
linkCanvasObjectMode={() => "after"}
|
||||
linkCanvasObject={paintLink}
|
||||
linkColor={() => "rgba(91,128,255,0.18)"}
|
||||
linkWidth={1.5}
|
||||
linkDirectionalArrowLength={5}
|
||||
linkDirectionalArrowRelPos={0.85}
|
||||
linkDirectionalArrowColor={() => "rgba(91,128,255,0.5)"}
|
||||
linkDirectionalParticles={2}
|
||||
linkDirectionalParticleWidth={2}
|
||||
linkDirectionalParticleSpeed={0.004}
|
||||
linkDirectionalParticleColor={() => "rgba(91,128,255,0.6)"}
|
||||
linkCurvature={0.1}
|
||||
onNodeClick={(node: GraphNode) => {
|
||||
setSelectedNode((prev) =>
|
||||
prev === node.id ? null : node.id,
|
||||
);
|
||||
linkColor={() => "rgba(120,120,140,0.32)"}
|
||||
nodePointerAreaPaint={(node, color, ctx) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x ?? 0, node.y ?? 0, Math.max(6, Math.sqrt(node.degree + 1) * 3), 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}}
|
||||
onBackgroundClick={() => setSelectedNode(null)}
|
||||
backgroundColor="transparent"
|
||||
cooldownTicks={100}
|
||||
warmupTicks={30}
|
||||
{...(containerSize !== null
|
||||
? { width: containerSize.width, height: containerSize.height }
|
||||
: {})}
|
||||
onNodeClick={(node) => setView({ ...view, selectedNodeId: node.id, selectedNodeLabel: node.label })}
|
||||
/>
|
||||
</Suspense>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Search results badge overlay */}
|
||||
{searchTerm.length > 0 && matchingIds.size > 0 && (
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<Badge variant="success">
|
||||
{matchingIds.size} match{matchingIds.size > 1 ? "es" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type legend overlay */}
|
||||
{showLegend && uniqueTypes.length > 0 && (
|
||||
<div className="absolute bottom-3 left-3 z-10 max-h-48 overflow-y-auto rounded-lg border border-border bg-surface-50/95 px-3 py-2 shadow-lg backdrop-blur-sm">
|
||||
<h4 className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Node Types
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{uniqueTypes.map(([name]) => (
|
||||
<div key={name} className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: hashColor(name) }}
|
||||
/>
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail panel -- positioned absolutely so it overlays the graph */}
|
||||
{selectedNode !== null && (
|
||||
<div className="absolute inset-y-0 right-0 z-10">
|
||||
<NodeDetailPanel
|
||||
nodeId={selectedNode}
|
||||
label={selectedLabel}
|
||||
triples={triples}
|
||||
labelMap={labelMap}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectedNode !== undefined && view.selectedNodeId !== null && (
|
||||
<NodeDetailPanel
|
||||
nodeId={view.selectedNodeId}
|
||||
label={view.selectedNodeLabel ?? selectedNode.label}
|
||||
triples={triples}
|
||||
labelMap={labelMap}
|
||||
onClose={() => setView({ ...view, selectedNodeId: null, selectedNodeLabel: null })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshCwIcon({ loading }: { loading: boolean }) {
|
||||
return loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
BrainCircuit,
|
||||
Loader2,
|
||||
|
|
@ -8,30 +8,30 @@ import {
|
|||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import {
|
||||
activeActionAtom,
|
||||
deleteKgCoreAtom,
|
||||
kgCoresAtom,
|
||||
knowledgeDeleteTargetAtom,
|
||||
loadKgCoreAtom,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
} from "@/atoms/workbench";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete confirmation dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DeleteCoreDialog({
|
||||
open,
|
||||
coreId,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
coreId: string;
|
||||
coreId: string | null;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
open={coreId !== null}
|
||||
onClose={onClose}
|
||||
title="Delete Knowledge Core"
|
||||
footer={
|
||||
|
|
@ -55,7 +55,7 @@ function DeleteCoreDialog({
|
|||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-error" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
Are you sure you want to delete knowledge core{" "}
|
||||
<span className="font-mono font-medium text-fg">{coreId}</span>?
|
||||
<span className="font-mono font-medium text-fg">{coreId ?? ""}</span>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -63,93 +63,20 @@ function DeleteCoreDialog({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Knowledge Cores page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function KnowledgeCoresPage() {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const notify = useNotification();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const result = useAtomValue(kgCoresAtom);
|
||||
const refresh = useAtomRefresh(kgCoresAtom);
|
||||
const loadCore = useAtomSet(loadKgCoreAtom);
|
||||
const deleteCore = useAtomSet(deleteKgCoreAtom);
|
||||
const [deleteTarget, setDeleteTarget] = useAtom(knowledgeDeleteTargetAtom);
|
||||
const actionInProgress = useAtomValue(activeActionAtom);
|
||||
|
||||
const [cores, setCores] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
|
||||
|
||||
const loadCores = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Request timed out")), 15000),
|
||||
);
|
||||
const ids = await Promise.race([
|
||||
socket.knowledge().getKnowledgeCores(),
|
||||
timeoutPromise,
|
||||
]);
|
||||
setCores(Array.isArray(ids) ? ids : []);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
console.error("Failed to load knowledge cores:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
// Auto-load when connected
|
||||
useEffect(() => {
|
||||
const connected =
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated";
|
||||
if (connected) {
|
||||
loadCores();
|
||||
}
|
||||
}, [connectionState.status, loadCores]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
async (id: string) => {
|
||||
setActionInProgress(id);
|
||||
try {
|
||||
await socket.knowledge().loadKgCore(id, flowId);
|
||||
notify.success("Core loaded", `Knowledge core "${id}" has been loaded.`);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to load core",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setActionInProgress(null);
|
||||
}
|
||||
},
|
||||
[socket, flowId, notify],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (deleteTarget === null || deleteTarget.length === 0) return;
|
||||
setActionInProgress(deleteTarget);
|
||||
try {
|
||||
await socket.knowledge().deleteKgCore(deleteTarget);
|
||||
notify.success("Core deleted", `Knowledge core "${deleteTarget}" has been deleted.`);
|
||||
await loadCores();
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to delete core",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setActionInProgress(null);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}, [socket, deleteTarget, notify, loadCores]);
|
||||
const cores = resultData(result, []);
|
||||
const loading = resultLoading(result, cores);
|
||||
const error = resultError(result);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<BrainCircuit className="h-6 w-6 text-brand-400" />
|
||||
|
|
@ -162,7 +89,7 @@ export default function KnowledgeCoresPage() {
|
|||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadCores}
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
|
|
@ -171,7 +98,6 @@ export default function KnowledgeCoresPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading && cores.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
|
|
@ -179,7 +105,7 @@ export default function KnowledgeCoresPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error !== null && error.length > 0 && (
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
|
|
@ -210,7 +136,7 @@ export default function KnowledgeCoresPage() {
|
|||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleLoad(id)}
|
||||
onClick={() => loadCore(id)}
|
||||
disabled={actionInProgress === id}
|
||||
className="flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium text-brand-400 hover:bg-brand-600/10 disabled:opacity-40"
|
||||
title="Load core"
|
||||
|
|
@ -242,12 +168,13 @@ export default function KnowledgeCoresPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<DeleteCoreDialog
|
||||
open={deleteTarget != null}
|
||||
coreId={deleteTarget ?? ""}
|
||||
coreId={deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget !== null) deleteCore(deleteTarget);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useAtom, useAtomRefresh, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
MessageCircleCode,
|
||||
Loader2,
|
||||
|
|
@ -9,47 +9,40 @@ import {
|
|||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePrompts } from "@/hooks/use-prompts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompts page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Tab = "templates" | "system";
|
||||
import {
|
||||
promptActiveTabAtom,
|
||||
promptDetailAtom,
|
||||
promptsAtom,
|
||||
resultData,
|
||||
resultError,
|
||||
resultLoading,
|
||||
selectedPromptIdAtom,
|
||||
systemPromptAtom,
|
||||
} from "@/atoms/workbench";
|
||||
|
||||
export default function PromptsPage() {
|
||||
const { prompts, systemPrompt, loading, error, loadPrompts, loadSystemPrompt, getPrompt } = usePrompts();
|
||||
const promptsResult = useAtomValue(promptsAtom);
|
||||
const systemPromptResult = useAtomValue(systemPromptAtom);
|
||||
const refreshPrompts = useAtomRefresh(promptsAtom);
|
||||
const refreshSystemPrompt = useAtomRefresh(systemPromptAtom);
|
||||
const [activeTab, setActiveTab] = useAtom(promptActiveTabAtom);
|
||||
const [selectedPromptId, setSelectedPromptId] = useAtom(selectedPromptIdAtom);
|
||||
const promptDetailResult = useAtomValue(promptDetailAtom(selectedPromptId ?? ""));
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>("templates");
|
||||
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
|
||||
const [promptDetail, setPromptDetail] = useState<{ system?: string; prompt?: string } | string | null>(null);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const prompts = resultData(promptsResult, []);
|
||||
const systemPrompt = resultData(systemPromptResult, "");
|
||||
const loading = resultLoading(promptsResult, prompts) || resultLoading(systemPromptResult, systemPrompt);
|
||||
const error = resultError(promptsResult) ?? resultError(systemPromptResult);
|
||||
const promptDetail = resultData(promptDetailResult, null) as { system?: string; prompt?: string } | string | null;
|
||||
const loadingDetail = selectedPromptId !== null && resultLoading(promptDetailResult, promptDetail);
|
||||
|
||||
const handleSelectPrompt = useCallback(
|
||||
async (id: string) => {
|
||||
setSelectedPromptId(id);
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const detail = await getPrompt(id);
|
||||
setPromptDetail(detail as typeof promptDetail);
|
||||
} catch (err) {
|
||||
console.error("Failed to load prompt detail:", err);
|
||||
setPromptDetail("Error loading prompt.");
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
},
|
||||
[getPrompt],
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
loadPrompts();
|
||||
loadSystemPrompt();
|
||||
}, [loadPrompts, loadSystemPrompt]);
|
||||
const refresh = () => {
|
||||
refreshPrompts();
|
||||
refreshSystemPrompt();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageCircleCode className="h-6 w-6 text-brand-400" />
|
||||
|
|
@ -57,7 +50,7 @@ export default function PromptsPage() {
|
|||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
|
|
@ -66,7 +59,6 @@ export default function PromptsPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div role="tablist" aria-label="Prompt sections" className="mb-4 flex gap-1 rounded-lg bg-surface-100 p-1">
|
||||
<button
|
||||
id="tab-templates"
|
||||
|
|
@ -75,9 +67,7 @@ export default function PromptsPage() {
|
|||
onClick={() => setActiveTab("templates")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors",
|
||||
activeTab === "templates"
|
||||
? "bg-surface-50 text-fg shadow-sm"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
activeTab === "templates" ? "bg-surface-50 text-fg shadow-sm" : "text-fg-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
|
|
@ -90,9 +80,7 @@ export default function PromptsPage() {
|
|||
onClick={() => setActiveTab("system")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors",
|
||||
activeTab === "system"
|
||||
? "bg-surface-50 text-fg shadow-sm"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
activeTab === "system" ? "bg-surface-50 text-fg shadow-sm" : "text-fg-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
|
|
@ -100,14 +88,12 @@ export default function PromptsPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error !== null && error.length > 0 && (
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Templates tab */}
|
||||
{activeTab === "templates" && (
|
||||
<div id="panel-templates" role="tabpanel" aria-labelledby="tab-templates" tabIndex={0} className="flex flex-1 flex-col gap-4 overflow-hidden">
|
||||
{loading && prompts.length === 0 && (
|
||||
|
|
@ -125,21 +111,20 @@ export default function PromptsPage() {
|
|||
)}
|
||||
|
||||
{prompts.length > 0 && (
|
||||
<div className="flex flex-1 gap-4 overflow-hidden">
|
||||
{/* Prompt list */}
|
||||
<div className="w-80 shrink-0 overflow-y-auto rounded-lg border border-border">
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden lg:flex-row">
|
||||
<div className="max-h-56 w-full shrink-0 overflow-y-auto rounded-lg border border-border lg:max-h-none lg:w-80">
|
||||
<div className="border-b border-border bg-surface-100 px-4 py-3">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wider text-fg-muted">
|
||||
Templates ({prompts.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{prompts.map((p) => {
|
||||
const id = p.id ?? (p as Record<string, unknown>).name ?? String(p);
|
||||
{prompts.map((prompt) => {
|
||||
const id = prompt.id ?? (prompt as Record<string, unknown>).name ?? String(prompt);
|
||||
return (
|
||||
<button
|
||||
key={String(id)}
|
||||
onClick={() => handleSelectPrompt(String(id))}
|
||||
onClick={() => setSelectedPromptId(String(id))}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between px-4 py-3 text-left text-sm transition-colors",
|
||||
selectedPromptId === String(id)
|
||||
|
|
@ -155,8 +140,7 @@ export default function PromptsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompt detail */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
|
||||
<div className="min-h-0 flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
|
||||
{selectedPromptId !== null && selectedPromptId.length > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between border-b border-border bg-surface-100 px-4 py-3">
|
||||
|
|
@ -164,10 +148,8 @@ export default function PromptsPage() {
|
|||
<span className="font-mono">{selectedPromptId}</span>
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPromptId(null);
|
||||
setPromptDetail("");
|
||||
}}
|
||||
onClick={() => setSelectedPromptId(null)}
|
||||
aria-label="Close prompt detail"
|
||||
className="rounded-md p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -220,7 +202,6 @@ export default function PromptsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* System Prompt tab */}
|
||||
{activeTab === "system" && (
|
||||
<div id="panel-system" role="tabpanel" aria-labelledby="tab-system" tabIndex={0} className="flex flex-1 flex-col overflow-hidden rounded-lg border border-border">
|
||||
<div className="border-b border-border bg-surface-100 px-4 py-3">
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue