diff --git a/ai-context/trustgraph-client/.github/workflows/ci.yml b/ai-context/trustgraph-client/.github/workflows/ci.yml new file mode 100644 index 00000000..67cee0e9 --- /dev/null +++ b/ai-context/trustgraph-client/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install dependencies + run: npm install + + - name: Type check + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Run tests + run: npm test + + - name: Build + run: npm run build diff --git a/ai-context/trustgraph-client/.github/workflows/publish.yml b/ai-context/trustgraph-client/.github/workflows/publish.yml new file mode 100644 index 00000000..fd681ba0 --- /dev/null +++ b/ai-context/trustgraph-client/.github/workflows/publish.yml @@ -0,0 +1,51 @@ +name: Publish to npm + +on: + push: + tags: + - 'v*' + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Upgrade npm for OIDC support + run: npm install -g npm@latest + + - name: Install dependencies + run: npm install + + - name: Type check + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Run tests + run: npm test + + - name: Build + run: npm run build + + - name: Verify version matches tag + run: | + TAG_VERSION=${GITHUB_REF#refs/tags/v} + PKG_VERSION=$(node -p "require('./package.json').version") + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "Tag version ($TAG_VERSION) doesn't match package.json ($PKG_VERSION)" + exit 1 + fi + + - name: Publish to npm + run: npm publish --access public --provenance diff --git a/ai-context/trustgraph-client/.gitignore b/ai-context/trustgraph-client/.gitignore new file mode 100644 index 00000000..58aa58fb --- /dev/null +++ b/ai-context/trustgraph-client/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.log +.DS_Store +*.tsbuildinfo +package-lock.json +*~ diff --git a/ai-context/trustgraph-client/.prettierrc b/ai-context/trustgraph-client/.prettierrc new file mode 100644 index 00000000..8b6b8803 --- /dev/null +++ b/ai-context/trustgraph-client/.prettierrc @@ -0,0 +1,3 @@ +{ + "printWidth": 79 +} diff --git a/ai-context/trustgraph-client/LICENSE b/ai-context/trustgraph-client/LICENSE new file mode 100644 index 00000000..d9a10c0d --- /dev/null +++ b/ai-context/trustgraph-client/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/ai-context/trustgraph-client/README.md b/ai-context/trustgraph-client/README.md new file mode 100644 index 00000000..cb25bc94 --- /dev/null +++ b/ai-context/trustgraph-client/README.md @@ -0,0 +1,319 @@ +# @trustgraph/client + +TypeScript/JavaScript client library for TrustGraph WebSocket API. This package provides a framework-agnostic client for communicating with TrustGraph services. + +## Features + +- 🌐 **WebSocket-based** - Real-time communication with TrustGraph services +- šŸ“¦ **Zero Dependencies** - No external runtime dependencies +- šŸ” **Authentication Support** - Optional API key authentication +- šŸ”„ **Auto-reconnection** - Handles connection failures gracefully +- šŸ“ **Full TypeScript Support** - Complete type definitions +- šŸŽÆ **Framework Agnostic** - Works with any JavaScript framework or vanilla JS + +## Installation + +```bash +npm install @trustgraph/client +``` + +## Quick Start + +```typescript +import { createTrustGraphSocket } from "@trustgraph/client"; + +// Create a socket connection +const socket = createTrustGraphSocket("your-username"); + +// Query triples from the knowledge graph +const triples = await socket.triplesQuery( + { v: "http://example.org/subject", e: true }, + { v: "http://example.org/predicate", e: true }, + undefined, + 10, // limit +); + +console.log(triples); +``` + +## With Authentication + +```typescript +const socket = createTrustGraphSocket("your-username", "your-api-key"); +``` + +## Core APIs + +### Knowledge Graph Operations + +**Query Triples** + +```typescript +const triples = await socket.triplesQuery( + subject?: Value, // Optional subject filter + predicate?: Value, // Optional predicate filter + object?: Value, // Optional object filter + limit: number, // Maximum results + collection?: string // Optional collection name +); +``` + +**Graph Embeddings Query** + +```typescript +const entities = await socket.graphEmbeddingsQuery( + vectors: number[][], // Embedding vectors + limit: number, // Maximum results + collection?: string // Optional collection name +); +``` + +### Text & LLM Operations + +**Text Completion** + +```typescript +const response = await socket.textCompletion( + system: string, // System prompt + prompt: string, // User prompt + temperature?: number +); +``` + +**Graph RAG** + +```typescript +const answer = await socket.graphRag( + query: string, + options?: { + 'entity-limit'?: number, + 'triple-limit'?: number, + 'max-subgraph-size'?: number, + 'max-path-length'?: number + }, + collection?: string +); +``` + +**Agent** + +```typescript +socket.agent( + question: string, + think: (thought: string) => void, // Called when agent is thinking + observe: (observation: string) => void, // Called on observations + answer: (answer: string) => void, // Called with final answer + error: (error: string) => void, // Called on errors + collection?: string +); +``` + +**Embeddings** + +```typescript +const vectors = await socket.embeddings(text: string); +``` + +### Document Operations + +**Load Document** + +```typescript +await socket.loadDocument( + id: string, // Document ID + data: string, // Base64-encoded document + metadata: Triple[], // Document metadata as triples + collection?: string +); +``` + +**Load Text** + +```typescript +await socket.loadText( + id: string, // Document ID + text: string, // Plain text content + charset: string, // Character encoding (e.g., 'utf-8') + metadata: Triple[], // Document metadata as triples + collection?: string +); +``` + +### Library Operations + +**List Documents** + +```typescript +const docs = await socket.library.listDocuments( + user?: string, + collection?: string +); +``` + +**Get Document** + +```typescript +const doc = await socket.library.getDocument( + id: string, + user?: string, + collection?: string +); +``` + +**Delete Document** + +```typescript +await socket.library.deleteDocument( + id: string, + user?: string, + collection?: string +); +``` + +### Flow Operations + +Flows represent processing pipelines for documents and queries. + +**Create Flow API** + +```typescript +const flowApi = socket.flow("flow-id"); +// flowApi has same methods as socket but scoped to this flow +``` + +**Start Flow** + +```typescript +await socket.flows.startFlow( + flowId: string, + className: string, + description: string +); +``` + +**Stop Flow** + +```typescript +await socket.flows.stopFlow(flowId: string); +``` + +**List Flows** + +```typescript +const flowIds = await socket.flows.getFlows(); +``` + +**Get Flow Definition** + +```typescript +const flowDef = await socket.flows.getFlow(flowId: string); +``` + +**List Flow Classes** + +```typescript +const classes = await socket.flows.getFlowClasses(); +``` + +**Get Flow Class** + +```typescript +const classDef = await socket.flows.getFlowClass(className: string); +``` + +## Connection State Monitoring + +```typescript +// Subscribe to connection state changes +const unsubscribe = socket.onConnectionStateChange((state) => { + console.log("Status:", state.status); // 'connecting' | 'connected' | 'authenticated' | 'disconnected' | 'error' + console.log("Authenticated:", state.authenticated); + console.log("Error:", state.error); +}); + +// Unsubscribe when done +unsubscribe(); +``` + +## Data Types + +### Value + +Represents a subject, predicate, or object in a triple: + +```typescript +interface Value { + v: string; // Value (URI or literal) + e: boolean; // Is entity (true) or literal (false) + label?: string; // Optional human-readable label +} +``` + +### Triple + +Represents a subject-predicate-object relationship: + +```typescript +interface Triple { + s: Value; // Subject + p: Value; // Predicate + o: Value; // Object +} +``` + +## Advanced Usage + +### Custom Timeout and Retries + +Most methods accept optional timeout and retry parameters: + +```typescript +await socket.triplesQuery( + subject, + predicate, + object, + limit, + collection, + 30000, // timeout in ms + 5, // retry attempts +); +``` + +### Closing the Connection + +```typescript +socket.close(); +``` + +## Error Handling + +All async methods return Promises that reject on error: + +```typescript +try { + const result = await socket.triplesQuery(...); +} catch (error) { + console.error('Query failed:', error); +} +``` + +## React Integration + +For React applications, use the companion package: + +```bash +npm install @trustgraph/react-provider +``` + +See [@trustgraph/react-provider](https://github.com/trustgraph-ai/trustgraph-client) for React-specific hooks and providers. + +## API Reference + +Full API documentation is available in the TypeScript definitions. Your IDE will provide autocomplete and inline documentation for all methods. + +## License + +Apache 2.0 + +(c) KnowNext Inc., KnowNext Limited 2025 + diff --git a/ai-context/trustgraph-client/docs/tech-specs/client-module.md b/ai-context/trustgraph-client/docs/tech-specs/client-module.md new file mode 100644 index 00000000..93e24d9e --- /dev/null +++ b/ai-context/trustgraph-client/docs/tech-specs/client-module.md @@ -0,0 +1,44 @@ +# TrustGraph Client Module - Technical Specification + +## Overview + +This module extracts reusable code from the existing TrustGraph Workbench +application and packages it as a standalone client library. The goal is to +enable developers to build TrustGraph user experiences without having to +reimplement API communication and state management from scratch. + +## Goals + +- Extract and package reusable WebSocket API code from TrustGraph Workbench +- Provide a clean, well-documented interface for TrustGraph WebSocket + communication +- Enable developers to quickly build TrustGraph UX applications +- Eliminate code duplication across TrustGraph UI projects +- Maintain compatibility with existing TrustGraph backend services + +## Non-Goals + +- REST API implementations (WebSocket only) +- UI components or presentation layer code +- Backend service implementations +- Authentication/authorization logic beyond what's needed for WebSocket + connections +- Application-specific business logic + +## Architecture + +## API Design + +## Implementation Plan + +## Testing Strategy + +## Dependencies + +## Security Considerations + +## Performance Considerations + +## Open Questions + +## References diff --git a/ai-context/trustgraph-client/docs/tech-specs/streaming-support.md b/ai-context/trustgraph-client/docs/tech-specs/streaming-support.md new file mode 100644 index 00000000..3f4207f1 --- /dev/null +++ b/ai-context/trustgraph-client/docs/tech-specs/streaming-support.md @@ -0,0 +1,808 @@ +# Streaming Support for TrustGraph Client + +**Status**: Draft for Review +**Author**: Claude +**Date**: 2025-11-27 +**Version**: 1.0 + +## Executive Summary + +Extend the TrustGraph TypeScript client to support streaming responses for Graph RAG, Document RAG, Text Completion, and Prompt services. The client already has streaming infrastructure (`ServiceCallMulti`) used by Agent, but the other services only support single-response mode. This spec proposes minimal changes to enable streaming across all services while maintaining backward compatibility. + +## Background + +### Current State + +The client has **two request patterns**: + +1. **Single-response** (`makeRequest` → `ServiceCall`) + - Used by: text-completion, graph-rag, document-rag, prompt, and most other services + - Returns Promise that resolves with single response + - Example: `graphRag(text: string): Promise` + +2. **Multi-response** (`makeRequestMulti` → `ServiceCallMulti`) + - Used by: agent (thoughts/observations/answer), knowledge.getKgCore (large graph streaming) + - Accepts `receiver: (resp: unknown) => boolean` callback + - Receiver returns `true` to signal end-of-stream + - Example: `agent(question, think, observe, answer, error): void` + +### Backend Streaming Protocol + +Per `STREAMING-IMPLEMENTATION-NOTES.txt`, the backend supports streaming when `streaming: true` is added to requests: + +**Graph RAG / Document RAG**: +- Chunks arrive with `chunk` field +- Final chunk has `end_of_stream: true` + +**Text Completion**: +- Chunks arrive with `response` field +- Final chunk has `end_of_stream: true` + +**Prompt**: +- Chunks arrive with `text` field +- Final chunk has `end_of_stream: true` + +**Agent** (already implemented): +- Multiple messages with `chunk_type` (thought/observation/final-answer) +- Final chunk has `end_of_dialog: true` + +## Problem Statement + +**Primary Issue**: Users who want streaming responses for Graph RAG, Document RAG, Text Completion, or Prompt services must: +1. Drop down to `makeRequestMulti` and handle raw responses +2. Manually parse `chunk`/`response`/`text` fields +3. Check `end_of_stream` flag +4. Handle errors mid-stream + +**Secondary Issue**: The Agent API doesn't correctly implement the backend streaming protocol. The backend sends: +``` +{chunk_type: "thought", content: "I need to", end_of_message: false, end_of_dialog: false} +{chunk_type: "thought", content: " search", end_of_message: false, end_of_dialog: false} +``` + +But the client expects: +``` +{thought?: string, observation?: string, answer?: string} +``` + +The Agent implementation needs to be updated to handle incremental chunks with completion flags. + +## Goals + +1. **Fix Agent API** to correctly implement backend streaming protocol with chunk-level callbacks +2. **Add streaming variants** for text-completion, graph-rag, document-rag, and prompt services +3. **Maintain backward compatibility** - existing non-streaming APIs unchanged (except Agent which needs fixing) +4. **Policy-free implementation** - no state management (accumulation, buffering, etc.) in client layer +5. **Minimal callback interface** - single receiver callback with chunk and completion flag +6. **Minimal type changes** - reuse existing request/response types where possible + +## Non-Goals + +- Changing the existing non-streaming APIs +- Supporting streaming for services that don't stream (embeddings, triples, etc.) +- Implementing state management (accumulation, buffering) - that belongs in higher layers +- Changing the underlying `ServiceCallMulti` implementation + +## Design + +### 1. Type Additions + +Add streaming-specific response types to `src/models/messages.ts`: + +```typescript +// Agent streaming response (NEW - replaces old AgentResponse for streaming) +export interface AgentStreamingResponse { + chunk_type?: "thought" | "action" | "observation" | "final-answer" | "error"; + content?: string; + end_of_message?: boolean; // Current chunk type is complete + end_of_dialog?: boolean; // Entire agent dialog is complete + + // Legacy fields for backward compatibility with non-streaming + thought?: string; + observation?: string; + answer?: string; + error?: string; +} + +// Generic streaming response wrapper for RAG/completion services +export interface StreamingChunk { + chunk?: string; // Graph RAG, Document RAG + response?: string; // Text Completion + text?: string; // Prompt + end_of_stream?: boolean; + error?: { + message: string; + type?: string; + }; +} + +// Request types get optional streaming flag +export interface AgentRequest { + question: string; + user?: string; + streaming?: boolean; // NEW - enable streaming mode +} + +export interface GraphRagRequest { + query: string; + user?: string; + collection?: string; + "entity-limit"?: number; + "triple-limit"?: number; + "max-subgraph-size"?: number; + "max-path-length"?: number; + streaming?: boolean; // NEW +} + +export interface DocumentRagRequest { + query: string; + user?: string; + collection?: string; + "doc-limit"?: number; + streaming?: boolean; // NEW +} + +export interface TextCompletionRequest { + system: string; + prompt: string; + streaming?: boolean; // NEW +} + +export interface PromptRequest { + id: string; + terms: Record; + streaming?: boolean; // NEW +} + +export interface PromptResponse { + text: string; +} +``` + +### 2. BaseApi Additions + +No changes needed to `BaseApi` - `makeRequestMulti` already exists. + +### 3. FlowApi Changes + +#### 3.1 Fix Agent Method + +Update the existing `agent()` method to correctly handle the backend streaming protocol: + +```typescript +export class FlowApi { + /** + * Interacts with an AI agent that provides streaming responses + * BREAKING CHANGE: Callbacks now receive (chunk, complete) instead of full messages + */ + agent( + question: string, + think: (chunk: string, complete: boolean) => void, + observe: (chunk: string, complete: boolean) => void, + answer: (chunk: string, complete: boolean) => void, + error: (s: string) => void, + ) { + const receiver = (response: unknown) => { + const resp = response as AgentStreamingResponse; + + // Check for errors + if (resp.chunk_type === "error" || resp.error) { + const errorMessage = resp.content || resp.error || "Unknown agent error"; + error(typeof errorMessage === "string" ? errorMessage : String(errorMessage)); + return true; // End streaming on error + } + + // Handle streaming chunks by chunk_type + const content = resp.content || ""; + const messageComplete = !!resp.end_of_message; + const dialogComplete = !!resp.end_of_dialog; + + switch (resp.chunk_type) { + case "thought": + think(content, messageComplete); + break; + case "observation": + observe(content, messageComplete); + break; + case "final-answer": + answer(content, messageComplete); + break; + case "action": + // Actions are typically not streamed incrementally, just logged + console.log("Agent action:", content); + break; + } + + return dialogComplete; // End when backend signals end_of_dialog + }; + + return this.api + .makeRequestMulti( + "agent", + { + question: question, + user: this.api.user, + streaming: true, // Always use streaming mode + }, + receiver, + 120000, + 2, + this.flowId, + ) + .catch((err) => { + const errorMessage = + err instanceof Error ? err.message : err?.toString() || "Unknown error"; + error(`Agent request failed: ${errorMessage}`); + }); + } + +#### 3.2 Add New Streaming Methods + +Add streaming variants for other services alongside existing methods in `src/socket/trustgraph-socket.ts`: + +```typescript + // ... existing non-streaming methods unchanged ... + + /** + * Performs Graph RAG query with streaming response + * @param text - Query text + * @param receiver - Called for each chunk with (chunk, complete) where complete=true on final chunk + * @param onError - Called on error + * @param options - Graph RAG options + * @param collection - Collection name + */ + graphRagStreaming( + text: string, + receiver: (chunk: string, complete: boolean) => void, + onError: (error: string) => void, + options?: GraphRagOptions, + collection?: string, + ): void { + const recv = (response: unknown): boolean => { + const resp = response as StreamingChunk; + + if (resp.error) { + onError(resp.error.message); + return true; // End streaming + } + + const chunk = resp.chunk || ""; + const complete = !!resp.end_of_stream; + + receiver(chunk, complete); + + return complete; // End when backend signals end_of_stream + }; + + this.api.makeRequestMulti( + "graph-rag", + { + query: text, + user: this.api.user, + collection: collection || "default", + "entity-limit": options?.entityLimit, + "triple-limit": options?.tripleLimit, + "max-subgraph-size": options?.maxSubgraphSize, + "max-path-length": options?.pathLength, + streaming: true, + }, + recv, + 60000, + undefined, + this.flowId, + ); + } + + /** + * Performs Document RAG query with streaming response + * @param text - Query text + * @param receiver - Called for each chunk with (chunk, complete) where complete=true on final chunk + * @param onError - Called on error + * @param docLimit - Maximum documents to retrieve + * @param collection - Collection name + */ + documentRagStreaming( + text: string, + receiver: (chunk: string, complete: boolean) => void, + onError: (error: string) => void, + docLimit?: number, + collection?: string, + ): void { + const recv = (response: unknown): boolean => { + const resp = response as StreamingChunk; + + if (resp.error) { + onError(resp.error.message); + return true; + } + + const chunk = resp.chunk || ""; + const complete = !!resp.end_of_stream; + + receiver(chunk, complete); + + return complete; + }; + + this.api.makeRequestMulti( + "document-rag", + { + query: text, + user: this.api.user, + collection: collection || "default", + "doc-limit": docLimit, + streaming: true, + }, + recv, + 60000, + undefined, + this.flowId, + ); + } + + /** + * Performs text completion with streaming response + * @param system - System prompt + * @param text - User prompt + * @param receiver - Called for each chunk with (chunk, complete) where complete=true on final chunk + * @param onError - Called on error + */ + textCompletionStreaming( + system: string, + text: string, + receiver: (chunk: string, complete: boolean) => void, + onError: (error: string) => void, + ): void { + const recv = (response: unknown): boolean => { + const resp = response as StreamingChunk; + + if (resp.error) { + onError(resp.error.message); + return true; + } + + // Text completion uses 'response' field, not 'chunk' + const chunk = resp.response || ""; + const complete = !!resp.end_of_stream; + + receiver(chunk, complete); + + return complete; + }; + + this.api.makeRequestMulti( + "text-completion", + { + system: system, + prompt: text, + streaming: true, + }, + recv, + 30000, + undefined, + this.flowId, + ); + } + + /** + * Executes a prompt template with streaming response + * @param id - Prompt template ID + * @param terms - Template variables + * @param receiver - Called for each chunk with (chunk, complete) where complete=true on final chunk + * @param onError - Called on error + */ + promptStreaming( + id: string, + terms: Record, + receiver: (chunk: string, complete: boolean) => void, + onError: (error: string) => void, + ): void { + const recv = (response: unknown): boolean => { + const resp = response as StreamingChunk; + + if (resp.error) { + onError(resp.error.message); + return true; + } + + // Prompt service uses 'text' field + const chunk = resp.text || ""; + const complete = !!resp.end_of_stream; + + receiver(chunk, complete); + + return complete; + }; + + this.api.makeRequestMulti( + "prompt", + { + id: id, + terms: terms, + streaming: true, + }, + recv, + 30000, + undefined, + this.flowId, + ); + } +} +``` + +### 4. BaseApi Convenience Methods (Optional) + +For users who don't need flow routing, add streaming methods to BaseApi: + +```typescript +export class BaseApi { + // Existing methods... + + /** + * Streaming text completion without flow routing + */ + textCompletionStreaming( + system: string, + prompt: string, + receiver: (chunk: string, complete: boolean) => void, + onError: (error: string) => void, + ): void { + const flowApi = new FlowApi(this, undefined); + flowApi.textCompletionStreaming(system, prompt, receiver, onError); + } + + // Similar for graphRagStreaming, documentRagStreaming, promptStreaming... +} +``` + +**Recommendation**: Add these for consistency with existing non-streaming methods on BaseApi. + +## Implementation Plan + +### Phase 1: Core Types (1 hour) +1. Add `streaming?: boolean` to request types +2. Add `StreamingChunk` interface +3. Add `PromptRequest` and `PromptResponse` types (currently missing) + +### Phase 2: FlowApi Streaming Methods (2 hours) +1. Implement `textCompletionStreaming` +2. Implement `graphRagStreaming` +3. Implement `documentRagStreaming` +4. Implement `promptStreaming` +5. Add JSDoc comments + +### Phase 3: BaseApi Convenience Methods (1 hour) +1. Add streaming methods to BaseApi +2. Update interface definitions +3. Update README with streaming examples + +### Phase 4: Testing (2 hours) +1. Add unit tests for streaming methods +2. Add integration tests against mock WebSocket +3. Test error handling mid-stream +4. Test timeout behavior +5. Test concurrent streaming requests + +### Phase 5: Documentation (1 hour) +1. Update README with streaming examples +2. Add streaming guide to docs/ +3. Update API reference + +**Total Estimated Time**: 7 hours + +## Testing Strategy + +### Unit Tests + +```typescript +describe("FlowApi streaming", () => { + it("should stream graph-rag chunks", async () => { + const chunks: Array<{ chunk: string; complete: boolean }> = []; + + flowApi.graphRagStreaming( + "test query", + (chunk, complete) => { + chunks.push({ chunk, complete }); + }, + (error) => fail(error), + ); + + // Simulate streaming chunks + mockWebSocket.simulateMessage({ chunk: "Hello", end_of_stream: false }); + mockWebSocket.simulateMessage({ chunk: " world", end_of_stream: false }); + mockWebSocket.simulateMessage({ chunk: "", end_of_stream: true }); + + expect(chunks).toEqual([ + { chunk: "Hello", complete: false }, + { chunk: " world", complete: false }, + { chunk: "", complete: true }, + ]); + }); + + it("should handle errors mid-stream", async () => { + let errorMsg = ""; + const chunks: string[] = []; + + flowApi.graphRagStreaming( + "test query", + (chunk, complete) => { + chunks.push(chunk); + }, + (error) => { + errorMsg = error; + }, + ); + + mockWebSocket.simulateMessage({ chunk: "Partial", end_of_stream: false }); + mockWebSocket.simulateMessage({ + error: { message: "LLM timeout" }, + end_of_stream: true, + }); + + expect(errorMsg).toBe("LLM timeout"); + expect(chunks).toEqual(["Partial"]); // Receiver gets chunks before error + }); +}); +``` + +### Integration Tests + +Test against actual TrustGraph backend (manual testing): +1. Start TrustGraph backend with streaming enabled +2. Test each streaming method with real queries +3. Verify chunks arrive in order +4. Verify end_of_stream handling +5. Test error scenarios (invalid query, timeout) + +## Migration Guide + +### For Users + +#### Graph RAG / Document RAG / Text Completion / Prompt + +**Before (non-streaming)**: +```typescript +const response = await flowApi.graphRag("What is machine learning?"); +console.log(response); // Full text after 10-30 seconds +``` + +**After (streaming)**: +```typescript +let accumulated = ""; + +flowApi.graphRagStreaming( + "What is machine learning?", + (chunk, complete) => { + accumulated += chunk; + updateDisplay(accumulated); + + if (complete) { + console.log("Final:", accumulated); + } + }, + (error) => { + console.error("Error:", error); + } +); +``` + +#### Agent (BREAKING CHANGE) + +**Before (old client - incorrect)**: +```typescript +flowApi.agent( + "What is machine learning?", + (thought) => console.log("Thinking:", thought), // Full thought received + (observation) => console.log("Observing:", observation), // Full observation received + (answer) => console.log("Answer:", answer), // Full answer received + (error) => console.error(error), +); +``` + +**After (updated to match backend)**: +```typescript +let currentThought = ""; +let currentObservation = ""; +let currentAnswer = ""; + +flowApi.agent( + "What is machine learning?", + (chunk, complete) => { + currentThought += chunk; + updateThinkingDisplay(currentThought); + if (complete) { + console.log("Thought complete:", currentThought); + currentThought = ""; // Reset for next thought + } + }, + (chunk, complete) => { + currentObservation += chunk; + updateObservationDisplay(currentObservation); + if (complete) { + console.log("Observation complete:", currentObservation); + currentObservation = ""; + } + }, + (chunk, complete) => { + currentAnswer += chunk; + updateAnswerDisplay(currentAnswer); + if (complete) { + console.log("Final answer:", currentAnswer); + } + }, + (error) => console.error(error), +); +``` + +### Gradual Adoption + +**For Graph RAG / Document RAG / Text Completion / Prompt**: +1. Continue using non-streaming APIs (no breaking changes) +2. Add streaming variants for user-facing chat interfaces first +3. Keep non-streaming for background tasks +4. Optionally add feature flag to toggle streaming on/off + +**For Agent (BREAKING CHANGE)**: +1. Existing Agent users MUST update their callbacks to handle (chunk, complete) signature +2. Add accumulation logic in callback handlers +3. Use `complete` flag to detect when to reset accumulator or take final action + +## Risks and Mitigations + +### Risk 1: BREAKING CHANGE for Agent API +**Concern**: Existing Agent users must update their code when they upgrade. + +**Mitigation**: +- Document the breaking change clearly in release notes +- Provide migration examples in this spec +- Consider: Add deprecation warning in previous version before breaking change +- Consider: Bump major version to signal breaking change +- The old API was incorrect anyway - this fixes a bug in the client + +### Risk 2: API Surface Growth +**Concern**: Adding 4 new methods per API class (FlowApi, BaseApi) increases maintenance burden. + +**Mitigation**: +- Methods share identical structure (only field name differs: chunk/response/text) +- Could extract common streaming handler if needed +- Backend already implements streaming, so no protocol risk + +### Risk 3: TypeScript Type Safety +**Concern**: `StreamingChunk` union type may be confusing (chunk vs response vs text). + +**Mitigation**: +- Each service method documents which field it uses +- Runtime code checks correct field +- Implementation is simple enough that field selection is obvious + +### Risk 4: State Management in User Code +**Concern**: Users must manually accumulate chunks if they need full text. + +**Mitigation**: +- This is intentional - client stays policy-free +- Higher-level abstractions (React hooks, etc.) can provide accumulation +- For users who don't need streaming behavior, non-streaming APIs remain unchanged + +## Future Enhancements + +### 1. Async Iterator API +Provide a modern streaming API using async iterators: + +```typescript +async *graphRagStream(text: string): AsyncGenerator { + // Wraps graphRagStreaming in async iterator +} + +// Usage: +for await (const chunk of flowApi.graphRagStream("query")) { + console.log(chunk); +} +``` + +### 2. Retry on Stream Interruption +Currently, retries only apply to initial request. Could add mid-stream retry: +- Detect connection drop mid-stream +- Resume from last chunk (if backend supports resumption) + +### 3. Client-Side Buffering +For very fast chunk arrival, buffer multiple chunks before calling receiver: +- Reduces callback frequency +- Could be opt-in via options parameter +- Note: This would add policy to the client, may be better in higher layers + +### 4. Stream Cancellation +Allow users to cancel in-flight streaming requests: +```typescript +const cancel = flowApi.graphRagStreaming(...); +// Later: +cancel(); +``` + +## Alternatives Considered + +### Alternative 1: Separate Callbacks for Chunk and Complete +Use three callbacks: onChunk, onComplete, onError: + +```typescript +graphRagStreaming( + text: string, + onChunk: (chunk: string, accumulated: string) => void, + onComplete: (fullText: string) => void, + onError: (error: string) => void, +) +``` + +**Rejected because**: +- Adds state management (accumulation) to the client layer +- Harder for implementations that need both signals at once +- More verbose callback signature + +### Alternative 2: Unified Streaming Flag on Existing Methods +Modify existing methods to detect streaming callbacks: + +```typescript +graphRag( + text: string, + options?: GraphRagOptions, + collection?: string, + receiver?: (chunk: string, complete: boolean) => void, +): Promise | void +``` + +**Rejected because**: +- Violates single responsibility principle +- Return type becomes conditional (Promise vs void) +- Hard to type correctly in TypeScript +- Confusing API (streaming vs non-streaming behavior implicit) + +### Alternative 3: Separate StreamingFlowApi Class +Create a parallel API class for streaming: + +```typescript +export class StreamingFlowApi { + graphRag(text: string, receiver: ..., onError: ...): void; + documentRag(text: string, receiver: ..., onError: ...): void; +} +``` + +**Rejected because**: +- Duplicates all configuration and state management +- Users must manage two API instances +- No clear benefit over method suffixes + +## Open Questions + +1. **Should we add streaming to Prompt service?** + - Prompt service is not currently in client (no PromptRequest/Response types) + - Could add it alongside streaming support + - **Decision**: Yes, add it for completeness (mentioned in backend docs) + +2. **Should we add TypeScript overloads?** + - Allow `graphRagStreaming(text, callbacks)` vs `graphRagStreaming(text, options, callbacks)` + - **Decision**: Use optional parameters (simpler implementation) + +## Conclusion + +This proposal adds streaming support to the TrustGraph client and fixes the Agent API to correctly implement the backend protocol: + +**Changes**: +1. **Fix Agent API** (BREAKING): Update callbacks to receive `(chunk, complete)` instead of full messages +2. Add `streaming?: boolean` flag to all request types +3. Add `AgentStreamingResponse` and `StreamingChunk` response types +4. Add `*Streaming` method variants to FlowApi and BaseApi for RAG/completion services +5. Use consistent two-callback pattern: `receiver(chunk, complete)` and `onError(message)` across all services + +The implementation is straightforward (~7-10 hours including Agent fix), stays minimal and focused, and provides a clean foundation for higher-level abstractions to build upon. + +**Key Design Principles**: +- **Policy-free**: No accumulation or buffering in client layer +- **Minimal callbacks**: Single receiver gets both chunk and completion signal +- **Protocol-correct**: Agent now properly implements backend's chunk_type/content/end_of_message protocol +- **Consistent**: Same pattern across all streaming services +- **Backward compatible**: Existing non-streaming APIs unchanged (except Agent which needs fixing) + +**Breaking Changes**: +- Agent API callbacks change from `(fullMessage: string)` to `(chunk: string, complete: boolean)` +- Requires major version bump + +**Recommendation**: Approve and implement in current sprint. diff --git a/ai-context/trustgraph-client/eslint.config.js b/ai-context/trustgraph-client/eslint.config.js new file mode 100644 index 00000000..462b81af --- /dev/null +++ b/ai-context/trustgraph-client/eslint.config.js @@ -0,0 +1,35 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import globals from "globals"; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + parser: tseslint.parser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + }, + globals: { + ...globals.browser, + ...globals.es2021, + }, + }, + rules: { + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/no-explicit-any": "warn", + }, + }, + { + ignores: ["dist/**", "node_modules/**", "*.config.js"], + }, +); diff --git a/ai-context/trustgraph-client/package.json b/ai-context/trustgraph-client/package.json new file mode 100644 index 00000000..c47b1002 --- /dev/null +++ b/ai-context/trustgraph-client/package.json @@ -0,0 +1,66 @@ +{ + "name": "@trustgraph/client", + "version": "1.6.0", + "description": "TypeScript client for TrustGraph", + "type": "module", + "main": "dist/index.esm.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.esm.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "rollup -c", + "dev": "rollup -c -w", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "lint": "eslint src", + "typecheck": "tsc --noEmit", + "prettify": "prettier --write .", + "prepare": "npm run build" + }, + "keywords": [ + "trustgraph", + "websocket", + "typescript", + "client" + ], + "author": "KnowNext Limited", + "license": "Apache-2.0", + "devDependencies": { + "@eslint/js": "^9.37.0", + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@vitest/ui": "^3.2.4", + "eslint": "^9.39.3", + "globals": "^16.4.0", + "happy-dom": "^20.0.10", + "jiti": "^2.6.1", + "prettier": "^3.6.2", + "rollup": "^4.9.0", + "tslib": "^2.6.2", + "typescript": "^5.3.3", + "typescript-eslint": "^8.46.0", + "vitest": "^3.2.4" + }, + "directories": { + "doc": "docs" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/trustgraph-ai/trustgraph-client.git" + }, + "bugs": { + "url": "https://github.com/trustgraph-ai/trustgraph-client/issues" + }, + "homepage": "https://github.com/trustgraph-ai/trustgraph-client#readme" +} diff --git a/ai-context/trustgraph-client/rollup.config.js b/ai-context/trustgraph-client/rollup.config.js new file mode 100644 index 00000000..ffa5e319 --- /dev/null +++ b/ai-context/trustgraph-client/rollup.config.js @@ -0,0 +1,30 @@ +import resolve from "@rollup/plugin-node-resolve"; +import commonjs from "@rollup/plugin-commonjs"; +import typescript from "@rollup/plugin-typescript"; + +export default { + input: "src/index.ts", + output: [ + { + file: "dist/index.cjs", + format: "cjs", + sourcemap: true, + }, + { + file: "dist/index.esm.js", + format: "esm", + sourcemap: true, + }, + ], + external: ["react", "react-dom"], + plugins: [ + resolve(), + commonjs(), + typescript({ + tsconfig: "./tsconfig.json", + declaration: true, + declarationDir: "dist", + rootDir: "src", + }), + ], +}; diff --git a/ai-context/trustgraph-client/src/__tests__/flows-api.test.ts b/ai-context/trustgraph-client/src/__tests__/flows-api.test.ts new file mode 100644 index 00000000..e011af72 --- /dev/null +++ b/ai-context/trustgraph-client/src/__tests__/flows-api.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { FlowsApi } from "../socket/trustgraph-socket"; +import { FlowResponse } from "../models/messages"; + +describe("FlowsApi", () => { + let mockApi: { + makeRequest: ReturnType; + }; + let flowsApi: FlowsApi; + + beforeEach(() => { + mockApi = { + makeRequest: vi.fn(), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + flowsApi = new FlowsApi(mockApi as any); + }); + + describe("startFlow", () => { + it("should call makeRequest with correct types and parameters", async () => { + const mockResponse: FlowResponse = { + flow: "started", + description: "Flow started successfully", + }; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.startFlow( + "test-flow-id", + "test-class", + "Test description", + ); + + expect(mockApi.makeRequest).toHaveBeenCalledWith( + "flow", + { + operation: "start-flow", + "flow-id": "test-flow-id", + "blueprint-name": "test-class", + description: "Test description", + }, + 30000, + ); + expect(result).toEqual(mockResponse); + }); + + it("should use FlowRequest and FlowResponse types", async () => { + const mockResponse: FlowResponse = {}; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + await flowsApi.startFlow("id", "class", "desc"); + + // Verify the call signature matches FlowRequest/FlowResponse types + const callArgs = mockApi.makeRequest.mock.calls[0]; + const request = callArgs[1]; + + // These properties should match FlowRequest interface + expect(request).toHaveProperty("operation"); + expect(request).toHaveProperty("flow-id"); + expect(request).toHaveProperty("blueprint-name"); + expect(request).toHaveProperty("description"); + }); + }); + + describe("stopFlow", () => { + it("should call makeRequest with correct types and parameters", async () => { + const mockResponse: FlowResponse = { + flow: "stopped", + description: "Flow stopped successfully", + }; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.stopFlow("test-flow-id"); + + expect(mockApi.makeRequest).toHaveBeenCalledWith( + "flow", + { + operation: "stop-flow", + "flow-id": "test-flow-id", + }, + 30000, + ); + expect(result).toEqual(mockResponse); + }); + + it("should use FlowRequest and FlowResponse types", async () => { + const mockResponse: FlowResponse = {}; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + await flowsApi.stopFlow("id"); + + // Verify the call signature matches FlowRequest/FlowResponse types + const callArgs = mockApi.makeRequest.mock.calls[0]; + const request = callArgs[1]; + + // These properties should match FlowRequest interface + expect(request).toHaveProperty("operation"); + expect(request).toHaveProperty("flow-id"); + }); + }); + + describe("getFlows", () => { + it("should return flow-ids array from response", async () => { + const mockResponse: FlowResponse = { + "flow-ids": ["flow1", "flow2", "flow3"], + }; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.getFlows(); + + expect(mockApi.makeRequest).toHaveBeenCalledWith( + "flow", + { + operation: "list-flows", + }, + 60000, + ); + expect(result).toEqual(["flow1", "flow2", "flow3"]); + }); + + it("should return empty array when flow-ids is undefined", async () => { + const mockResponse: FlowResponse = {}; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.getFlows(); + + expect(result).toEqual([]); + }); + + it("should handle response with flow-ids property correctly", async () => { + // This test ensures we're accessing the hyphenated property name correctly + const mockResponse = { + "flow-ids": ["test-flow"], + "other-property": "should-be-ignored", + }; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.getFlows(); + + expect(result).toEqual(["test-flow"]); + }); + }); + + describe("getFlowBlueprints", () => { + it("should return blueprint-names array from response", async () => { + const mockResponse: FlowResponse = { + "blueprint-names": ["class1", "class2"], + }; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.getFlowBlueprints(); + + expect(mockApi.makeRequest).toHaveBeenCalledWith( + "flow", + { + operation: "list-blueprints", + }, + 60000, + ); + expect(result).toEqual(["class1", "class2"]); + }); + + it("should handle response with blueprint-names property correctly", async () => { + // This test ensures we're accessing the hyphenated property name correctly + const mockResponse = { + "blueprint-names": ["test-class"], + "other-property": "should-be-ignored", + }; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.getFlowBlueprints(); + + expect(result).toEqual(["test-class"]); + }); + }); + + describe("getFlow", () => { + it("should call makeRequest with correct parameters and parse JSON", async () => { + const flowDefinition = { type: "flow", config: "test" }; + const mockResponse: FlowResponse = { + flow: JSON.stringify(flowDefinition), // Must be valid JSON string + description: "Test flow", + }; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.getFlow("test-flow-id"); + + expect(mockApi.makeRequest).toHaveBeenCalledWith( + "flow", + { + operation: "get-flow", + "flow-id": "test-flow-id", + }, + 60000, + ); + expect(result).toEqual(flowDefinition); // Result should be parsed JSON + }); + }); + + describe("getFlowBlueprint", () => { + it("should call makeRequest with correct parameters and parse JSON", async () => { + const blueprintDefinition = { type: "blueprint", name: "test-blueprint" }; + const mockResponse: FlowResponse = { + "blueprint-definition": JSON.stringify(blueprintDefinition), // Must be valid JSON string + description: "Test blueprint", + }; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.getFlowBlueprint("test-class"); + + expect(mockApi.makeRequest).toHaveBeenCalledWith( + "flow", + { + operation: "get-blueprint", + "blueprint-name": "test-class", + }, + 60000, + ); + expect(result).toEqual(blueprintDefinition); // Result should be parsed JSON + }); + }); +}); diff --git a/ai-context/trustgraph-client/src/__tests__/messages.test.ts b/ai-context/trustgraph-client/src/__tests__/messages.test.ts new file mode 100644 index 00000000..65d96c9e --- /dev/null +++ b/ai-context/trustgraph-client/src/__tests__/messages.test.ts @@ -0,0 +1,370 @@ +import { describe, it, expect } from "vitest"; +import type { + RequestMessage, + ApiResponse, + TextCompletionRequest, + TextCompletionResponse, + GraphRagRequest, + GraphRagResponse, + AgentRequest, + AgentResponse, + EmbeddingsRequest, + EmbeddingsResponse, + GraphEmbeddingsQueryRequest, + GraphEmbeddingsQueryResponse, + TriplesQueryRequest, + LoadDocumentRequest, + LoadTextRequest, + LibraryRequest, + LibraryResponse, + FlowRequest, + FlowResponse, + DocumentMetadata, + ProcessingMetadata, +} from "../models/messages"; + +describe("Message Types", () => { + describe("RequestMessage", () => { + it("should have correct structure", () => { + const message: RequestMessage = { + id: "test-id", + service: "test-service", + request: { test: "data" }, + }; + + expect(message.id).toBe("test-id"); + expect(message.service).toBe("test-service"); + expect(message.request).toEqual({ test: "data" }); + }); + }); + + describe("ApiResponse", () => { + it("should have correct structure", () => { + const response: ApiResponse = { + id: "test-id", + response: { result: "success" }, + }; + + expect(response.id).toBe("test-id"); + expect(response.response).toEqual({ result: "success" }); + }); + }); + + describe("TextCompletionRequest", () => { + it("should have correct structure", () => { + const request: TextCompletionRequest = { + system: "You are a helpful assistant", + prompt: "Hello, world!", + }; + + expect(request.system).toBe("You are a helpful assistant"); + expect(request.prompt).toBe("Hello, world!"); + }); + }); + + describe("TextCompletionResponse", () => { + it("should have correct structure", () => { + const response: TextCompletionResponse = { + response: "Hello! How can I help you today?", + }; + + expect(response.response).toBe("Hello! How can I help you today?"); + }); + }); + + describe("GraphRagRequest", () => { + it("should have correct structure with required query", () => { + const request: GraphRagRequest = { + query: "What is the capital of France?", + }; + + expect(request.query).toBe("What is the capital of France?"); + }); + + it("should have correct structure with optional parameters", () => { + const request: GraphRagRequest = { + query: "What is the capital of France?", + "entity-limit": 100, + "triple-limit": 50, + "max-subgraph-size": 2000, + "max-path-length": 3, + }; + + expect(request.query).toBe("What is the capital of France?"); + expect(request["entity-limit"]).toBe(100); + expect(request["triple-limit"]).toBe(50); + expect(request["max-subgraph-size"]).toBe(2000); + expect(request["max-path-length"]).toBe(3); + }); + }); + + describe("GraphRagResponse", () => { + it("should have correct structure", () => { + const response: GraphRagResponse = { + response: "The capital of France is Paris.", + }; + + expect(response.response).toBe("The capital of France is Paris."); + }); + }); + + describe("AgentRequest", () => { + it("should have correct structure", () => { + const request: AgentRequest = { + question: "What is the weather like today?", + }; + + expect(request.question).toBe("What is the weather like today?"); + }); + }); + + describe("AgentResponse", () => { + it("should have correct structure with all fields", () => { + const response: AgentResponse = { + thought: "I need to check the weather", + observation: "Weather API shows sunny conditions", + answer: "It is sunny today", + error: undefined, + }; + + expect(response.thought).toBe("I need to check the weather"); + expect(response.observation).toBe("Weather API shows sunny conditions"); + expect(response.answer).toBe("It is sunny today"); + expect(response.error).toBeUndefined(); + }); + + it("should handle error response", () => { + const response: AgentResponse = { + error: { type: "agent-error", message: "Weather service unavailable" }, + }; + + expect(response.error?.message).toBe("Weather service unavailable"); + expect(response.error?.type).toBe("agent-error"); + }); + }); + + describe("EmbeddingsRequest", () => { + it("should have correct structure", () => { + const request: EmbeddingsRequest = { + texts: ["This is a test sentence for embedding", "Another text"], + }; + + expect(request.texts).toEqual(["This is a test sentence for embedding", "Another text"]); + }); + }); + + describe("EmbeddingsResponse", () => { + it("should have correct structure", () => { + // vectors[text_index][dimension_index] - one vector per input text + const response: EmbeddingsResponse = { + vectors: [ + [0.1, 0.2, 0.3], // First text's vector + [0.4, 0.5, 0.6], // Second text's vector + ], + }; + + expect(response.vectors).toEqual([ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + ]); + }); + }); + + describe("GraphEmbeddingsQueryRequest", () => { + it("should have correct structure", () => { + const request: GraphEmbeddingsQueryRequest = { + vector: [0.1, 0.2, 0.3], + limit: 10, + }; + + expect(request.vector).toEqual([0.1, 0.2, 0.3]); + expect(request.limit).toBe(10); + }); + }); + + describe("GraphEmbeddingsQueryResponse", () => { + it("should have correct structure", () => { + const response: GraphEmbeddingsQueryResponse = { + entities: [ + { entity: { t: "i", i: "http://example.org/entity1" }, score: 0.95 }, + { entity: { t: "i", i: "http://example.org/entity2" }, score: 0.87 }, + ], + }; + + expect(response.entities).toHaveLength(2); + expect(response.entities[0].score).toBe(0.95); + expect(response.entities[0].entity?.t).toBe("i"); + expect((response.entities[0].entity as { t: "i"; i: string }).i).toBe("http://example.org/entity1"); + expect(response.entities[1].score).toBe(0.87); + }); + }); + + describe("TriplesQueryRequest", () => { + it("should have correct structure with all fields", () => { + const request: TriplesQueryRequest = { + s: { t: "i", i: "http://example.org/subject" }, + p: { t: "i", i: "http://example.org/predicate" }, + o: { t: "l", v: "object value" }, + limit: 100, + }; + + expect((request.s as { t: "i"; i: string }).i).toBe("http://example.org/subject"); + expect((request.p as { t: "i"; i: string }).i).toBe("http://example.org/predicate"); + expect((request.o as { t: "l"; v: string }).v).toBe("object value"); + expect(request.limit).toBe(100); + }); + + it("should handle optional fields", () => { + const request: TriplesQueryRequest = { + limit: 50, + }; + + expect(request.s).toBeUndefined(); + expect(request.p).toBeUndefined(); + expect(request.o).toBeUndefined(); + expect(request.limit).toBe(50); + }); + }); + + describe("LoadDocumentRequest", () => { + it("should have correct structure", () => { + const request: LoadDocumentRequest = { + id: "doc-123", + data: "base64-encoded-document-data", + metadata: [ + { + s: { t: "i", i: "http://example.org/doc-123" }, + p: { t: "i", i: "http://example.org/title" }, + o: { t: "l", v: "Test Document" }, + }, + ], + }; + + expect(request.id).toBe("doc-123"); + expect(request.data).toBe("base64-encoded-document-data"); + expect(request.metadata).toHaveLength(1); + }); + }); + + describe("LoadTextRequest", () => { + it("should have correct structure", () => { + const request: LoadTextRequest = { + id: "text-123", + text: "This is some text to load", + charset: "utf-8", + metadata: [], + }; + + expect(request.id).toBe("text-123"); + expect(request.text).toBe("This is some text to load"); + expect(request.charset).toBe("utf-8"); + expect(request.metadata).toEqual([]); + }); + }); + + describe("DocumentMetadata", () => { + it("should have correct structure", () => { + const metadata: DocumentMetadata = { + id: "doc-123", + time: 1640995200000, + kind: "pdf", + title: "Test Document", + comments: "A test document", + metadata: [], + user: "test-user", + tags: ["test", "document"], + }; + + expect(metadata.id).toBe("doc-123"); + expect(metadata.time).toBe(1640995200000); + expect(metadata.kind).toBe("pdf"); + expect(metadata.title).toBe("Test Document"); + expect(metadata.comments).toBe("A test document"); + expect(metadata.user).toBe("test-user"); + expect(metadata.tags).toEqual(["test", "document"]); + }); + }); + + describe("ProcessingMetadata", () => { + it("should have correct structure", () => { + const metadata: ProcessingMetadata = { + id: "proc-123", + "document-id": "doc-123", + time: 1640995200000, + flow: "default-flow", + user: "test-user", + collection: "test-collection", + tags: ["processing", "test"], + }; + + expect(metadata.id).toBe("proc-123"); + expect(metadata["document-id"]).toBe("doc-123"); + expect(metadata.time).toBe(1640995200000); + expect(metadata.flow).toBe("default-flow"); + expect(metadata.user).toBe("test-user"); + expect(metadata.collection).toBe("test-collection"); + expect(metadata.tags).toEqual(["processing", "test"]); + }); + }); + + describe("LibraryRequest", () => { + it("should have correct structure", () => { + const request: LibraryRequest = { + operation: "list_documents", + user: "test-user", + collection: "test-collection", + }; + + expect(request.operation).toBe("list_documents"); + expect(request.user).toBe("test-user"); + expect(request.collection).toBe("test-collection"); + }); + }); + + describe("LibraryResponse", () => { + it("should have correct structure", () => { + const response: LibraryResponse = { + error: new Error(), + "document-metadatas": [ + { + id: "doc-1", + title: "Document 1", + time: 1640995200000, + }, + ], + }; + + expect(response.error).toBeInstanceOf(Error); + expect(response["document-metadatas"]).toHaveLength(1); + expect(response["document-metadatas"]![0].id).toBe("doc-1"); + }); + }); + + describe("FlowRequest", () => { + it("should have correct structure", () => { + const request: FlowRequest = { + operation: "get_flow", + "flow-id": "default-flow", + }; + + expect(request.operation).toBe("get_flow"); + expect(request["flow-id"]).toBe("default-flow"); + }); + }); + + describe("FlowResponse", () => { + it("should have correct structure", () => { + const response: FlowResponse = { + "flow-ids": ["flow-1", "flow-2"], + flow: "flow-definition", + description: "A test flow", + error: undefined, + }; + + expect(response["flow-ids"]).toEqual(["flow-1", "flow-2"]); + expect(response.flow).toBe("flow-definition"); + expect(response.description).toBe("A test flow"); + expect(response.error).toBeUndefined(); + }); + }); +}); diff --git a/ai-context/trustgraph-client/src/__tests__/service-call-multi.test.ts b/ai-context/trustgraph-client/src/__tests__/service-call-multi.test.ts new file mode 100644 index 00000000..c414c574 --- /dev/null +++ b/ai-context/trustgraph-client/src/__tests__/service-call-multi.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ServiceCallMulti } from "../socket/service-call-multi"; + +// Mock WebSocket constants +vi.stubGlobal("WebSocket", { + OPEN: 1, + CONNECTING: 0, + CLOSING: 2, + CLOSED: 3, +}); + +// Mock Socket interface +const mockSocket = { + inflight: {} as Record, + 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; + let mockError: ReturnType; + let mockReceiver: ReturnType; + let serviceCallMulti: ServiceCallMulti; + + beforeEach(() => { + vi.clearAllMocks(); + mockSuccess = vi.fn(); + mockError = vi.fn(); + mockReceiver = vi.fn(); + mockSocket.inflight = {} as Record; + 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); + }); +}); diff --git a/ai-context/trustgraph-client/src/__tests__/service-call.test.ts b/ai-context/trustgraph-client/src/__tests__/service-call.test.ts new file mode 100644 index 00000000..acd72111 --- /dev/null +++ b/ai-context/trustgraph-client/src/__tests__/service-call.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ServiceCall } from "../socket/service-call"; + +// Mock WebSocket constants +vi.stubGlobal("WebSocket", { + OPEN: 1, + CONNECTING: 0, + CLOSING: 2, + CLOSED: 3, +}); + +// Mock Socket interface +const mockSocket = { + inflight: {} as Record, + 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; + let mockError: ReturnType; + let serviceCall: ServiceCall; + + beforeEach(() => { + vi.clearAllMocks(); + mockSuccess = vi.fn(); + mockError = vi.fn(); + mockSocket.inflight = {} as Record; + 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); + }); +}); diff --git a/ai-context/trustgraph-client/src/index.ts b/ai-context/trustgraph-client/src/index.ts new file mode 100644 index 00000000..c7b5f4b2 --- /dev/null +++ b/ai-context/trustgraph-client/src/index.ts @@ -0,0 +1,10 @@ +// @trustgraph/client +// TrustGraph TypeScript Client + +// Export models (data types) +export * from "./models/Triple"; +export * from "./models/messages"; +export * from "./models/namespaces"; + +// Export socket client +export * from "./socket/trustgraph-socket"; diff --git a/ai-context/trustgraph-client/src/models/Triple.ts b/ai-context/trustgraph-client/src/models/Triple.ts new file mode 100644 index 00000000..c9d7ca4c --- /dev/null +++ b/ai-context/trustgraph-client/src/models/Triple.ts @@ -0,0 +1,40 @@ +// Term type discriminators matching the wire format +// i = IRI, b = BLANK node, l = LITERAL, t = TRIPLE (reified) +export type TermType = "i" | "b" | "l" | "t"; + +export interface IriTerm { + t: "i"; + i: string; +} + +export interface BlankTerm { + t: "b"; + d: string; +} + +export interface LiteralTerm { + t: "l"; + v: string; + dt?: string; // datatype + ln?: string; // language +} + +export interface TripleTerm { + t: "t"; + tr?: Triple; +} + +export type Term = IriTerm | BlankTerm | LiteralTerm | TripleTerm; + +export interface PartialTriple { + s?: Term; + p?: Term; + o?: Term; +} + +export interface Triple { + s: Term; + p: Term; + o: Term; + g?: string; // graph (renamed from direc to match backend) +} diff --git a/ai-context/trustgraph-client/src/models/messages.ts b/ai-context/trustgraph-client/src/models/messages.ts new file mode 100644 index 00000000..26198521 --- /dev/null +++ b/ai-context/trustgraph-client/src/models/messages.ts @@ -0,0 +1,496 @@ +import { Triple, Term } from "./Triple"; + +// FIXME: Better types? +export type Request = object; +export type Response = object; +export type Error = object | string; + +export interface ResponseError { + type?: string; + message: string; +} + +export interface RequestMessage { + id: string; + service: string; + request: Request; + flow?: string; +} + +export interface ApiResponse { + id: string; + response: Response; +} + +export interface Metadata { + id?: string; + metadata?: Triple[]; + user?: string; + collection?: string; +} + +export interface EntityEmbeddings { + entity?: Term; + vectors?: number[][]; +} + +export interface GraphEmbeddings { + metadata?: Metadata; + entities?: EntityEmbeddings[]; +} + +export interface TextCompletionRequest { + system: string; + prompt: string; + streaming?: boolean; +} + +export interface TextCompletionResponse { + response: string; + // Streaming fields + end_of_stream?: boolean; + error?: { + message: string; + type?: string; + }; + // Token usage (appears in final message) + in_token?: number; + out_token?: number; + model?: string; +} + +export interface GraphRagRequest { + query: string; + user?: string; + collection?: string; + "entity-limit"?: number; // Default: 50 + "triple-limit"?: number; // Default: 30 + "max-subgraph-size"?: number; // Default: 1000 + "max-path-length"?: number; // Default: 2 + streaming?: boolean; +} + +export interface GraphRagResponse { + response: string; + // Streaming fields + chunk?: string; + end_of_stream?: boolean; + error?: { + message: string; + type?: string; + }; + // Token usage (appears in final message) + in_token?: number; + out_token?: number; + model?: string; + // Explainability fields + message_type?: "chunk" | "explain"; + explain_id?: string; + explain_graph?: string; // Named graph where explain data is stored (e.g., urn:graph:retrieval) + end_of_session?: boolean; +} + +export interface DocumentRagRequest { + query: string; + user?: string; + collection?: string; + "doc-limit"?: number; // Default: 20 + streaming?: boolean; +} + +export interface DocumentRagResponse { + response: string; + // Streaming fields + chunk?: string; + end_of_stream?: boolean; + error?: { + message: string; + type?: string; + }; + // Token usage (appears in final message) + in_token?: number; + out_token?: number; + model?: string; + // Explainability fields + message_type?: "chunk" | "explain"; + explain_id?: string; + explain_graph?: string; + end_of_session?: boolean; +} + +export interface AgentRequest { + question: string; + user?: string; + streaming?: boolean; +} + +export interface AgentResponse { + // Streaming response format (new protocol) + chunk_type?: "thought" | "action" | "observation" | "answer" | "final-answer" | "explain" | "error"; + content?: string; + end_of_message?: boolean; + end_of_dialog?: boolean; + + // Legacy fields for backward compatibility with non-streaming + thought?: string; + observation?: string; + answer?: string; + error?: ResponseError; + + // Token usage (appears in final message) + in_token?: number; + out_token?: number; + model?: string; + + // Explainability fields + message_type?: "chunk" | "explain"; + explain_id?: string; + explain_graph?: string; +} + +export interface EmbeddingsRequest { + texts: string[]; +} + +export interface EmbeddingsResponse { + vectors: number[][]; // One vector per input text +} + +export interface GraphEmbeddingsQueryRequest { + vector: number[]; // Single query vector + limit: number; + user?: string; + collection?: string; +} + +export interface EntityMatch { + entity: Term | null; + score: number; +} + +export interface GraphEmbeddingsQueryResponse { + entities: EntityMatch[]; +} + +export interface TriplesQueryRequest { + s?: Term; + p?: Term; + o?: Term; + g?: string; // Named graph URI filter (plain string, not Term) + limit: number; + user?: string; + collection?: string; +} + +export interface TriplesQueryResponse { + response: Triple[]; +} + +export interface RowsQueryRequest { + query: string; + user?: string; + collection?: string; + variables?: Record; + operation_name?: string; +} + +export interface RowsQueryResponse { + data?: Record; + errors?: Record[]; + extensions?: Record; + values?: unknown[]; +} + +export interface NlpQueryRequest { + question: string; + max_results?: number; +} + +export interface NlpQueryResponse { + graphql_query?: string; + variables?: Record; + detected_schemas?: Record[]; + confidence?: number; +} + +export interface StructuredQueryRequest { + question: string; + user?: string; + collection?: string; +} + +export interface StructuredQueryResponse { + data?: Record; + errors?: Record[]; +} + +export interface RowEmbeddingsQueryRequest { + vector: number[]; // Single query vector + schema_name: string; + user?: string; + collection?: string; + index_name?: string; + limit?: number; +} + +export interface RowEmbeddingsMatch { + index_name: string; + index_value: string[]; + text: string; + score: number; +} + +export interface RowEmbeddingsQueryResponse { + matches?: RowEmbeddingsMatch[]; + error?: { + message: string; + type?: string; + }; +} + +export interface LoadDocumentRequest { + id?: string; + data: string; + metadata?: Triple[]; +} + +export type LoadDocumentResponse = void; + +export interface LoadTextRequest { + id?: string; + text: string; + charset?: string; + metadata?: Triple[]; +} + +export type LoadTextResponse = void; + +export interface DocumentMetadata { + id?: string; + time?: number; + kind?: string; + title?: string; + comments?: string; + metadata?: Triple[]; + user?: string; + tags?: string[]; + "document-type"?: string; +} + +export interface ProcessingMetadata { + id?: string; + "document-id"?: string; + time?: number; + flow?: string; + user?: string; + collection?: string; + tags?: string[]; +} + +export interface LibraryRequest { + operation: string; + "document-id"?: string; + "processing-id"?: string; + "document-metadata"?: DocumentMetadata; + "processing-metadata"?: ProcessingMetadata; + content?: string; + user?: string; + collection?: string; + metadata?: Triple[]; + id?: string; + flow?: string; +} + +export interface LibraryResponse { + error: Error; + "document-metadata"?: DocumentMetadata; + content?: string; + "document-metadatas"?: DocumentMetadata[]; + "processing-metadata"?: ProcessingMetadata; +} + +export interface KnowledgeRequest { + operation: string; + user?: string; + id?: string; + flow?: string; + collection?: string; + triples?: Triple[]; + "graph-embeddings"?: GraphEmbeddings; +} + +export interface KnowledgeResponse { + error?: Error; + ids?: string[]; + eos?: boolean; + triples?: Triple[]; + "graph-embeddings"?: GraphEmbeddings; +} + +export interface FlowRequest { + operation: string; + "blueprint-name"?: string; + "blueprint-definition"?: string; + description?: string; + "flow-id"?: string; + parameters?: Record; + user?: string; +} + +export interface FlowResponse { + "blueprint-names"?: string[]; + "flow-ids"?: string[]; + ids?: string[]; + flow?: string; + "blueprint-definition"?: string; + description?: string; + error?: + | { + message?: string; + } + | Error; +} + +export interface PromptRequest { + id: string; + terms: Record; + streaming?: boolean; +} + +export interface PromptResponse { + text: string; + // Streaming fields + end_of_stream?: boolean; + error?: { + message: string; + type?: string; + }; + // Token usage (appears in final message) + in_token?: number; + out_token?: number; + model?: string; +} + +export type ConfigRequest = object; +export type ConfigResponse = object; + +// Chunked Upload Types + +export interface ChunkedUploadDocumentMetadata { + id: string; + time: number; + kind: string; + title: string; + comments?: string; + metadata?: Triple[]; + user: string; + collection?: string; + tags?: string[]; +} + +export interface BeginUploadRequest { + operation: "begin-upload"; + "document-metadata": ChunkedUploadDocumentMetadata; + "total-size": number; + "chunk-size"?: number; +} + +export interface BeginUploadResponse { + "upload-id": string; + "chunk-size": number; + "total-chunks": number; + error?: ResponseError; +} + +export interface UploadChunkRequest { + operation: "upload-chunk"; + "upload-id": string; + "chunk-index": number; + content: string; // base64-encoded + user: string; +} + +export interface UploadChunkResponse { + "upload-id": string; + "chunk-index": number; + "chunks-received": number; + "total-chunks": number; + "bytes-received": number; + "total-bytes": number; + error?: ResponseError; +} + +export interface CompleteUploadRequest { + operation: "complete-upload"; + "upload-id": string; + user: string; +} + +export interface CompleteUploadResponse { + "document-id": string; + "object-id": string; + error?: ResponseError; +} + +export interface GetUploadStatusRequest { + operation: "get-upload-status"; + "upload-id": string; + user: string; +} + +export interface GetUploadStatusResponse { + "upload-id": string; + "upload-state": "in-progress" | "completed" | "expired"; + "chunks-received": number; + "total-chunks": number; + "received-chunks": number[]; + "missing-chunks": number[]; + "bytes-received": number; + "total-bytes": number; + error?: ResponseError; +} + +export interface AbortUploadRequest { + operation: "abort-upload"; + "upload-id": string; + user: string; +} + +export interface AbortUploadResponse { + error?: ResponseError; +} + +export interface ListUploadsRequest { + operation: "list-uploads"; + user: string; +} + +export interface UploadSession { + "upload-id": string; + "document-id": string; + "document-metadata-json": string; + "total-size": number; + "chunk-size": number; + "total-chunks": number; + "chunks-received": number; + "created-at": string; +} + +export interface ListUploadsResponse { + "upload-sessions": UploadSession[]; + error?: ResponseError; +} + +export interface StreamDocumentRequest { + operation: "stream-document"; + "document-id": string; + "chunk-size"?: number; + user: string; +} + +export interface StreamDocumentResponse { + content: string; // base64-encoded chunk + "chunk-index": number; + "total-chunks": number; + error?: ResponseError; +} diff --git a/ai-context/trustgraph-client/src/models/namespaces.ts b/ai-context/trustgraph-client/src/models/namespaces.ts new file mode 100644 index 00000000..df75fc04 --- /dev/null +++ b/ai-context/trustgraph-client/src/models/namespaces.ts @@ -0,0 +1,42 @@ +/** + * RDF namespace constants for TrustGraph + * Used for querying explainability data, provenance chains, and knowledge graph + */ + +// TrustGraph namespace +export const TG = "https://trustgraph.ai/ns/"; +export const TG_QUERY = TG + "query"; +export const TG_EDGE_COUNT = TG + "edgeCount"; +export const TG_SELECTED_EDGE = TG + "selectedEdge"; +export const TG_EDGE = TG + "edge"; +export const TG_REASONING = TG + "reasoning"; +export const TG_CONTENT = TG + "content"; +export const TG_REIFIES = TG + "reifies"; +export const TG_DOCUMENT = TG + "document"; + +// W3C PROV-O namespace +export const PROV = "http://www.w3.org/ns/prov#"; +export const PROV_STARTED_AT_TIME = PROV + "startedAtTime"; +export const PROV_WAS_DERIVED_FROM = PROV + "wasDerivedFrom"; +export const PROV_WAS_GENERATED_BY = PROV + "wasGeneratedBy"; +export const PROV_ACTIVITY = PROV + "Activity"; +export const PROV_ENTITY = PROV + "Entity"; + +// RDFS namespace +export const RDFS = "http://www.w3.org/2000/01/rdf-schema#"; +export const RDFS_LABEL = RDFS + "label"; + +// RDF namespace +export const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +export const RDF_TYPE = RDF + "type"; + +// Schema.org namespace (used in document metadata) +export const SCHEMA = "https://schema.org/"; +export const SCHEMA_NAME = SCHEMA + "name"; +export const SCHEMA_DESCRIPTION = SCHEMA + "description"; +export const SCHEMA_AUTHOR = SCHEMA + "author"; +export const SCHEMA_KEYWORDS = SCHEMA + "keywords"; + +// SKOS namespace +export const SKOS = "http://www.w3.org/2004/02/skos/core#"; +export const SKOS_DEFINITION = SKOS + "definition"; diff --git a/ai-context/trustgraph-client/src/socket/service-call-multi.ts b/ai-context/trustgraph-client/src/socket/service-call-multi.ts new file mode 100644 index 00000000..16e3e6ff --- /dev/null +++ b/ai-context/trustgraph-client/src/socket/service-call-multi.ts @@ -0,0 +1,171 @@ +import { RequestMessage } from "../models/messages"; + +// 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?: WebSocket; + inflight: { [key: string]: ServiceCallMulti }; + reopen: () => void; + getNextId?: () => string; + user?: string; +} + +export class ServiceCallMulti { + constructor( + mid: string, + msg: RequestMessage, + success: (resp: unknown) => void, + error: (err: object | string) => void, + timeout: number, + retries: number, + socket: Socket, + receiver: (resp: unknown) => boolean, + ) { + this.mid = mid; + this.msg = msg; + this.success = success; + this.error = error; + this.timeout = timeout; + this.retries = retries; + this.socket = socket; + this.complete = false; + this.receiver = receiver; + } + + mid: string; + msg: RequestMessage; + success: (resp: unknown) => void; + error: (err: object | string) => void; + receiver: (resp: unknown) => boolean; + timeoutId?: ReturnType; + timeout: number; + retries: number; + socket: Socket; + complete: boolean; + + start() { + this.socket.inflight[this.mid] = this; + this.attempt(); + } + + onReceived(resp: object) { + if (this.complete == true) + console.log(this.mid, "should not happen, request is already complete"); + + const fin = this.receiver(resp); + + if (fin) { + this.complete = true; + + // console.log("Received for", this.mid); + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + delete this.socket.inflight[this.mid]; + this.success(resp); + } + } + + /** + * Called when socket connects - immediately retry if we were waiting + */ + retryNow() { + if (this.complete) return; + + // Clear any pending backoff timer + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + + // Restore retry count since we didn't actually fail + this.retries++; + + // Attempt immediately + this.attempt(); + } + + onTimeout() { + if (this.complete == true) + console.log( + this.mid, + "timeout should not happen, request is already complete", + ); + + console.log("Request", this.mid, "timed out"); + clearTimeout(this.timeoutId); + this.attempt(); + } + + attempt() { + // console.log("attempt:", this.mid); + + if (this.complete == true) + console.log( + this.mid, + "attempt should not be called, request is already complete", + ); + + this.retries--; + + if (this.retries < 0) { + console.log("Request", this.mid, "ran out of retries"); + + clearTimeout(this.timeoutId); + delete this.socket.inflight[this.mid]; + + this.error("Ran out of retries"); + return; // Exit early - no more attempts + } + + // Check if WebSocket connection is available and ready + if (this.socket.ws && this.socket.ws.readyState === WebSocket.OPEN) { + try { + this.socket.ws.send(JSON.stringify(this.msg)); + this.timeoutId = setTimeout(this.onTimeout.bind(this), this.timeout); + + return; + } catch (e) { + console.log("Error:", e); + console.log("Message send failure, retry..."); + + // Calculate backoff delay with jitter + const backoffDelay = Math.min( + SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) + + Math.random() * 1000, + 30000, // Max 30 seconds + ); + + this.timeoutId = setTimeout(this.attempt.bind(this), backoffDelay); + + console.log("Reopen..."); + // Attempt to reopen the WebSocket connection + this.socket.reopen(); + } + } else { + // No WebSocket connection available or not ready + // Check if socket is connecting + if ( + this.socket.ws && + this.socket.ws.readyState === WebSocket.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); + } + } + } +} diff --git a/ai-context/trustgraph-client/src/socket/service-call.ts b/ai-context/trustgraph-client/src/socket/service-call.ts new file mode 100644 index 00000000..4b5fe80b --- /dev/null +++ b/ai-context/trustgraph-client/src/socket/service-call.ts @@ -0,0 +1,239 @@ +import { RequestMessage } from "../models/messages"; + +// 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?: WebSocket; + inflight: { [key: string]: ServiceCall }; + reopen: () => void; + getNextId?: () => string; + user?: string; +} + +/** + * ServiceCall represents a single request/response cycle over a WebSocket + * connection with built-in retry logic, timeout handling, and completion + * tracking. + * + * This class manages the lifecycle of a service call including: + * - Sending the initial request + * - Handling timeouts and retries + * - Managing completion state + * - Cleaning up resources + */ +export class ServiceCall { + constructor( + mid: string, // Message ID - unique identifier for this request + msg: RequestMessage, // The actual message/request to send + success: (resp: unknown) => void, // Callback function called on + // successful response + error: (err: object | string) => void, // Callback function called on error/failure + timeout: number, // Timeout duration in milliseconds + retries: number, // Number of retry attempts allowed + socket: Socket, // WebSocket instance to send the message through + ) { + this.mid = mid; + this.msg = msg; + this.success = success; + this.error = error; + this.timeout = timeout; + this.retries = retries; + this.socket = socket; + this.complete = false; // Track if this request has completed + } + + // Properties + mid: string; // Message identifier + msg: RequestMessage; // The request message + success: (resp: unknown) => void; // Success callback + error: (err: object | string) => void; // Error callback + timeoutId?: ReturnType; // Reference to the active timeout timer + timeout: number; // Timeout duration in milliseconds + retries: number; // Remaining retry attempts + socket: Socket; // WebSocket connection reference + complete: boolean; // Flag indicating if request is complete + + /** + * Initiates the service call by registering it with the socket's inflight + * requests and making the first attempt to send the message + */ + start() { + // Register this request as "in-flight" so responses can be matched to it + this.socket.inflight[this.mid] = this; + // Make the first attempt to send the message + this.attempt(); + } + + /** + * Called when a response is received for this request + * Handles cleanup and calls the success or error callback based on response + * + * @param resp - The response object received from the server + */ + onReceived(resp: object) { + // Defensive check - this shouldn't happen but log if it does + if (this.complete == true) + console.log(this.mid, "should not happen, request is already complete"); + + // Mark as complete to prevent duplicate processing + this.complete = true; + + // Clean up timeout timer + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + + // Remove from inflight requests tracker + delete this.socket.inflight[this.mid]; + + // Check if the response contains an error (error can be directly in resp or nested under response) + let errorToHandle: unknown = null; + + // Check for direct error in response + if (resp && typeof resp === "object" && "error" in resp) { + errorToHandle = (resp as Record).error; + } + // Check for nested error under response property + else if (resp && typeof resp === "object" && "response" in resp) { + const response = (resp as Record).response; + if (response && typeof response === "object" && "error" in response) { + errorToHandle = (response as Record).error; + } + } + + if (errorToHandle) { + // Response contains an error - call error callback + const errorObj = errorToHandle as Record; + const errorMessage = + (typeof errorObj.message === "string" ? errorObj.message : null) || + (typeof errorObj.type === "string" ? errorObj.type : null) || + "Unknown error"; + console.log( + "ServiceCall: API error detected in response:", + errorMessage, + "Full error:", + errorToHandle, + ); + this.error(new Error(errorMessage)); + return; + } + + // Extract the response field from the message object + // The resp parameter is the full message: {id, response, complete} + // We need to pass just the response field to the success callback + const responseData = (resp as { response?: unknown }).response; + this.success(responseData); + } + + /** + * Called when socket connects - immediately retry if we were waiting + */ + retryNow() { + if (this.complete) return; + + // Clear any pending backoff timer + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + + // Restore retry count since we didn't actually fail + this.retries++; + + // Attempt immediately + this.attempt(); + } + + /** + * Called when the request times out + * Triggers another attempt if retries are available + */ + onTimeout() { + // Defensive check - this shouldn't happen but log if it does + if (this.complete == true) + console.log( + this.mid, + "timeout should not happen, request is already complete", + ); + + console.log("Request", this.mid, "timed out"); + + // Clear the current timeout + clearTimeout(this.timeoutId); + + // Try again (this will check retry count) + this.attempt(); + } + + /** + * Calculates exponential backoff delay with jitter + * @returns backoff delay in milliseconds + */ + calculateBackoff() { + return Math.min( + SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) + + Math.random() * 1000, + 30000, // Max 30 seconds + ); + } + + /** + * Core retry logic - attempts to send the message over the WebSocket + * Handles retries and waits for BaseApi to handle reconnection + */ + attempt() { + // Defensive check - this shouldn't be called on completed requests + if (this.complete == true) + console.log( + this.mid, + "attempt should not be called, request is already complete", + ); + + // Decrement retry counter + this.retries--; + + // Check if we've exhausted all retries + if (this.retries < 0) { + console.log("Request", this.mid, "ran out of retries"); + + // Clean up and call error callback + clearTimeout(this.timeoutId); + delete this.socket.inflight[this.mid]; + this.error("Ran out of retries"); + return; // Exit early - no more attempts + } + + // Check if WebSocket connection is available and ready + if (this.socket.ws && this.socket.ws.readyState === WebSocket.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()); + } + } +} diff --git a/ai-context/trustgraph-client/src/socket/trustgraph-socket.ts b/ai-context/trustgraph-client/src/socket/trustgraph-socket.ts new file mode 100644 index 00000000..e3adf6b3 --- /dev/null +++ b/ai-context/trustgraph-client/src/socket/trustgraph-socket.ts @@ -0,0 +1,2353 @@ +// Import core types and classes for the TrustGraph API +import { Triple, Term } from "../models/Triple"; +import { ServiceCallMulti } from "./service-call-multi"; +import { ServiceCall } from "./service-call"; + +// Import all message types for different services +import { + AgentRequest, + AgentResponse, + ConfigRequest, + ConfigResponse, + DocumentMetadata, + DocumentRagRequest, + DocumentRagResponse, + EmbeddingsRequest, + EmbeddingsResponse, + EntityMatch, + FlowRequest, + FlowResponse, + GraphEmbeddingsQueryRequest, + GraphEmbeddingsQueryResponse, + GraphRagRequest, + GraphRagResponse, + // KnowledgeRequest, + // KnowledgeResponse, + LibraryRequest, + LibraryResponse, + LoadDocumentRequest, + LoadDocumentResponse, + LoadTextRequest, + LoadTextResponse, + NlpQueryRequest, + NlpQueryResponse, + RowsQueryRequest, + RowsQueryResponse, + RowEmbeddingsQueryRequest, + RowEmbeddingsQueryResponse, + RowEmbeddingsMatch, + PromptRequest, + PromptResponse, + // ProcessingMetadata, + RequestMessage, + StructuredQueryRequest, + StructuredQueryResponse, + TextCompletionRequest, + TextCompletionResponse, + TriplesQueryRequest, + TriplesQueryResponse, + // Chunked upload types + ChunkedUploadDocumentMetadata, + BeginUploadRequest, + BeginUploadResponse, + UploadChunkRequest, + UploadChunkResponse, + CompleteUploadRequest, + CompleteUploadResponse, + GetUploadStatusRequest, + GetUploadStatusResponse, + AbortUploadRequest, + AbortUploadResponse, + ListUploadsRequest, + ListUploadsResponse, + UploadSession, + StreamDocumentRequest, + StreamDocumentResponse, + // EntityEmbeddings, + // Error, + // GraphEmbedding, + // Metadata, + // Request, + // Response, +} from "../models/messages"; + +// GraphRAG options interface for configurable parameters +export interface GraphRagOptions { + entityLimit?: number; + tripleLimit?: number; + maxSubgraphSize?: number; + pathLength?: number; +} + +// Metadata included in final streaming message +export interface StreamingMetadata { + in_token?: number; + out_token?: number; + model?: string; +} + +// Explainability event data +export interface ExplainEvent { + explainId: string; + explainGraph: string; // Named graph where explain data is stored (e.g., urn:graph:retrieval) +} + +// Configuration constants +const SOCKET_RECONNECTION_TIMEOUT = 2000; // 2 seconds between reconnection +// attempts +const SOCKET_URL = "/api/socket"; // WebSocket endpoint path + +/** + * Socket interface defining all available operations for the TrustGraph API + * This provides a unified interface for various AI/ML and knowledge graph + * operations + */ +export interface Socket { + close: () => void; + + // Text completion using AI models + textCompletion: (system: string, text: string) => Promise; + + // Graph-based Retrieval Augmented Generation + graphRag: (text: string, options?: GraphRagOptions) => Promise; + + // Agent interaction with streaming callbacks for different phases + // BREAKING CHANGE: Callbacks now receive (chunk, complete, metadata?) instead of full messages + agent: ( + question: string, + think: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + observe: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + answer: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + error: (e: string) => void, + onExplain?: (event: ExplainEvent) => void, + ) => void; + + // Streaming variants for RAG and completion services + graphRagStreaming: ( + text: string, + receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + onError: (error: string) => void, + options?: GraphRagOptions, + collection?: string, + ) => void; + + documentRagStreaming: ( + text: string, + receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + onError: (error: string) => void, + docLimit?: number, + collection?: string, + onExplain?: (event: ExplainEvent) => void, + ) => void; + + textCompletionStreaming: ( + system: string, + text: string, + receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + onError: (error: string) => void, + ) => void; + + promptStreaming: ( + id: string, + terms: Record, + receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + onError: (error: string) => void, + ) => void; + + // Generate embeddings for texts (batch) + embeddings: (texts: string[]) => Promise; + + // Query graph using embedding vector + graphEmbeddingsQuery: (vec: number[], limit: number) => Promise; + + // Query knowledge graph triples (subject-predicate-object) + triplesQuery: ( + s?: Term, // Subject (optional) + p?: Term, // Predicate (optional) + o?: Term, // Object (optional) + limit?: number, + collection?: string, + graph?: string, // Named graph URI filter + ) => Promise; + + // Load a document into the system + loadDocument: ( + document: string, // Base64-encoded document + id?: string, // Optional document ID + metadata?: Triple[], // Optional metadata as triples + ) => Promise; + + // Load plain text into the system + loadText: (text: string, id?: string, metadata?: Triple[]) => Promise; + + // Load a document into the library with full metadata + loadLibraryDocument: ( + document: string, + mimeType: string, + id?: string, + metadata?: Triple[], + ) => Promise; +} + +/** + * Generates a random message ID using cryptographically secure random values + * @param length - Number of random characters to generate + * @returns Random string of specified length + */ +function makeid(length: number) { + const array = new Uint32Array(length); + crypto.getRandomValues(array); + + const characters = "abcdefghijklmnopqrstuvwxyz1234567890"; + + return array.reduce( + (acc, current) => acc + characters[current % characters.length], + "", + ); +} + +/** + * BaseApi - Core WebSocket client for TrustGraph API + * Manages connection lifecycle, message routing, and provides base request + * functionality + */ +// Connection state interface for UI consumption +export interface ConnectionState { + status: + | "connecting" + | "connected" + | "reconnecting" + | "failed" + | "authenticated" + | "unauthenticated"; + hasApiKey: boolean; + reconnectAttempt?: number; + maxAttempts?: number; + nextRetryIn?: number; + lastError?: string; +} + +export class BaseApi { + ws?: WebSocket; // WebSocket connection instance + tag: string; // Unique client identifier + id: number; // Counter for generating unique message IDs + token?: string; // Optional authentication token + user: string; // User identifier for API requests + socketUrl: string; // WebSocket URL + inflight: { [key: string]: ServiceCall } = {}; // Track active requests by + // message ID + reconnectAttempts: number = 0; // Track reconnection attempts + maxReconnectAttempts: number = 10; // Maximum reconnection attempts + reconnectTimer?: number; // Timer for reconnection attempts + reconnectionState: "idle" | "reconnecting" | "failed" = "idle"; // Connection state + + // Connection state tracking for UI + private connectionStateListeners: ((state: ConnectionState) => void)[] = []; + private lastError?: string; + + constructor(user: string, token?: string, socketUrl?: string) { + this.tag = makeid(16); // Generate unique client tag + this.id = 1; // Start message ID counter + this.token = token; // Store authentication token + this.user = user; // Store user identifier + this.socketUrl = socketUrl || SOCKET_URL; // Use provided URL or default + + console.log( + "SOCKET: opening socket...", + token ? "with auth" : "without auth", + "user:", + user, + ); + this.openSocket(); // Establish WebSocket connection + console.log("SOCKET: socket opened"); + } + + /** + * Subscribe to connection state changes for UI updates + */ + onConnectionStateChange(listener: (state: ConnectionState) => void) { + this.connectionStateListeners.push(listener); + // Immediately send current state + listener(this.getConnectionState()); + + // Return unsubscribe function + return () => { + const index = this.connectionStateListeners.indexOf(listener); + if (index > -1) { + this.connectionStateListeners.splice(index, 1); + } + }; + } + + /** + * Get current connection state + */ + private getConnectionState(): ConnectionState { + const hasApiKey = !!this.token; + + // Determine status based on WebSocket state and reconnection state + let status: ConnectionState["status"]; + + if (!this.ws || this.ws.readyState === WebSocket.CLOSED) { + if (this.reconnectionState === "failed") { + status = "failed"; + } else if (this.reconnectionState === "reconnecting") { + status = "reconnecting"; + } else { + status = "connecting"; + } + } else if (this.ws.readyState === WebSocket.CONNECTING) { + status = "connecting"; + } else if (this.ws.readyState === WebSocket.OPEN) { + status = hasApiKey ? "authenticated" : "unauthenticated"; + } else { + status = "connecting"; + } + + const state: ConnectionState = { + status, + hasApiKey, + lastError: this.lastError, + }; + + // Add reconnection details if applicable + if (status === "reconnecting") { + state.reconnectAttempt = this.reconnectAttempts; + state.maxAttempts = this.maxReconnectAttempts; + } + + return state; + } + + /** + * Notify all listeners of connection state changes + */ + private notifyStateChange() { + const state = this.getConnectionState(); + this.connectionStateListeners.forEach((listener) => { + try { + listener(state); + } catch (error) { + console.error("Error in connection state listener:", error); + } + }); + } + + /** + * Establishes WebSocket connection and sets up event handlers + */ + openSocket() { + // Don't create multiple connections + if ( + this.ws && + (this.ws.readyState === WebSocket.CONNECTING || + this.ws.readyState === WebSocket.OPEN) + ) { + return; + } + + // Clean up old socket if exists + if (this.ws) { + 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 = this.token + ? `${this.socketUrl}?token=${this.token}` + : this.socketUrl; + console.log( + "SOCKET: connecting to", + wsUrl.replace(/token=[^&]*/, "token=***"), + ); + this.ws = new WebSocket(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: MessageEvent) { + if (!message.data) return; + + try { + const obj = JSON.parse(message.data); + + // Skip messages without ID (can't route them) + if (!obj.id) return; + + // Route response to the corresponding inflight request + if (this.inflight[obj.id]) { + // Pass the whole message object so receiver can access 'complete' flag + this.inflight[obj.id].onReceived(obj); + } + } catch (e) { + console.error("[socket message parse error]", e); + } + } + + // Handle connection closure - automatically attempt reconnection + onClose(event: CloseEvent) { + console.log("[socket close]", event.code, event.reason); + this.lastError = `Connection closed: ${event.reason || "Unknown reason"}`; + this.ws = undefined; + this.notifyStateChange(); + this.scheduleReconnect(); + } + + // Handle successful connection + onOpen() { + 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) { + 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: Event) { + 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) 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 && + (this.ws.readyState === WebSocket.OPEN || + this.ws.readyState === WebSocket.CONNECTING) + ) { + return; + } + this.openSocket(); + } + + /** + * Closes the WebSocket connection and cleans up + */ + close() { + // Clear reconnection timer + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + + // Clean up WebSocket + if (this.ws) { + // 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 = {}; + } + + /** + * Generates the next unique message ID for requests + * Format: {clientTag}-{incrementingNumber} + */ + getNextId() { + const mid = this.tag + "-" + this.id.toString(); + this.id++; + return mid; + } + + /** + * Core method for making service requests over WebSocket + * @param service - Name of the service to call + * @param request - Request payload + * @param timeout - Request timeout in milliseconds (default: 10000) + * @param retries - Number of retry attempts (default: 3) + * @param flow - Optional flow identifier + * @returns Promise resolving to the service response + */ + makeRequest( + service: string, + request: RequestType, + 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 (flow) msg.flow = flow; + + // Return a Promise that will be resolved/rejected by the ServiceCall + return new Promise((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 obj as ResponseType; + }); + } + + /** + * Makes a request that can receive multiple responses (streaming) + * Used for operations that return data in chunks + */ + makeRequestMulti( + service: string, + request: RequestType, + receiver: (resp: unknown) => boolean, // Callback to handle each response chunk + 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 (flow) msg.flow = flow; + + return new Promise((resolve, reject) => { + const call = new ServiceCallMulti( + mid, + msg, + resolve as (resp: unknown) => void, + reject as (err: object | string) => void, + timeout, + retries, + this as any, // eslint-disable-line @typescript-eslint/no-explicit-any + receiver, + ); + + call.start(); + }).then((obj) => { + return obj as ResponseType; + }); + } + + /** + * Convenience method for making flow-specific requests + * Defaults to "default" flow if none specified + */ + makeFlowRequest( + service: string, + request: RequestType, + timeout?: number, + retries?: number, + flow?: string, + ) { + if (!flow) flow = "default"; + + return this.makeRequest( + service, + request, + timeout, + retries, + flow, + ); + } + + // Factory methods for creating specialized API instances + librarian() { + return new LibrarianApi(this); + } + + flows() { + return new FlowsApi(this); + } + + flow(id: string) { + return new FlowApi(this, id); + } + + knowledge() { + return new KnowledgeApi(this); + } + + config() { + return new ConfigApi(this); + } + + collectionManagement() { + return new CollectionManagementApi(this); + } +} + +/** + * LibrarianApi - Manages document storage and retrieval + * Handles document lifecycle including upload, processing, and removal + */ +export class LibrarianApi { + api: BaseApi; + + constructor(api: BaseApi) { + this.api = api; + } + + /** + * Retrieves list of all documents in the system + */ + getDocuments() { + return this.api + .makeRequest( + "librarian", + { + operation: "list-documents", + user: this.api.user, + }, + 60000, // 60 second timeout for potentially large lists + ) + .then((r) => r["document-metadatas"] || []); + } + + /** + * Retrieves list of documents currently being processed + */ + getProcessing() { + return this.api + .makeRequest( + "librarian", + { + operation: "list-processing", + user: this.api.user, + }, + 60000, + ) + .then((r) => r["processing-metadata"] || []); + } + + /** + * Retrieves metadata for a single document by ID + * @param documentId - Document URI/ID to fetch + * @returns Document metadata including title, comments, tags, and RDF metadata + */ + getDocumentMetadata(documentId: string): Promise { + return this.api + .makeRequest( + "librarian", + { + operation: "get-document-metadata", + "document-id": documentId, + user: this.api.user, + }, + 30000, + ) + .then((r) => r["document-metadata"] || null); + } + + /** + * Uploads a document to the library with full metadata + * @param document - Base64-encoded document content + * @param id - Optional document identifier + * @param metadata - Optional metadata as triples + * @param mimeType - Document MIME type + * @param title - Document title + * @param comments - Additional comments + * @param tags - Document tags for categorization + */ + loadDocument( + document: string, // base64-encoded doc + mimeType: string, + title: string, + comments: string, + tags: string[], + id?: string, + metadata?: Triple[], + ) { + return this.api.makeRequest( + "librarian", + { + operation: "add-document", + "document-metadata": { + id: id, + time: Math.floor(Date.now() / 1000), // Unix timestamp + kind: mimeType, + title: title, + comments: comments, + metadata: metadata, + user: this.api.user, + tags: tags, + }, + content: document, + }, + 30000, // 30 second timeout for document upload + ); + } + + /** + * Removes a document from the library + */ + removeDocument(id: string, collection?: string) { + return this.api.makeRequest( + "librarian", + { + operation: "remove-document", + "document-id": id, + user: this.api.user, + collection: collection || "default", + }, + 30000, + ); + } + + /** + * Adds a document to the processing queue + * @param id - Processing job identifier + * @param doc_id - Document to process + * @param flow - Processing flow to use + * @param collection - Collection to add processed data to + * @param tags - Tags for the processing job + */ + addProcessing( + id: string, + doc_id: string, + flow: string, + collection?: string, + tags?: string[], + ) { + return this.api.makeRequest( + "librarian", + { + operation: "add-processing", + "processing-metadata": { + id: id, + "document-id": doc_id, + time: Math.floor(Date.now() / 1000), + flow: flow, + user: this.api.user, + collection: collection ? collection : "default", + tags: tags ? tags : [], + }, + }, + 30000, + ); + } + + // ========== Chunked Upload API ========== + + /** + * Initialize a chunked upload session for large documents (>2MB) + * @param metadata - Document metadata including id, title, kind (MIME type), etc. + * @param totalSize - Total size of the document in bytes + * @param chunkSize - Optional chunk size (default: 5MB) + * @returns Upload session info including upload-id and total-chunks + */ + beginUpload( + metadata: ChunkedUploadDocumentMetadata, + totalSize: number, + chunkSize?: number, + ): Promise { + return this.api + .makeRequest( + "librarian", + { + operation: "begin-upload", + "document-metadata": metadata, + "total-size": totalSize, + "chunk-size": chunkSize, + }, + 30000, + ) + .then((r) => { + if (r.error) { + throw new Error(r.error.message); + } + return r; + }); + } + + /** + * Upload a single chunk of a document + * Chunks can be uploaded in any order and in parallel + * @param uploadId - Upload session ID from beginUpload + * @param chunkIndex - Zero-based chunk index + * @param content - Base64-encoded chunk content + * @returns Progress info including chunks-received and bytes-received + */ + uploadChunk( + uploadId: string, + chunkIndex: number, + content: string, + ): Promise { + return this.api + .makeRequest( + "librarian", + { + operation: "upload-chunk", + "upload-id": uploadId, + "chunk-index": chunkIndex, + content: content, + user: this.api.user, + }, + 60000, // Longer timeout for chunk uploads + ) + .then((r) => { + if (r.error) { + throw new Error(r.error.message); + } + return r; + }); + } + + /** + * Finalize a chunked upload after all chunks are received + * Triggers document processing + * @param uploadId - Upload session ID from beginUpload + * @returns Document ID and object ID + */ + completeUpload(uploadId: string): Promise { + return this.api + .makeRequest( + "librarian", + { + operation: "complete-upload", + "upload-id": uploadId, + user: this.api.user, + }, + 30000, + ) + .then((r) => { + if (r.error) { + throw new Error(r.error.message); + } + return r; + }); + } + + /** + * Check upload progress (useful for resuming interrupted uploads) + * @param uploadId - Upload session ID + * @returns Status including received/missing chunks + */ + getUploadStatus(uploadId: string): Promise { + return this.api + .makeRequest( + "librarian", + { + operation: "get-upload-status", + "upload-id": uploadId, + user: this.api.user, + }, + 30000, + ) + .then((r) => { + if (r.error) { + throw new Error(r.error.message); + } + return r; + }); + } + + /** + * Cancel an in-progress upload and clean up + * @param uploadId - Upload session ID to abort + */ + abortUpload(uploadId: string): Promise { + return this.api + .makeRequest( + "librarian", + { + operation: "abort-upload", + "upload-id": uploadId, + user: this.api.user, + }, + 30000, + ) + .then((r) => { + if (r.error) { + throw new Error(r.error.message); + } + }); + } + + /** + * List pending upload sessions for the current user + * @returns Array of upload sessions with metadata and progress + */ + listUploads(): Promise { + return this.api + .makeRequest( + "librarian", + { + operation: "list-uploads", + user: this.api.user, + }, + 30000, + ) + .then((r) => { + if (r.error) { + throw new Error(r.error.message); + } + return r["upload-sessions"] || []; + }); + } + + /** + * Stream a document in chunks for retrieval (streaming response) + * Sends one request, receives multiple chunk responses via callback + * @param documentId - Document ID to retrieve + * @param onChunk - Callback for each chunk: (content, chunkIndex, totalChunks, complete) => void + * @param onError - Callback for errors + * @param chunkSize - Optional chunk size (default: 1MB) + */ + streamDocument( + documentId: string, + onChunk: (content: string, chunkIndex: number, totalChunks: number, complete: boolean) => void, + onError: (error: string) => void, + chunkSize?: number, + ): void { + const receiver = (message: unknown): boolean => { + const msg = message as { response?: StreamDocumentResponse; complete?: boolean; error?: string }; + + // Check for top-level error + if (msg.error) { + onError(msg.error); + return true; + } + + const resp = msg.response; + if (!resp) { + return !!msg.complete; + } + + // Check for response-level error + if (resp.error) { + onError(resp.error.message); + return true; + } + + const complete = !!msg.complete; + onChunk(resp.content, resp["chunk-index"], resp["total-chunks"], complete); + + return complete; + }; + + this.api.makeRequestMulti( + "librarian", + { + operation: "stream-document", + "document-id": documentId, + "chunk-size": chunkSize, + user: this.api.user, + }, + receiver, + 300000, // 5 minute timeout for full document stream + ); + } +} + +/** + * FlowsApi - Manages processing flows and configuration + * Flows define how documents and data are processed through the system + */ +export class FlowsApi { + api: BaseApi; + + constructor(api: BaseApi) { + this.api = api; + } + + /** + * Retrieves list of available flows + */ + getFlows() { + return this.api + .makeRequest( + "flow", + { + operation: "list-flows", + }, + 60000, + ) + .then((r) => r["flow-ids"] || []); + } + + /** + * Retrieves definition of a specific flow + */ + getFlow(id: string) { + return this.api + .makeRequest( + "flow", + { + operation: "get-flow", + "flow-id": id, + }, + 60000, + ) + .then((r) => JSON.parse(r.flow || "{}")); // Parse JSON flow definition + } + + // Configuration management methods + + /** + * Retrieves all configuration settings + */ + getConfigAll() { + return this.api.makeRequest( + "config", + { + operation: "config", + }, + 60000, + ); + } + + /** + * Retrieves specific configuration values by key + */ + getConfig(keys: { type: string; key: string }[]) { + return this.api.makeRequest( + "config", + { + operation: "get", + keys: keys, + }, + 60000, + ); + } + + /** + * Updates configuration values + */ + putConfig(values: { type: string; key: string; value: string }[]) { + return this.api.makeRequest( + "config", + { + operation: "put", + values: values, + }, + 60000, + ); + } + + /** + * Deletes configuration entries + */ + deleteConfig(keys: { type: string; key: string }) { + return this.api.makeRequest( + "config", + { + operation: "delete", + keys: keys, + }, + 30000, + ); + } + + // Prompt management - specialized config operations for AI prompts + + /** + * Retrieves list of available prompt templates + */ + getPrompts() { + return this.getConfigAll().then((r) => { + const config = r as Record< + string, + Record> + >; + return JSON.parse(config.config.prompt["template-index"]); + }); + } + + /** + * Retrieves a specific prompt template + */ + getPrompt(id: string) { + return this.getConfigAll().then((r) => { + const config = r as Record< + string, + Record> + >; + return JSON.parse(config.config.prompt[`template.${id}`]); + }); + } + + /** + * Retrieves the system prompt configuration + */ + getSystemPrompt() { + return this.getConfigAll().then((r) => { + const config = r as Record< + string, + Record> + >; + return JSON.parse(config.config.prompt.system); + }); + } + + // Flow blueprint management - templates for creating flows + + /** + * Retrieves list of available flow blueprints (templates) + */ + getFlowBlueprints() { + return this.api + .makeRequest( + "flow", + { + operation: "list-blueprints", + }, + 60000, + ) + .then((r) => r["blueprint-names"]); + } + + /** + * Retrieves definition of a specific flow blueprint + */ + getFlowBlueprint(name: string) { + return this.api + .makeRequest( + "flow", + { + operation: "get-blueprint", + "blueprint-name": name, + }, + 60000, + ) + .then((r) => JSON.parse(r["blueprint-definition"] || "{}")); + } + + /** + * Deletes a flow blueprint + */ + deleteFlowBlueprint(name: string) { + return this.api.makeRequest( + "flow", + { + operation: "delete-blueprint", + "blueprint-name": name, + }, + 30000, + ); + } + + // Flow lifecycle management + + /** + * Starts a new flow instance + */ + startFlow( + id: string, + blueprint_name: string, + description: string, + parameters?: Record, + ) { + const request: FlowRequest = { + operation: "start-flow", + "flow-id": id, + "blueprint-name": blueprint_name, + description: description, + }; + + // Only include parameters if provided and not empty + if (parameters && Object.keys(parameters).length > 0) { + request.parameters = parameters; + } + + return this.api + .makeRequest("flow", request, 30000) + .then((response) => { + if (response.error) { + let errorMessage = "Flow start failed"; + if ( + typeof response.error === "object" && + response.error && + "message" in response.error + ) { + errorMessage = + (response.error as { message?: string }).message || errorMessage; + } else if (typeof response.error === "string") { + errorMessage = response.error; + } + throw new Error(errorMessage); + } + return response; + }); + } + + /** + * Stops a running flow instance + */ + stopFlow(id: string) { + return this.api.makeRequest( + "flow", + { + operation: "stop-flow", + "flow-id": id, + }, + 30000, + ); + } +} + +/** + * FlowApi - Interface for interacting with a specific flow instance + * Provides flow-specific versions of core AI/ML operations + */ +export class FlowApi { + api: BaseApi; + flowId: string; + + constructor(api: BaseApi, flowId: string) { + this.api = api; + this.flowId = flowId; // All requests will be routed through this flow + } + + /** + * Performs text completion using AI models within this flow + */ + textCompletion(system: string, text: string): Promise { + return this.api + .makeRequest( + "text-completion", + { + system: system, // System prompt/instructions + prompt: text, // User prompt + }, + 30000, + undefined, // Use default retries + this.flowId, // Route through this flow + ) + .then((r) => r.response); + } + + /** + * Performs Graph RAG (Retrieval Augmented Generation) query + */ + graphRag(text: string, options?: GraphRagOptions, collection?: string) { + return this.api + .makeRequest( + "graph-rag", + { + query: text, + user: this.api.user, + collection: collection || "default", + "entity-limit": options?.entityLimit, + "triple-limit": options?.tripleLimit, + "max-subgraph-size": options?.maxSubgraphSize, + "max-path-length": options?.pathLength, + }, + 60000, // Longer timeout for complex graph operations + undefined, + this.flowId, + ) + .then((r) => r.response); + } + + /** + * Performs Document RAG (Retrieval Augmented Generation) query + */ + documentRag(text: string, docLimit?: number, collection?: string) { + return this.api + .makeRequest( + "document-rag", + { + query: text, + user: this.api.user, + collection: collection || "default", + "doc-limit": docLimit || 20, + }, + 60000, // Longer timeout for document operations + undefined, + this.flowId, + ) + .then((r) => r.response); + } + + /** + * Interacts with an AI agent that provides streaming responses + * BREAKING CHANGE: Callbacks now receive (chunk, complete, metadata?) instead of full messages + */ + agent( + question: string, + think: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + observe: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + answer: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + error: (s: string) => void, + onExplain?: (event: ExplainEvent) => void, + ) { + const receiver = (message: unknown) => { + const msg = message as { response?: AgentResponse; complete?: boolean; error?: string }; + + // Check for top-level error + if (msg.error) { + error(msg.error); + return true; + } + + const resp = msg.response || {}; + + // Check for errors in response + if (resp.chunk_type === "error" || resp.error) { + error(resp.error?.message || "Unknown agent error"); + return true; // End streaming on error + } + + // Handle explainability events (agent uses chunk_type="explain") + if ((resp.chunk_type === "explain" || resp.message_type === "explain") && resp.explain_id && resp.explain_graph) { + onExplain?.({ + explainId: resp.explain_id, + explainGraph: resp.explain_graph, + }); + return false; + } + + // Handle streaming chunks by chunk_type + const content = resp.content || ""; + const messageComplete = !!resp.end_of_message; + const dialogComplete = !!msg.complete; + + // Extract metadata from final message + const metadata: StreamingMetadata | undefined = dialogComplete && (resp.in_token || resp.out_token || resp.model) + ? { in_token: resp.in_token, out_token: resp.out_token, model: resp.model } + : undefined; + + switch (resp.chunk_type) { + case "thought": + think(content, messageComplete, metadata); + break; + case "observation": + observe(content, messageComplete, metadata); + break; + case "answer": + case "final-answer": + answer(content, messageComplete, metadata); + break; + case "action": + // Actions are typically not streamed incrementally, just logged + console.log("Agent action:", content); + break; + } + + return dialogComplete; // End when backend signals complete + }; + + return this.api + .makeRequestMulti( + "agent", + { + question: question, + user: this.api.user, + streaming: true, // Always use streaming mode + }, + receiver, + 120000, + 2, + this.flowId, + ) + .catch((err) => { + const errorMessage = + err instanceof Error ? err.message : err?.toString() || "Unknown error"; + error(`Agent request failed: ${errorMessage}`); + }); + } + + /** + * Performs Graph RAG query with streaming response + * @param text - Query text + * @param receiver - Called for each chunk with (chunk, complete) where complete=true on final chunk + * @param onError - Called on error + * @param options - Graph RAG options (including explainable flag) + * @param collection - Collection name + * @param onExplain - Optional callback for explainability events + */ + graphRagStreaming( + text: string, + receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + onError: (error: string) => void, + options?: GraphRagOptions, + collection?: string, + onExplain?: (event: ExplainEvent) => void, + ): void { + const recv = (message: unknown): boolean => { + const msg = message as { response?: GraphRagResponse; complete?: boolean; error?: string }; + + // Check for top-level error + if (msg.error) { + onError(msg.error); + return true; + } + + const resp = (msg.response || {}) as GraphRagResponse; + + // Check for response-level error + if (resp.error) { + onError(resp.error.message); + return true; + } + + // Handle explainability events + if (resp.message_type === "explain" && resp.explain_id && resp.explain_graph) { + onExplain?.({ + explainId: resp.explain_id, + explainGraph: resp.explain_graph, + }); + // Don't return true - more messages may follow + return false; + } + + // Handle chunk messages (default behavior) + const chunk = resp.response || resp.chunk || ""; + const complete = !!resp.end_of_session || !!msg.complete; + + // Extract metadata from final message + const metadata: StreamingMetadata | undefined = complete && (resp.in_token || resp.out_token || resp.model) + ? { in_token: resp.in_token, out_token: resp.out_token, model: resp.model } + : undefined; + + receiver(chunk, complete, metadata); + + return complete; + }; + + this.api.makeRequestMulti( + "graph-rag", + { + query: text, + user: this.api.user, + collection: collection || "default", + "entity-limit": options?.entityLimit, + "triple-limit": options?.tripleLimit, + "max-subgraph-size": options?.maxSubgraphSize, + "max-path-length": options?.pathLength, + streaming: true, + }, + recv, + 60000, + undefined, + this.flowId, + ); + } + + /** + * Performs Document RAG query with streaming response + * @param text - Query text + * @param receiver - Called for each chunk with (chunk, complete) where complete=true on final chunk + * @param onError - Called on error + * @param docLimit - Maximum documents to retrieve + * @param collection - Collection name + */ + documentRagStreaming( + text: string, + receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + onError: (error: string) => void, + docLimit?: number, + collection?: string, + onExplain?: (event: ExplainEvent) => void, + ): void { + const recv = (message: unknown): boolean => { + const msg = message as { response?: DocumentRagResponse; complete?: boolean; error?: string }; + + // Check for top-level error + if (msg.error) { + onError(msg.error); + return true; + } + + const resp = (msg.response || {}) as DocumentRagResponse; + + // Check for response-level error + if (resp.error) { + onError(resp.error.message); + return true; + } + + // Handle explainability events + if (resp.message_type === "explain" && resp.explain_id && resp.explain_graph) { + onExplain?.({ + explainId: resp.explain_id, + explainGraph: resp.explain_graph, + }); + return false; + } + + const chunk = resp.response || resp.chunk || ""; + const complete = !!resp.end_of_session || !!msg.complete; + + // Extract metadata from final message + const metadata: StreamingMetadata | undefined = complete && (resp.in_token || resp.out_token || resp.model) + ? { in_token: resp.in_token, out_token: resp.out_token, model: resp.model } + : undefined; + + receiver(chunk, complete, metadata); + + return complete; + }; + + this.api.makeRequestMulti( + "document-rag", + { + query: text, + user: this.api.user, + collection: collection || "default", + "doc-limit": docLimit, + streaming: true, + }, + recv, + 60000, + undefined, + this.flowId, + ); + } + + /** + * Performs text completion with streaming response + * @param system - System prompt + * @param text - User prompt + * @param receiver - Called for each chunk with (chunk, complete) where complete=true on final chunk + * @param onError - Called on error + */ + textCompletionStreaming( + system: string, + text: string, + receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + onError: (error: string) => void, + ): void { + const recv = (message: unknown): boolean => { + const msg = message as { response?: TextCompletionResponse; complete?: boolean; error?: string }; + + // Check for top-level error + if (msg.error) { + onError(msg.error); + return true; + } + + const resp = (msg.response || {}) as TextCompletionResponse; + + // Check for response-level error + if (resp.error) { + onError(resp.error.message); + return true; + } + + // Text completion uses 'response' field for chunks + const chunk = resp.response || ""; + const complete = !!msg.complete; + + // Extract metadata from final message + const metadata: StreamingMetadata | undefined = complete && (resp.in_token || resp.out_token || resp.model) + ? { in_token: resp.in_token, out_token: resp.out_token, model: resp.model } + : undefined; + + receiver(chunk, complete, metadata); + + return complete; + }; + + this.api.makeRequestMulti( + "text-completion", + { + system: system, + prompt: text, + streaming: true, + }, + recv, + 30000, + undefined, + this.flowId, + ); + } + + /** + * Executes a prompt template with streaming response + * @param id - Prompt template ID + * @param terms - Template variables + * @param receiver - Called for each chunk with (chunk, complete) where complete=true on final chunk + * @param onError - Called on error + */ + promptStreaming( + id: string, + terms: Record, + receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + onError: (error: string) => void, + ): void { + const recv = (message: unknown): boolean => { + const msg = message as { response?: PromptResponse; complete?: boolean; error?: string }; + + // Check for top-level error + if (msg.error) { + onError(msg.error); + return true; + } + + const resp = (msg.response || {}) as PromptResponse; + + // Check for response-level error + if (resp.error) { + onError(resp.error.message); + return true; + } + + // Prompt service uses 'text' field for chunks + const chunk = resp.text || ""; + const complete = !!msg.complete; + + // Extract metadata from final message + const metadata: StreamingMetadata | undefined = complete && (resp.in_token || resp.out_token || resp.model) + ? { in_token: resp.in_token, out_token: resp.out_token, model: resp.model } + : undefined; + + receiver(chunk, complete, metadata); + + return complete; + }; + + this.api.makeRequestMulti( + "prompt", + { + id: id, + terms: terms, + streaming: true, + }, + recv, + 30000, + undefined, + this.flowId, + ); + } + + /** + * Generates embeddings for multiple texts within this flow. + * Returns vectors[text_index][dimension_index] - one vector per input text. + */ + embeddings(texts: string[]) { + return this.api + .makeRequest( + "embeddings", + { + texts: texts, + }, + 30000, + undefined, + this.flowId, + ) + .then((r) => r.vectors); + } + + /** + * Queries the knowledge graph using a single embedding vector + */ + graphEmbeddingsQuery( + vec: number[], + limit: number | undefined, + collection?: string, + ) { + return this.api + .makeRequest( + "graph-embeddings", + { + vector: vec, + limit: limit ? limit : 20, // Default to 20 results + user: this.api.user, + collection: collection || "default", + }, + 30000, + undefined, + this.flowId, + ) + .then((r) => r.entities); + } + + /** + * Queries knowledge graph triples (subject-predicate-object relationships) + * All parameters are optional - omitted parameters act as wildcards + */ + triplesQuery( + s?: Term, + p?: Term, + o?: Term, + limit?: number, + collection?: string, + graph?: string, + ) { + return this.api + .makeRequest( + "triples", + { + s: s, // Subject + p: p, // Predicate + o: o, // Object + g: graph, // Named graph URI filter + limit: limit ? limit : 20, + user: this.api.user, + collection: collection || "default", + }, + 30000, + undefined, + this.flowId, + ) + .then((r) => r.response); + } + + /** + * Loads a document into this flow for processing + */ + loadDocument( + document: string, // base64-encoded document + id?: string, + metadata?: Triple[], + ) { + return this.api.makeRequest( + "document-load", + { + id: id, + metadata: metadata, + data: document, + }, + 30000, + undefined, + this.flowId, + ); + } + + /** + * Loads plain text into this flow for processing + */ + loadText( + text: string, // Text content + id?: string, + metadata?: Triple[], + charset?: string, // Character encoding + ) { + return this.api.makeRequest( + "text-load", + { + id: id, + metadata: metadata, + text: text, + charset: charset, + }, + 30000, + undefined, + this.flowId, + ); + } + + /** + * Executes a GraphQL query against structured row data + */ + rowsQuery( + query: string, + collection?: string, + variables?: Record, + operationName?: string, + ) { + return this.api + .makeRequest( + "rows", + { + query: query, + user: this.api.user, + collection: collection || "default", + variables: variables, + operation_name: operationName, + }, + 30000, + undefined, + this.flowId, + ) + .then((r) => { + // Return the GraphQL response structure directly + const result: Record = {}; + if (r.data !== undefined) result.data = r.data; + if (r.errors) result.errors = r.errors; + if (r.extensions) result.extensions = r.extensions; + return result; + }); + } + + /** + * Converts a natural language question to a GraphQL query + */ + nlpQuery(question: string, maxResults?: number) { + return this.api + .makeRequest( + "nlp-query", + { + question: question, + max_results: maxResults || 100, + }, + 30000, + undefined, + this.flowId, + ) + .then((r) => r); + } + + /** + * Executes a natural language question against structured data + * Combines NLP query conversion and GraphQL execution + */ + structuredQuery(question: string, collection?: string) { + return this.api + .makeRequest( + "structured-query", + { + question: question, + user: this.api.user, + collection: collection || "default", + }, + 30000, + undefined, + this.flowId, + ) + .then((r) => { + // Return the response structure directly + const result: Record = {}; + if (r.data !== undefined) result.data = r.data; + if (r.errors) result.errors = r.errors; + return result; + }); + } + + /** + * Performs semantic search on structured data indexes using embedding vectors + * @param vectors - Embedding vectors to search for + * @param schemaName - Name of the schema to search + * @param collection - Optional collection name + * @param indexName - Optional index name to filter results + * @param limit - Maximum number of results to return (default: 10) + */ + rowEmbeddingsQuery( + vector: number[], + schemaName: string, + collection?: string, + indexName?: string, + limit?: number, + ): Promise { + const request: RowEmbeddingsQueryRequest = { + vector: vector, + schema_name: schemaName, + user: this.api.user, + collection: collection || "default", + limit: limit || 10, + }; + + if (indexName) { + request.index_name = indexName; + } + + return this.api + .makeRequest( + "row-embeddings", + request, + 30000, + undefined, + this.flowId, + ) + .then((r) => { + if (r.error) { + throw new Error(r.error.message); + } + return r.matches || []; + }); + } +} + +/** + * ConfigApi - Dedicated configuration management interface + * Handles system configuration, prompts, and token cost tracking + */ +export class ConfigApi { + api: BaseApi; + + constructor(api: BaseApi) { + this.api = api; + } + + /** + * Retrieves complete configuration + */ + getConfigAll() { + return this.api.makeRequest( + "config", + { + operation: "config", + }, + 60000, + ); + } + + /** + * Retrieves specific configuration entries + */ + getConfig(keys: { type: string; key: string }[]) { + return this.api.makeRequest( + "config", + { + operation: "get", + keys: keys, + }, + 60000, + ); + } + + /** + * Updates configuration values + */ + putConfig(values: { type: string; key: string; value: string }[]) { + return this.api.makeRequest( + "config", + { + operation: "put", + values: values, + }, + 60000, + ); + } + + /** + * Deletes configuration entries + */ + deleteConfig(keys: { type: string; key: string }) { + return this.api.makeRequest( + "config", + { + operation: "delete", + keys: keys, + }, + 30000, + ); + } + + // Specialized prompt management methods + + /** + * Retrieves available prompt templates + */ + getPrompts() { + return this.getConfigAll().then((r) => { + const config = r as Record< + string, + Record> + >; + return JSON.parse(config.config.prompt["template-index"]); + }); + } + + /** + * Retrieves a specific prompt template + */ + getPrompt(id: string) { + return this.getConfigAll().then((r) => { + const config = r as Record< + string, + Record> + >; + return JSON.parse(config.config.prompt[`template.${id}`]); + }); + } + + /** + * Retrieves system prompt configuration + */ + getSystemPrompt() { + return this.getConfigAll().then((r) => { + const config = r as Record< + string, + Record> + >; + return JSON.parse(config.config.prompt.system); + }); + } + + /** + * Lists available configuration types + */ + list(type: string) { + return this.api + .makeRequest( + "config", + { + operation: "list", + type: type, + }, + 60000, + ) + .then((r) => r); + } + + /** + * Retrieves all key/values for a specific type + */ + getValues(type: string) { + return this.api + .makeRequest( + "config", + { + operation: "getvalues", + type: type, + }, + 60000, + ) + .then((r) => (r as RowsQueryResponse).values); + } + + /** + * Retrieves token cost information for different AI models + * Useful for cost tracking and optimization + */ + getTokenCosts() { + return this.api + .makeRequest( + "config", + { + operation: "getvalues", + type: "token-cost", + }, + 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; + return { key: item.key, value: JSON.parse(item.value) }; + }); + }) + .then((r) => + // Transform to more usable format + r.map((x: unknown) => { + const item = x as Record; + const value = item.value as Record; + return { + model: item.key, + input_price: value.input_price, // Cost per input token + output_price: value.output_price, // Cost per output token + }; + }), + ); + } +} + +/** + * KnowledgeApi - Manages knowledge graph cores and data + * Knowledge cores appear to be collections of processed knowledge graph data + */ +export class KnowledgeApi { + api: BaseApi; + + constructor(api: BaseApi) { + this.api = api; + } + + /** + * Retrieves list of available knowledge graph cores + */ + getKnowledgeCores() { + return this.api + .makeRequest( + "knowledge", + { + operation: "list-kg-cores", + user: this.api.user, + }, + 60000, + ) + .then((r) => r.ids || []); + } + + /** + * Deletes a knowledge graph core + */ + deleteKgCore(id: string, collection?: string) { + return this.api.makeRequest( + "knowledge", + { + operation: "delete-kg-core", + id: id, + user: this.api.user, + collection: collection || "default", + }, + 30000, + ); + } + + /** + * Deletes a knowledge graph core + */ + loadKgCore(id: string, flow: string, collection?: string) { + return this.api.makeRequest( + "knowledge", + { + operation: "load-kg-core", + id: id, + flow: flow, + user: this.api.user, + collection: collection || "default", + }, + 30000, + ); + } + + /** + * Retrieves a knowledge graph core with streaming data + * Uses multi-request pattern for large datasets + * @param receiver - Callback function to handle streaming data chunks + */ + getKgCore( + id: string, + collection: string | undefined, + receiver: (msg: unknown, eos: boolean) => void, + ) { + // Wrapper to handle end-of-stream detection + const recv = (msg: unknown) => { + const response = msg as Record; + if (response.eos) { + // End of stream - notify receiver and signal completion + receiver(msg, true); + return true; + } else { + // Regular message - continue streaming + receiver(msg, false); + return false; + } + }; + + return this.api.makeRequestMulti( + "knowledge", + { + operation: "get-kg-core", + id: id, + user: this.api.user, + collection: collection || "default", + }, + recv, // Stream handler + 30000, + ); + } +} + +/** + * CollectionManagementApi - Manages collections for organizing documents + * Provides operations for listing, creating, updating, and deleting collections + */ +export class CollectionManagementApi { + api: BaseApi; + + constructor(api: BaseApi) { + this.api = api; + } + + /** + * Lists all collections for the current user with optional tag filtering + * @param tagFilter - Optional array of tags to filter collections + * @returns Promise resolving to array of collection metadata + */ + listCollections(tagFilter?: string[]) { + const request: Record = { + operation: "list-collections", + user: this.api.user, + }; + + if (tagFilter && tagFilter.length > 0) { + request.tag_filter = tagFilter; + } + + return this.api + .makeRequest< + Record, + Record + >("collection-management", request, 30000) + .then((r) => r.collections || []); + } + + /** + * Creates or updates a collection for the current user + * @param collection - Collection ID (unique identifier) + * @param name - Display name for the collection + * @param description - Description of the collection + * @param tags - Array of tags for categorization + * @returns Promise resolving to updated collection metadata + */ + updateCollection( + collection: string, + name?: string, + description?: string, + tags?: string[], + ) { + const request: Record = { + operation: "update-collection", + user: this.api.user, + collection, + }; + + if (name !== undefined) { + request.name = name; + } + if (description !== undefined) { + request.description = description; + } + if (tags !== undefined) { + request.tags = tags; + } + + return this.api + .makeRequest< + Record, + Record + >("collection-management", request, 30000) + .then((r) => { + if ( + r.collections && + Array.isArray(r.collections) && + r.collections.length > 0 + ) { + return r.collections[0]; + } + throw new Error("Failed to update collection"); + }); + } + + /** + * Deletes a collection and all its data for the current user + * @param collection - Collection ID to delete + * @returns Promise resolving when deletion is complete + */ + deleteCollection(collection: string) { + return this.api.makeRequest< + Record, + Record + >( + "collection-management", + { + operation: "delete-collection", + user: this.api.user, + collection, + }, + 30000, + ); + } +} + +/** + * Factory function to create a new TrustGraph WebSocket connection + * 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) + */ +export const createTrustGraphSocket = ( + user: string, + token?: string, + socketUrl?: string, +): BaseApi => { + return new BaseApi(user, token, socketUrl); +}; diff --git a/ai-context/trustgraph-client/src/types.ts b/ai-context/trustgraph-client/src/types.ts new file mode 100644 index 00000000..19bcb6bf --- /dev/null +++ b/ai-context/trustgraph-client/src/types.ts @@ -0,0 +1,3 @@ +// Type definitions for TrustGraph client + +export {}; diff --git a/ai-context/trustgraph-client/test-graphrag.js b/ai-context/trustgraph-client/test-graphrag.js new file mode 100755 index 00000000..308fef40 --- /dev/null +++ b/ai-context/trustgraph-client/test-graphrag.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +/** + * Standalone test for GraphRAG streaming + * Tests the question "What is a cat?" using GraphRAG streaming mode + */ + +import { createTrustGraphSocket } from './dist/index.esm.js'; + +// Configuration +const USER = 'trustgraph'; +const SOCKET_URL = 'ws://localhost:8088/api/v1/socket'; +const QUESTION = 'What is a cat?'; + +console.log('GraphRAG Streaming Test'); +console.log('======================'); +console.log(`User: ${USER}`); +console.log(`Socket URL: ${SOCKET_URL}`); +console.log(`Question: "${QUESTION}"\n`); + +// Create socket connection +const socket = createTrustGraphSocket(USER, undefined, SOCKET_URL); + +// Wait for connection to establish +setTimeout(() => { + console.log('Starting GraphRAG query...\n'); + + let accumulated = ''; + let chunkCount = 0; + + // GraphRAG options + const options = { + entityLimit: 50, + tripleLimit: 30, + maxSubgraphSize: 1000, + pathLength: 2, + }; + + // Streaming receiver callback + const onChunk = (chunk, complete, metadata) => { + chunkCount++; + accumulated += chunk; + + if (chunk) { + process.stdout.write(chunk); + } + + if (complete) { + console.log('\n\n--- Streaming Complete ---'); + console.log(`Total chunks received: ${chunkCount}`); + console.log(`Total characters: ${accumulated.length}`); + + if (metadata) { + console.log('\nMetadata:'); + if (metadata.model) console.log(` Model: ${metadata.model}`); + if (metadata.in_token) console.log(` Input tokens: ${metadata.in_token}`); + if (metadata.out_token) console.log(` Output tokens: ${metadata.out_token}`); + } + + console.log('\n--- Full Response ---'); + console.log(accumulated); + + // Close socket and exit + socket.close(); + process.exit(0); + } + }; + + // Error callback + const onError = (error) => { + console.error('\n\nERROR:', error); + socket.close(); + process.exit(1); + }; + + // Execute GraphRAG streaming query + socket + .flow('default') + .graphRagStreaming( + QUESTION, + onChunk, + onError, + options, + 'default' // collection + ); + +}, 1000); // Wait 1 second for connection + +// Handle process termination +process.on('SIGINT', () => { + console.log('\n\nInterrupted. Closing socket...'); + socket.close(); + process.exit(0); +}); diff --git a/ai-context/trustgraph-client/test-streaming.js b/ai-context/trustgraph-client/test-streaming.js new file mode 100755 index 00000000..5ae6800a --- /dev/null +++ b/ai-context/trustgraph-client/test-streaming.js @@ -0,0 +1,111 @@ +#!/usr/bin/env node + +/** + * Test script for TrustGraph streaming APIs + * Tests both streaming and non-streaming text completion + * + * Usage: + * node test-streaming.js + * + * Requirements: + * - TrustGraph backend running on http://localhost:8088 + * - Built client library in ./dist/ + */ + +import { createTrustGraphSocket } from './dist/index.esm.js'; + +const USER = "test-user"; +const SYSTEM_PROMPT = "You are a helpful AI assistant."; +const TEST_PROMPT = "Explain what streaming is in one paragraph."; +const SOCKET_URL = "ws://localhost:8888/api/socket"; + +console.log("=".repeat(80)); +console.log("TrustGraph Streaming API Test"); +console.log("=".repeat(80)); +console.log(`Connecting to: ${SOCKET_URL}`); +console.log(`User: ${USER}`); +console.log("=".repeat(80)); + +// Create client connection with explicit WebSocket URL for Node.js +const client = createTrustGraphSocket(USER, undefined, SOCKET_URL); + +// Wait a bit for connection to establish +await new Promise(resolve => setTimeout(resolve, 1000)); + +console.log("\n[1/2] Testing NON-STREAMING text completion..."); +console.log("-".repeat(80)); + +try { + const flowApi = client.flow("default"); + const response = await flowApi.textCompletion(SYSTEM_PROMPT, TEST_PROMPT); + + console.log("āœ“ Non-streaming response received:"); + console.log(response); +} catch (error) { + console.error("āœ— Non-streaming failed:", error.message); +} + +console.log("\n[2/2] Testing STREAMING text completion..."); +console.log("-".repeat(80)); + +try { + const flowApi = client.flow("default"); + + let accumulated = ""; + let chunkCount = 0; + const startTime = Date.now(); + + await new Promise((resolve, reject) => { + flowApi.textCompletionStreaming( + SYSTEM_PROMPT, + TEST_PROMPT, + (chunk, complete, metadata) => { + chunkCount++; + accumulated += chunk; + + // Show progress indicator + if (chunk) { + process.stdout.write(chunk); + } + + if (complete) { + const duration = Date.now() - startTime; + console.log("\n"); + console.log("-".repeat(80)); + console.log(`āœ“ Streaming complete!`); + console.log(` Chunks received: ${chunkCount}`); + console.log(` Total length: ${accumulated.length} chars`); + console.log(` Duration: ${duration}ms`); + console.log(` First chunk: ~${(startTime - Date.now() + duration) / chunkCount}ms`); + + // Display token usage and model info if available + if (metadata) { + console.log("\n Metadata:"); + if (metadata.model) console.log(` Model: ${metadata.model}`); + if (metadata.in_token !== undefined) console.log(` Input tokens: ${metadata.in_token}`); + if (metadata.out_token !== undefined) console.log(` Output tokens: ${metadata.out_token}`); + if (metadata.in_token && metadata.out_token) { + console.log(` Total tokens: ${metadata.in_token + metadata.out_token}`); + } + } + + resolve(); + } + }, + (error) => { + console.error("\nāœ— Streaming error:", error); + reject(new Error(error)); + } + ); + }); +} catch (error) { + console.error("āœ— Streaming failed:", error.message); +} + +console.log("\n" + "=".repeat(80)); +console.log("Test complete!"); +console.log("=".repeat(80)); + +// Close connection +client.close(); +process.exit(0); diff --git a/ai-context/trustgraph-client/tsconfig.json b/ai-context/trustgraph-client/tsconfig.json new file mode 100644 index 00000000..f69c5d78 --- /dev/null +++ b/ai-context/trustgraph-client/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM"], + "jsx": "react", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/ai-context/trustgraph-client/vitest.config.ts b/ai-context/trustgraph-client/vitest.config.ts new file mode 100644 index 00000000..1d10f07b --- /dev/null +++ b/ai-context/trustgraph-client/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "happy-dom", + }, +});