Squashed 'ai-context/trustgraph-client/' content from commit 908f18cf

git-subtree-dir: ai-context/trustgraph-client
git-subtree-split: 908f18cf814470ec3b72cc336bb945fb792ffdec
This commit is contained in:
elpresidank 2026-04-05 21:07:35 -05:00
commit deff028fed
27 changed files with 6278 additions and 0 deletions

34
.github/workflows/ci.yml vendored Normal file
View file

@ -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

51
.github/workflows/publish.yml vendored Normal file
View file

@ -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

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
dist/
*.log
.DS_Store
*.tsbuildinfo
package-lock.json
*~

3
.prettierrc Normal file
View file

@ -0,0 +1,3 @@
{
"printWidth": 79
}

176
LICENSE Normal file
View file

@ -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

319
README.md Normal file
View file

@ -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

View file

@ -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

View file

@ -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<string>`
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<string, unknown>;
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<AgentRequest, AgentStreamingResponse>(
"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<GraphRagRequest, StreamingChunk>(
"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<DocumentRagRequest, StreamingChunk>(
"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<TextCompletionRequest, StreamingChunk>(
"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<string, unknown>,
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<PromptRequest, StreamingChunk>(
"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<string, void, void> {
// 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<string> | 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.

35
eslint.config.js Normal file
View file

@ -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"],
},
);

66
package.json Normal file
View file

@ -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"
}

30
rollup.config.js Normal file
View file

@ -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",
}),
],
};

View file

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

View file

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

View file

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

View file

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

10
src/index.ts Normal file
View file

@ -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";

40
src/models/Triple.ts Normal file
View file

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

496
src/models/messages.ts Normal file
View file

@ -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<string, unknown>;
operation_name?: string;
}
export interface RowsQueryResponse {
data?: Record<string, unknown>;
errors?: Record<string, unknown>[];
extensions?: Record<string, unknown>;
values?: unknown[];
}
export interface NlpQueryRequest {
question: string;
max_results?: number;
}
export interface NlpQueryResponse {
graphql_query?: string;
variables?: Record<string, unknown>;
detected_schemas?: Record<string, unknown>[];
confidence?: number;
}
export interface StructuredQueryRequest {
question: string;
user?: string;
collection?: string;
}
export interface StructuredQueryResponse {
data?: Record<string, unknown>;
errors?: Record<string, unknown>[];
}
export interface RowEmbeddingsQueryRequest {
vector: number[]; // Single query vector
schema_name: string;
user?: string;
collection?: string;
index_name?: string;
limit?: number;
}
export interface RowEmbeddingsMatch {
index_name: string;
index_value: string[];
text: string;
score: number;
}
export interface RowEmbeddingsQueryResponse {
matches?: RowEmbeddingsMatch[];
error?: {
message: string;
type?: string;
};
}
export interface LoadDocumentRequest {
id?: string;
data: string;
metadata?: Triple[];
}
export type LoadDocumentResponse = void;
export interface LoadTextRequest {
id?: string;
text: string;
charset?: string;
metadata?: Triple[];
}
export type LoadTextResponse = void;
export interface DocumentMetadata {
id?: string;
time?: number;
kind?: string;
title?: string;
comments?: string;
metadata?: Triple[];
user?: string;
tags?: string[];
"document-type"?: string;
}
export interface ProcessingMetadata {
id?: string;
"document-id"?: string;
time?: number;
flow?: string;
user?: string;
collection?: string;
tags?: string[];
}
export interface LibraryRequest {
operation: string;
"document-id"?: string;
"processing-id"?: string;
"document-metadata"?: DocumentMetadata;
"processing-metadata"?: ProcessingMetadata;
content?: string;
user?: string;
collection?: string;
metadata?: Triple[];
id?: string;
flow?: string;
}
export interface LibraryResponse {
error: Error;
"document-metadata"?: DocumentMetadata;
content?: string;
"document-metadatas"?: DocumentMetadata[];
"processing-metadata"?: ProcessingMetadata;
}
export interface KnowledgeRequest {
operation: string;
user?: string;
id?: string;
flow?: string;
collection?: string;
triples?: Triple[];
"graph-embeddings"?: GraphEmbeddings;
}
export interface KnowledgeResponse {
error?: Error;
ids?: string[];
eos?: boolean;
triples?: Triple[];
"graph-embeddings"?: GraphEmbeddings;
}
export interface FlowRequest {
operation: string;
"blueprint-name"?: string;
"blueprint-definition"?: string;
description?: string;
"flow-id"?: string;
parameters?: Record<string, unknown>;
user?: string;
}
export interface FlowResponse {
"blueprint-names"?: string[];
"flow-ids"?: string[];
ids?: string[];
flow?: string;
"blueprint-definition"?: string;
description?: string;
error?:
| {
message?: string;
}
| Error;
}
export interface PromptRequest {
id: string;
terms: Record<string, unknown>;
streaming?: boolean;
}
export interface PromptResponse {
text: string;
// Streaming fields
end_of_stream?: boolean;
error?: {
message: string;
type?: string;
};
// Token usage (appears in final message)
in_token?: number;
out_token?: number;
model?: string;
}
export type ConfigRequest = object;
export type ConfigResponse = object;
// Chunked Upload Types
export interface ChunkedUploadDocumentMetadata {
id: string;
time: number;
kind: string;
title: string;
comments?: string;
metadata?: Triple[];
user: string;
collection?: string;
tags?: string[];
}
export interface BeginUploadRequest {
operation: "begin-upload";
"document-metadata": ChunkedUploadDocumentMetadata;
"total-size": number;
"chunk-size"?: number;
}
export interface BeginUploadResponse {
"upload-id": string;
"chunk-size": number;
"total-chunks": number;
error?: ResponseError;
}
export interface UploadChunkRequest {
operation: "upload-chunk";
"upload-id": string;
"chunk-index": number;
content: string; // base64-encoded
user: string;
}
export interface UploadChunkResponse {
"upload-id": string;
"chunk-index": number;
"chunks-received": number;
"total-chunks": number;
"bytes-received": number;
"total-bytes": number;
error?: ResponseError;
}
export interface CompleteUploadRequest {
operation: "complete-upload";
"upload-id": string;
user: string;
}
export interface CompleteUploadResponse {
"document-id": string;
"object-id": string;
error?: ResponseError;
}
export interface GetUploadStatusRequest {
operation: "get-upload-status";
"upload-id": string;
user: string;
}
export interface GetUploadStatusResponse {
"upload-id": string;
"upload-state": "in-progress" | "completed" | "expired";
"chunks-received": number;
"total-chunks": number;
"received-chunks": number[];
"missing-chunks": number[];
"bytes-received": number;
"total-bytes": number;
error?: ResponseError;
}
export interface AbortUploadRequest {
operation: "abort-upload";
"upload-id": string;
user: string;
}
export interface AbortUploadResponse {
error?: ResponseError;
}
export interface ListUploadsRequest {
operation: "list-uploads";
user: string;
}
export interface UploadSession {
"upload-id": string;
"document-id": string;
"document-metadata-json": string;
"total-size": number;
"chunk-size": number;
"total-chunks": number;
"chunks-received": number;
"created-at": string;
}
export interface ListUploadsResponse {
"upload-sessions": UploadSession[];
error?: ResponseError;
}
export interface StreamDocumentRequest {
operation: "stream-document";
"document-id": string;
"chunk-size"?: number;
user: string;
}
export interface StreamDocumentResponse {
content: string; // base64-encoded chunk
"chunk-index": number;
"total-chunks": number;
error?: ResponseError;
}

42
src/models/namespaces.ts Normal file
View file

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

View file

@ -0,0 +1,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<typeof setTimeout>;
timeout: number;
retries: number;
socket: Socket;
complete: boolean;
start() {
this.socket.inflight[this.mid] = this;
this.attempt();
}
onReceived(resp: object) {
if (this.complete == true)
console.log(this.mid, "should not happen, request is already complete");
const fin = this.receiver(resp);
if (fin) {
this.complete = true;
// console.log("Received for", this.mid);
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
delete this.socket.inflight[this.mid];
this.success(resp);
}
}
/**
* Called when socket connects - immediately retry if we were waiting
*/
retryNow() {
if (this.complete) return;
// Clear any pending backoff timer
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
// Restore retry count since we didn't actually fail
this.retries++;
// Attempt immediately
this.attempt();
}
onTimeout() {
if (this.complete == true)
console.log(
this.mid,
"timeout should not happen, request is already complete",
);
console.log("Request", this.mid, "timed out");
clearTimeout(this.timeoutId);
this.attempt();
}
attempt() {
// console.log("attempt:", this.mid);
if (this.complete == true)
console.log(
this.mid,
"attempt should not be called, request is already complete",
);
this.retries--;
if (this.retries < 0) {
console.log("Request", this.mid, "ran out of retries");
clearTimeout(this.timeoutId);
delete this.socket.inflight[this.mid];
this.error("Ran out of retries");
return; // Exit early - no more attempts
}
// Check if WebSocket connection is available and ready
if (this.socket.ws && this.socket.ws.readyState === 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);
}
}
}
}

239
src/socket/service-call.ts Normal file
View file

@ -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<typeof setTimeout>; // Reference to the active timeout timer
timeout: number; // Timeout duration in milliseconds
retries: number; // Remaining retry attempts
socket: Socket; // WebSocket connection reference
complete: boolean; // Flag indicating if request is complete
/**
* Initiates the service call by registering it with the socket's inflight
* requests and making the first attempt to send the message
*/
start() {
// Register this request as "in-flight" so responses can be matched to it
this.socket.inflight[this.mid] = this;
// Make the first attempt to send the message
this.attempt();
}
/**
* Called when a response is received for this request
* Handles cleanup and calls the success or error callback based on response
*
* @param resp - The response object received from the server
*/
onReceived(resp: object) {
// Defensive check - this shouldn't happen but log if it does
if (this.complete == true)
console.log(this.mid, "should not happen, request is already complete");
// Mark as complete to prevent duplicate processing
this.complete = true;
// Clean up timeout timer
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
// Remove from inflight requests tracker
delete this.socket.inflight[this.mid];
// Check if the response contains an error (error can be directly in resp or nested under response)
let errorToHandle: unknown = null;
// Check for direct error in response
if (resp && typeof resp === "object" && "error" in resp) {
errorToHandle = (resp as Record<string, unknown>).error;
}
// Check for nested error under response property
else if (resp && typeof resp === "object" && "response" in resp) {
const response = (resp as Record<string, unknown>).response;
if (response && typeof response === "object" && "error" in response) {
errorToHandle = (response as Record<string, unknown>).error;
}
}
if (errorToHandle) {
// Response contains an error - call error callback
const errorObj = errorToHandle as Record<string, unknown>;
const errorMessage =
(typeof errorObj.message === "string" ? errorObj.message : null) ||
(typeof errorObj.type === "string" ? errorObj.type : null) ||
"Unknown error";
console.log(
"ServiceCall: API error detected in response:",
errorMessage,
"Full error:",
errorToHandle,
);
this.error(new Error(errorMessage));
return;
}
// Extract the response field from the message object
// The resp parameter is the full message: {id, response, complete}
// We need to pass just the response field to the success callback
const responseData = (resp as { response?: unknown }).response;
this.success(responseData);
}
/**
* Called when socket connects - immediately retry if we were waiting
*/
retryNow() {
if (this.complete) return;
// Clear any pending backoff timer
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
// Restore retry count since we didn't actually fail
this.retries++;
// Attempt immediately
this.attempt();
}
/**
* Called when the request times out
* Triggers another attempt if retries are available
*/
onTimeout() {
// Defensive check - this shouldn't happen but log if it does
if (this.complete == true)
console.log(
this.mid,
"timeout should not happen, request is already complete",
);
console.log("Request", this.mid, "timed out");
// Clear the current timeout
clearTimeout(this.timeoutId);
// Try again (this will check retry count)
this.attempt();
}
/**
* Calculates exponential backoff delay with jitter
* @returns backoff delay in milliseconds
*/
calculateBackoff() {
return Math.min(
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) +
Math.random() * 1000,
30000, // Max 30 seconds
);
}
/**
* Core retry logic - attempts to send the message over the WebSocket
* Handles retries and waits for BaseApi to handle reconnection
*/
attempt() {
// Defensive check - this shouldn't be called on completed requests
if (this.complete == true)
console.log(
this.mid,
"attempt should not be called, request is already complete",
);
// Decrement retry counter
this.retries--;
// Check if we've exhausted all retries
if (this.retries < 0) {
console.log("Request", this.mid, "ran out of retries");
// Clean up and call error callback
clearTimeout(this.timeoutId);
delete this.socket.inflight[this.mid];
this.error("Ran out of retries");
return; // Exit early - no more attempts
}
// Check if WebSocket connection is available and ready
if (this.socket.ws && this.socket.ws.readyState === 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());
}
}
}

File diff suppressed because it is too large Load diff

3
src/types.ts Normal file
View file

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

94
test-graphrag.js Executable file
View file

@ -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);
});

111
test-streaming.js Executable file
View file

@ -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);

23
tsconfig.json Normal file
View file

@ -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"]
}

8
vitest.config.ts Normal file
View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "happy-dom",
},
});