mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
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:
commit
deff028fed
27 changed files with 6278 additions and 0 deletions
34
.github/workflows/ci.yml
vendored
Normal file
34
.github/workflows/ci.yml
vendored
Normal 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
51
.github/workflows/publish.yml
vendored
Normal 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
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
package-lock.json
|
||||
*~
|
||||
3
.prettierrc
Normal file
3
.prettierrc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"printWidth": 79
|
||||
}
|
||||
176
LICENSE
Normal file
176
LICENSE
Normal 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
319
README.md
Normal 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
|
||||
|
||||
44
docs/tech-specs/client-module.md
Normal file
44
docs/tech-specs/client-module.md
Normal 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
|
||||
808
docs/tech-specs/streaming-support.md
Normal file
808
docs/tech-specs/streaming-support.md
Normal 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
35
eslint.config.js
Normal 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
66
package.json
Normal 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
30
rollup.config.js
Normal 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",
|
||||
}),
|
||||
],
|
||||
};
|
||||
221
src/__tests__/flows-api.test.ts
Normal file
221
src/__tests__/flows-api.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
370
src/__tests__/messages.test.ts
Normal file
370
src/__tests__/messages.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
285
src/__tests__/service-call-multi.test.ts
Normal file
285
src/__tests__/service-call-multi.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
239
src/__tests__/service-call.test.ts
Normal file
239
src/__tests__/service-call.test.ts
Normal 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
10
src/index.ts
Normal 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
40
src/models/Triple.ts
Normal 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
496
src/models/messages.ts
Normal 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
42
src/models/namespaces.ts
Normal 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";
|
||||
171
src/socket/service-call-multi.ts
Normal file
171
src/socket/service-call-multi.ts
Normal 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
239
src/socket/service-call.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
2353
src/socket/trustgraph-socket.ts
Normal file
2353
src/socket/trustgraph-socket.ts
Normal file
File diff suppressed because it is too large
Load diff
3
src/types.ts
Normal file
3
src/types.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Type definitions for TrustGraph client
|
||||
|
||||
export {};
|
||||
94
test-graphrag.js
Executable file
94
test-graphrag.js
Executable 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
111
test-streaming.js
Executable 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
23
tsconfig.json
Normal 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
8
vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "happy-dom",
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue