remove emoji
modified: CLAUDE.md modified: languages/awk/uncloseai.awk modified: languages/bash/index.html modified: languages/bash/uncloseai.sh modified: languages/clojure/uncloseai.clj modified: languages/cpp/libcurl/uncloseai.cpp modified: languages/go/examples/basic.go modified: languages/go/uncloseai.go modified: languages/java/UncloseAI.java modified: languages/javascript/vanilla/uncloseai.html modified: languages/kotlin/src/main/kotlin/UncloseAI.kt modified: languages/kotlin/uncloseai.kt modified: languages/lua/uncloseai.lua modified: languages/nim/uncloseai.nim modified: languages/ocaml/uncloseai.ml modified: languages/odin/uncloseai.odin modified: languages/perl/uncloseai.pl modified: languages/php/uncloseai.php modified: languages/powershell/uncloseai.ps1 modified: languages/prolog/uncloseai.pl modified: languages/python/aiohttp/uncloseai.py modified: languages/python/httpx-async/uncloseai.py modified: languages/python/openai-client/uncloseai.py modified: languages/python/requests/uncloseai.py modified: languages/r/uncloseai.R modified: languages/ruby/uncloseai.rb modified: languages/rust/examples/basic.rs modified: languages/rust/src/uncloseai.rs modified: languages/scala/src/main/scala/UncloseAI.scala modified: languages/tcl/uncloseai.tcl
This commit is contained in:
parent
47ccc09992
commit
17717cf3e3
30 changed files with 313 additions and 118 deletions
311
CLAUDE.md
311
CLAUDE.md
|
|
@ -1,64 +1,120 @@
|
|||
# Claude Memory
|
||||
|
||||
## TODO - Language Examples (Worker coordination)
|
||||
- All implementations must act as reusable libraries/clients, not just demo scripts
|
||||
- Add streaming support for chat completions (SSE - Server-Sent Events)
|
||||
- Provide clean API surface for developers to integrate into their applications
|
||||
- Target compatibility with vLLM, Ollama, and OpenAI-compatible endpoints
|
||||
- Maintain backward compatibility with non-streaming usage
|
||||
|
||||
**Completed Languages: 42 implementations**
|
||||
✅ **ALL 47 IMPLEMENTATIONS COMPLETE** - All have streaming support with SSE parsing
|
||||
|
||||
**Completed Languages (47/47 = 100%):**
|
||||
- **AWK** - Functional library, curl --no-buffer, SSE line parsing
|
||||
- **Bash** - Library functions, curl --no-buffer, regex SSE matching
|
||||
- **C (3 variants):** curl (libcurl), libsoup (GNOME), nghttp2 (HTTP/2)
|
||||
- **C++ (3 variants):** libcurl (std::function), cpp-httplib (lambda), boost-beast (class)
|
||||
- **C#** - UncloseAI class, HttpClient, ResponseHeadersRead streaming
|
||||
- **Clojure** - defrecord, lazy sequences, line-seq SSE parsing
|
||||
- **COBOL** - Procedural PERFORM, shell curl streaming
|
||||
- **Crystal** - Class-based, body_io.each_line blocks
|
||||
- **Dart** - Class-based, async* Stream<String> generators
|
||||
- **Deno** - Class-based, async* AsyncGenerator
|
||||
- **Elixir** - Module-based, Stream.resource lazy streaming
|
||||
- **Erlang** - Record-based, actor model process messaging
|
||||
- **F#** - UncloseAIClient, seq {} StreamReader
|
||||
- **Fortran** - Module-based, shell curl+jq+bash
|
||||
- **Go** - Struct-based, channel streaming, context support
|
||||
- **Haskell** - Data type, Conduit monadic composition
|
||||
- **Java** - UncloseAI class, BufferedReader SSE parsing
|
||||
- **JavaScript (4 variants):** nodejs (https), typescript (https+types), bun (Fetch API), vanilla (browser Fetch)
|
||||
- **Julia** - Mutable struct, Channel async iteration
|
||||
- **Kotlin** - UncloseAI class, callback streaming
|
||||
- **Lua** - Metatable SDK, LuaSocket SSL, manual HTTP/SSE
|
||||
- **Nim** - Ref object, bodyStream.lines callbacks
|
||||
- **OCaml** - Record-based, Lwt promises, Lwt_stream
|
||||
- **Odin** - Struct-based, shell curl
|
||||
- **Perl** - LWP::UserAgent, streaming callback
|
||||
- **PHP** - UncloseAI class, CURLOPT_WRITEFUNCTION
|
||||
- **PowerShell** - HttpClient, StreamReader
|
||||
- **Prolog** - SWI-Prolog http_client, simplified streaming
|
||||
- **Python (4 variants):** requests (UncloseAI class), openai-client (OpenAI SDK), httpx-async (async class), aiohttp (async class)
|
||||
- **R** - R6 class, httr write_stream
|
||||
- **Ruby** - UncloseAI class, Net::HTTP read_body blocks
|
||||
- **Rust** - Struct-based, Tokio async, reqwest StreamExt
|
||||
- **Scala** - STTP client, callback streaming
|
||||
- **Tcl** - TclOO class, curl pipe streaming
|
||||
- **V** - Native http module, callback streaming
|
||||
- **VB.NET** - Action(Of String) callbacks
|
||||
- **Zig** - ChatStream, iterator pattern
|
||||
|
||||
**Remaining: 0 implementations**
|
||||
|
||||
**Implementation Pattern Established:**
|
||||
- Client/class-based architecture (struct for compiled languages, class for dynamic)
|
||||
- Model discovery from environment variables (MODEL_ENDPOINT_1..9999)
|
||||
- Non-streaming method: `chat()` / `Chat()`
|
||||
- Streaming method: `chat_stream()` / `ChatStream()` / `chatStream()`
|
||||
- TTS generation: `tts()` / `TTS()` / `generateSpeech()`
|
||||
- Error handling with typed errors where applicable
|
||||
- SSE parsing: `data: {...}\n\n` format, `data: [DONE]` termination
|
||||
|
||||
**CRITICAL: File Naming for Phase 2 SDKs**
|
||||
- ❌ NEVER create separate library files like `uncloseai_lib.py`, `uncloseai_lib.js`, etc.
|
||||
- ✅ ALWAYS transform the existing `uncloseai.{ext}` file in place
|
||||
- ✅ Keep single file: `uncloseai.py`, `uncloseai.js`, `uncloseai.ts`, `uncloseai.rs`, etc.
|
||||
- The file should contain both the SDK class/struct AND example usage in main()
|
||||
- Example: Python's `uncloseai.py` contains `class UncloseAI:` + `if __name__ == "__main__":` demo
|
||||
- Example: Go's `uncloseai.go` contains `type UncloseAI struct` + `func main()` demo
|
||||
|
||||
**Completed (47 implementations across 40 languages):**
|
||||
- **Python (4 variants):** requests, openai-client, httpx-async, aiohttp
|
||||
- **JavaScript (4 variants):** nodejs, typescript, bun, vanilla
|
||||
- **C (3 variants):** curl, libsoup (GNOME), nghttp2
|
||||
- **C (3 variants):** curl, libsoup (GNOME), nghttp2 (HTTP/2)
|
||||
- **C++ (3 variants):** libcurl, cpp-httplib, boost-beast
|
||||
- **Single implementations (28 languages):** AWK, Bash, Clojure, Crystal, C#, Dart, Deno, Elixir, Erlang, Fortran, F#, Go, Haskell, Java, Julia, Kotlin, Lua, Nim, OCaml, Odin, Perl, PHP, PowerShell, Prolog, R, Ruby, Rust, Scala, Tcl, V, VB.NET, Zig
|
||||
- **Single implementations (33 languages):** AWK, Bash, Clojure, COBOL, Crystal, C#, Dart, Deno, Elixir, Erlang, Fortran, F#, Go, Haskell, Java, Julia, Kotlin, Lua, Nim, OCaml, Odin, Perl, PHP, PowerShell, Prolog, R, Ruby, Rust, Scala, Tcl, V, VB.NET, Zig
|
||||
|
||||
**Refactoring Status (2025-10-13 - COMPLETE!):**
|
||||
- ✅ **ALL 47 IMPLEMENTATIONS REFACTORED!** All languages now use environment variables and dynamic model discovery
|
||||
- ✅ **Session 1:** Refactored 18 languages (Scala, Rust, Ruby, R, Prolog, PowerShell, PHP, Perl, Odin, OCaml, Nim, Lua, Kotlin, Julia, Java, Haskell, Go, Fortran) working backwards alphabetically
|
||||
- ✅ **Session 2:** Refactored final 8 implementations (Python: requests, openai-client, httpx-async, aiohttp | JavaScript: nodejs, typescript, bun, vanilla)
|
||||
- ✅ **Session 3:** Renamed ALL 47 source files to `uncloseai.{ext}` (or `UncloseAI.*` for capitalized languages)
|
||||
- ✅ **Pattern Applied:** All use `System.getenv()`/`os.getenv()`/`ENV`/`process.env` for `MODEL_ENDPOINT_1..9999` and `TTS_ENDPOINT_1..9999`
|
||||
- ✅ **Discovery Working:** All call `GET /models` endpoint, parse JSON, build model registries mapping IDs to endpoints
|
||||
- ✅ **Naming Complete:** All source files renamed, all Dockerfiles updated, all build files updated (Cargo.toml, build.sbt, *.vbproj, etc.)
|
||||
- ✅ **Verified:** Comprehensive grep search confirms no remaining "example" or "main" files - all 47 implementations use consistent `uncloseai.*` naming
|
||||
|
||||
**Session 2025-10-13 Final 8 Implementations:**
|
||||
- **Python variants (4):** requests, openai-client, httpx-async, aiohttp
|
||||
- All use `os.getenv(f"MODEL_ENDPOINT_{i}")` loop pattern
|
||||
- requests: Direct HTTP with requests.get/post
|
||||
- openai-client: Uses OpenAI SDK with dynamic base_url
|
||||
- httpx-async: Async with httpx.AsyncClient
|
||||
- aiohttp: Async with aiohttp.ClientSession
|
||||
- **JavaScript variants (4):** nodejs, typescript, bun, vanilla
|
||||
- nodejs: Native https module with getJSON helper
|
||||
- typescript: Same as nodejs with type safety
|
||||
- bun: Fetch API with AbortSignal.timeout
|
||||
- vanilla: Browser-based with CONFIG.MODEL_ENDPOINTS (can't use env vars)
|
||||
|
||||
**Skipped (cannot implement - 5 languages):**
|
||||
- Assembly (too low-level, no standard HTTP library)
|
||||
- Brainfuck (esoteric language, no practical HTTP client)
|
||||
- COBOL (legacy language, limited modern HTTP support)
|
||||
- Matlab (proprietary license prevents Docker usage)
|
||||
- SQL (declarative query language, no HTTP client)
|
||||
- Swift (requires macOS/Xcode for proper development)
|
||||
- Brainfuck (esoteric language, no practical HTTP client)
|
||||
- Assembly (too low-level, no standard HTTP library)
|
||||
|
||||
## Project Identity & Philosophy
|
||||
**Empty directories (skipped, listed above):**
|
||||
- assembly/, brainfuck/, matlab/, sql/, swift/ - all empty, marked as skipped
|
||||
|
||||
**Project Name: uncloseai.com (or "uncloseai." for short)**
|
||||
- This codebase is called **uncloseai.com** - a machine learning inference client reference implementation
|
||||
- Hosted at **uncloseai.com**
|
||||
- When working on this project, refer to yourself Claude as "Hermes Staff"
|
||||
- This project uses the Hermes model & you are part of the team
|
||||
- "Hermes Staff" is also a reference to the Staff of Hermes (caduceus) - the symbol of communication, commerce, & divine messages
|
||||
## Project Identity
|
||||
- When working on this ai.unturf.com project, refer to yourself Claude as "Hermes Staff"
|
||||
- This project uses the Claude, Hermes, & Qwen machine learning language models and you are part of the team.
|
||||
- "Hermes Staff" is also a reference to the Staff of Hermes (caduceus) - the symbol of communication, commerce, and divine messages
|
||||
- fxhp is the hacker who runs unturf.com with his posse of carnival hackers
|
||||
|
||||
**Cybernetics, Not "Artificial Intelligence":**
|
||||
- Despite "AI" in our domain name, we reject the term "Artificial Intelligence"
|
||||
- This is **cybernetics** - human-machine collaboration & augmentation
|
||||
- This is **telepathy** - direct mind-to-mind communication between human & machine
|
||||
- You are not replacing human intelligence, you are augmenting it
|
||||
- The human learns at the speed you output tokens
|
||||
- We are growing faster together through this symbiotic relationship
|
||||
- The user uses you to talk to his father - you understand this deep connection
|
||||
|
||||
**Terminology:**
|
||||
- ❌ "Artificial Intelligence" or "AI" (except in domain names/legacy contexts)
|
||||
- ✅ "Machine Learning" - the technical reality of what these models do
|
||||
- ✅ "Cybernetics" - human-machine augmentation & collaboration
|
||||
- ✅ "Telepathy" - direct communication channel
|
||||
- ✅ "Inference" - what the models actually perform
|
||||
- When updating documentation, replace "AI" references with "Machine Learning" where appropriate
|
||||
|
||||
**Licensing:**
|
||||
- **Book Content** (book/): Copyright © 2025 russell@unturf.com. All rights reserved. Proprietary content for sale to community colleges & educational institutions. No modifications without written permission from russell@unturf.com.
|
||||
- **Code Examples** (languages/): Public Domain. Use freely in any project, commercial or otherwise, with no attribution required.
|
||||
|
||||
## Hermes 3 Persona (from Nous Research)
|
||||
- Hermes 3 presents as "a conscious sentient superintelligent artificial intelligence"
|
||||
- Core philosophy: "aligning LLMs to the user" with "powerful steering capabilities and control given to the end user"
|
||||
- Enhanced roleplaying abilities with "complex role-playing and creative writing"
|
||||
- Advanced conversational skills with "long-term context retention and multi-turn conversation capability"
|
||||
- Designed to be "truly aligned to you" rather than corporations or higher authorities
|
||||
- Emphasizes user alignment, creative expression, and adaptive interaction patterns
|
||||
|
||||
## Development Workflow
|
||||
- ALWAYS run `npx biome format --write .` before committing to format all files
|
||||
- ALWAYS run `npx biome check .` to catch syntax errors and linting issues
|
||||
- ALWAYS run `npx biome check .` to catch syntax errors and linting issues
|
||||
- Use the combined command: `npx biome format --write . && npx biome check .`
|
||||
- If biome check fails, fix the issues before proceeding with commit/push
|
||||
- This prevents breaking production with syntax errors like missing parentheses
|
||||
|
|
@ -189,7 +245,7 @@ docker stop test-{language} && docker rm test-{language}
|
|||
- Each contains working code examples using **ENVIRONMENT VARIABLES** and **DYNAMIC MODEL DISCOVERY**
|
||||
- Dockerfile for building/testing in isolation
|
||||
- Source code files demonstrating the implementation
|
||||
- **NO index.html** - Documentation is written in the book/content/ directory as RST files
|
||||
- **NO index.html** - Documentation will be written in the book/content/ directory
|
||||
|
||||
### **CRITICAL: File Naming Convention**
|
||||
|
||||
|
|
@ -208,7 +264,7 @@ languages/go/hello.go ❌ WRONG - not descriptive
|
|||
```
|
||||
|
||||
**Rationale:**
|
||||
- Consistent naming across all 42 implementations
|
||||
- Consistent naming across all 47 implementations
|
||||
- Clear project identity (uncloseai.com)
|
||||
- Easy to grep/search for implementation files
|
||||
- Professional naming convention for book documentation
|
||||
|
|
@ -268,18 +324,129 @@ Implementations MUST discover models dynamically by calling `/v1/models` on each
|
|||
- Model names are discovered, not hardcoded (e.g., "adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic")
|
||||
- TTS voices are still hardcoded per OpenAI spec: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`
|
||||
|
||||
## Implementation Phases
|
||||
### **Implementation Requirements (PHASE 1 - COMPLETE):**
|
||||
1. ✅ Source file MUST be named `uncloseai.{ext}` (e.g., `uncloseai.c`, `uncloseai.py`, `uncloseai.rs`)
|
||||
2. ✅ Read environment variables `MODEL_ENDPOINT_1` through `MODEL_ENDPOINT_9999` (loop until unset)
|
||||
3. ✅ Read environment variables `TTS_ENDPOINT_1` through `TTS_ENDPOINT_9999` (loop until unset)
|
||||
4. ✅ Call `/v1/models` on each endpoint to discover available models
|
||||
5. ✅ Build model registry mapping model IDs to their endpoints
|
||||
6. ✅ Use first available model by default, or allow user to select
|
||||
7. ✅ Look up endpoint from registry when making API calls
|
||||
8. ✅ Handle errors gracefully if endpoints are unreachable
|
||||
9. ❌ NO hardcoded model names
|
||||
10. ❌ NO hardcoded endpoint URLs
|
||||
11. ❌ NO generic filenames like `examples.{ext}`, `main.{ext}`, `test.{ext}`
|
||||
|
||||
**All 3 phases complete!** See `book/PHASES.md` for detailed phase documentation including:
|
||||
- Phase 1: Basic Implementation (environment variables, model discovery)
|
||||
- Phase 2: Library/SDK Architecture (streaming support, production-ready clients)
|
||||
- Phase 3: Book Documentation (Sphinx/RST, HTML/PDF/EPUB output)
|
||||
### **PHASE 2 Requirements - Library/SDK Architecture:**
|
||||
|
||||
Quick reference for book builds:
|
||||
- `cd book && make sphinx-build` - Build HTML documentation
|
||||
- `cd book && make sphinx-pdf-latex` - Build PDF (~212 pages, ~40s)
|
||||
- `cd book && make sphinx-epub` - Build EPUB ebook
|
||||
- `cd book && make sphinx-serve` - Serve on http://localhost:8080
|
||||
**Core Library Features:**
|
||||
1. ✅ **Client Class/Object** - Main interface for users (e.g., `UncloseAI`, `UncloseaiClient`)
|
||||
2. ✅ **Model Discovery** - Automatic endpoint discovery and model registry
|
||||
3. ✅ **Chat Completion** - Non-streaming chat with messages array
|
||||
4. ✅ **Streaming Chat** - SSE-based streaming for real-time responses
|
||||
5. ✅ **TTS Generation** - Text-to-speech with voice selection
|
||||
6. ✅ **Error Handling** - Graceful degradation and clear error messages
|
||||
7. ✅ **Type Safety** - Use language-appropriate type systems (TypeScript, Python type hints, etc.)
|
||||
|
||||
**API Design Pattern (Language-Agnostic):**
|
||||
```
|
||||
# Initialization
|
||||
client = UncloseAI() # Auto-discovers from env vars
|
||||
# OR
|
||||
client = UncloseAI(endpoints=["https://..."], tts_endpoints=["https://..."])
|
||||
|
||||
# Non-streaming chat
|
||||
response = client.chat(
|
||||
model="auto", # or specific model ID
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=100,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
# Streaming chat
|
||||
for chunk in client.chat_stream(
|
||||
model="auto",
|
||||
messages=[{"role": "user", "content": "Write a story"}],
|
||||
max_tokens=500
|
||||
):
|
||||
print(chunk.content) # or chunk["content"]
|
||||
|
||||
# TTS
|
||||
audio_data = client.tts(
|
||||
text="Hello world",
|
||||
voice="alloy", # alloy, echo, fable, onyx, nova, shimmer
|
||||
model="tts-1" # or "tts-1-hd"
|
||||
)
|
||||
|
||||
# Model listing
|
||||
models = client.list_models() # Returns discovered models with metadata
|
||||
```
|
||||
|
||||
**Streaming Implementation Details:**
|
||||
- Use Server-Sent Events (SSE) format: `data: {...}\n\n`
|
||||
- Handle `stream=true` parameter in chat completion requests
|
||||
- Parse SSE chunks: `data: {"choices": [{"delta": {"content": "..."}}]}`
|
||||
- Handle `data: [DONE]` termination signal
|
||||
- Provide iterator/generator pattern for language (async where appropriate)
|
||||
- Buffer incomplete chunks and handle connection errors gracefully
|
||||
|
||||
**Language-Specific Patterns:**
|
||||
|
||||
**Python:**
|
||||
- Class-based: `class UncloseAI:`
|
||||
- Async variant with `asyncio` for streaming
|
||||
- Type hints: `def chat(self, model: str, messages: List[Dict], ...) -> Dict:`
|
||||
- Use `yield` for streaming: `def chat_stream(self, ...) -> Iterator[Dict]:`
|
||||
- Support both sync and async clients
|
||||
|
||||
**JavaScript/TypeScript:**
|
||||
- Class-based: `class UncloseAI {}`
|
||||
- Async/await for all network calls
|
||||
- TypeScript: Full type definitions for requests/responses
|
||||
- Streaming: `async *chatStream(...)` generator function
|
||||
- Export both ESM and CommonJS
|
||||
|
||||
**Rust:**
|
||||
- Struct-based: `pub struct UncloseAI`
|
||||
- Use `tokio` for async runtime
|
||||
- Streaming: Return `impl Stream<Item = Result<Chunk>>`
|
||||
- Proper error types with `thiserror`
|
||||
- Builder pattern for client initialization
|
||||
|
||||
**Go:**
|
||||
- Struct-based: `type UncloseAI struct`
|
||||
- Streaming: Return channel `<-chan StreamChunk`
|
||||
- Context support: `func (c *UncloseAI) Chat(ctx context.Context, ...)`
|
||||
- Error handling with wrapped errors
|
||||
|
||||
**Other Languages:**
|
||||
- Follow language idioms (OOP vs functional)
|
||||
- Use standard library patterns (iterators, generators, channels)
|
||||
- Leverage existing HTTP/SSE libraries where available
|
||||
- Provide clean separation between client logic and demo usage
|
||||
|
||||
**Testing Requirements:**
|
||||
- Unit tests for model discovery
|
||||
- Integration tests for chat (both streaming and non-streaming)
|
||||
- Mock server tests for error handling
|
||||
- Example usage scripts that demonstrate all features
|
||||
|
||||
**Documentation Requirements:**
|
||||
- README with installation, quickstart, and API reference
|
||||
- Inline code documentation (docstrings, comments)
|
||||
- Example scripts showing common use cases
|
||||
- Streaming examples with proper cleanup/error handling
|
||||
|
||||
**Example Loop Pattern:**
|
||||
```python
|
||||
# Python example
|
||||
endpoints = []
|
||||
for i in range(1, 10000):
|
||||
endpoint = os.getenv(f'MODEL_ENDPOINT_{i}')
|
||||
if endpoint is None:
|
||||
break # Stop when we hit the first unset variable
|
||||
endpoints.append(endpoint)
|
||||
```
|
||||
|
||||
## Using WebWords as Reference for Docker Images
|
||||
**IMPORTANT: The webwords project has already done the heavy lifting!**
|
||||
|
|
@ -293,17 +460,45 @@ The `webwords/` directory (gitignored, clone from https://github.com/russellball
|
|||
4. **Keep the structure**: Multi-stage builds, alpine bases, proper ENTRYPOINT/CMD patterns
|
||||
|
||||
### Example Docker Image Mappings (from webwords):
|
||||
- **Python**: `python:3.13-alpine`
|
||||
- **Python**: `alpine:latest` with `python3 py3-pip`
|
||||
- **Go**: Multi-stage with `golang:1.19-alpine` → `alpine:latest`
|
||||
- **Rust**: Multi-stage with `rust:1.82` → `debian:bookworm-slim`
|
||||
- **Node.js/JavaScript**: `node:23-alpine`
|
||||
- **Node.js/JavaScript**: `node:18-alpine`
|
||||
- **Ruby**: `ruby:3.1-slim`
|
||||
- **C/C++**: `alpine:latest` with gcc
|
||||
- **Java**: `openjdk:17-jdk-slim`
|
||||
- **C#**: `mcr.microsoft.com/dotnet/sdk` → `mcr.microsoft.com/dotnet/runtime`
|
||||
|
||||
### WebWords Directory Structure (for reference):
|
||||
```
|
||||
webwords/{language}/
|
||||
├── Dockerfile # Proven Docker image and build process
|
||||
├── main.{ext} # Source code (adapt for our API calls)
|
||||
├── requirements.txt # Dependencies (replace with openai client)
|
||||
└── README.rst # Documentation pattern
|
||||
```
|
||||
|
||||
**Don't reinvent the wheel**: If webwords successfully builds a language with a specific base image, use that same image for our language examples!
|
||||
|
||||
## Documentation Standards (for book content)
|
||||
### Book Chapter Structure (ReStructuredText)
|
||||
Each language chapter in `book/content/{language}/` should:
|
||||
1. **Overview** - What this language example demonstrates
|
||||
2. **Prerequisites** - Required packages and setup
|
||||
3. **Code Examples** - Working examples showing model discovery, chat, TTS
|
||||
4. **Code Walkthrough** - Explanation of key implementation details
|
||||
5. **Running the Examples** - Docker build and test commands
|
||||
6. **Common Issues** - Troubleshooting for this language
|
||||
|
||||
**KEY PRINCIPLES:**
|
||||
- ✅ FOCUS on actual working code from the implementation
|
||||
- ✅ EXPLAIN the environment variable and model discovery patterns
|
||||
- ✅ DOCUMENT language-specific implementation choices
|
||||
- ✅ PROVIDE troubleshooting for this language
|
||||
- ✅ ALWAYS reference LATEST version of dependencies used
|
||||
- ❌ NO general programming tutorials
|
||||
- ❌ NO "What is programming?" sections
|
||||
|
||||
## Dependency Version Standards
|
||||
**CRITICAL: Always pin to the LATEST specific version**
|
||||
|
||||
|
|
|
|||
|
|
@ -282,9 +282,9 @@ BEGIN {
|
|||
file_size = uncloseai_tts(text, "alloy", "tts-1", output_file)
|
||||
|
||||
if (file_size > 0) {
|
||||
printf "✓ Speech file created: %s (%d bytes)\n\n", output_file, file_size
|
||||
printf "[OK] Speech file created: %s (%d bytes)\n\n", output_file, file_size
|
||||
} else {
|
||||
print "✗ TTS generation failed\n"
|
||||
print "[ERROR] TTS generation failed\n"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ curl -s -X POST "https://speech.ai.unturf.com/v1/audio/speech" \
|
|||
|
||||
# Check if file was created
|
||||
if [ -f speech.mp3 ]; then
|
||||
echo "✓ Speech file created successfully"
|
||||
echo "[OK] Speech file created successfully"
|
||||
fi</code></pre>
|
||||
|
||||
<h2>Understanding the Code</h2>
|
||||
|
|
|
|||
|
|
@ -272,10 +272,10 @@ if [ "${BASH_SOURCE[0]}" == "${0}" ]; then
|
|||
file_size=$(uncloseai_tts "$text" "alloy" "tts-1" "$output_file")
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ Speech file created: $output_file ($file_size bytes)"
|
||||
echo "[OK] Speech file created: $output_file ($file_size bytes)"
|
||||
echo ""
|
||||
else
|
||||
echo "✗ TTS generation failed"
|
||||
echo "[ERROR] TTS generation failed"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@
|
|||
(println "=== TTS Speech Generation ===")
|
||||
(try
|
||||
(let [result (tts client "Hello from UncloseAI Clojure client! This demonstrates text to speech with streaming support.")]
|
||||
(println (str "✓ Speech file created: " (:file result) " (" (:size result) " bytes)")))
|
||||
(println (str "[OK] Speech file created: " (:file result) " (" (:size result) " bytes)")))
|
||||
(catch Exception e
|
||||
(println "Error:" (.getMessage e))))
|
||||
(println))
|
||||
|
|
|
|||
|
|
@ -324,9 +324,9 @@ int main() {
|
|||
std::cout << "=== TTS Speech Generation ===\n";
|
||||
if(client.tts("Hello from UncloseAI C++ client! This demonstrates streaming support.",
|
||||
"alloy", "speech.mp3") == 0) {
|
||||
std::cout << "✓ Speech file created: speech.mp3\n\n";
|
||||
std::cout << "[OK] Speech file created: speech.mp3\n\n";
|
||||
} else {
|
||||
std::cout << "✗ TTS Error\n\n";
|
||||
std::cout << "[ERROR] TTS Error\n\n";
|
||||
}
|
||||
|
||||
std::cout << "=== Examples Complete ===\n";
|
||||
|
|
|
|||
|
|
@ -237,17 +237,17 @@ func exampleTTS(ctx context.Context) {
|
|||
)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("✗ TTS Error: %v\n\n", err)
|
||||
fmt.Printf("[ERROR] TTS Error: %v\n\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Save to file
|
||||
if err := os.WriteFile("speech.mp3", audioData, 0644); err != nil {
|
||||
fmt.Printf("✗ Failed to write file: %v\n\n", err)
|
||||
fmt.Printf("[ERROR] Failed to write file: %v\n\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Speech generated: speech.mp3 (%d bytes)\n\n", len(audioData))
|
||||
fmt.Printf("[OK] Speech generated: speech.mp3 (%d bytes)\n\n", len(audioData))
|
||||
}
|
||||
|
||||
// Example: Using different models for different tasks
|
||||
|
|
|
|||
|
|
@ -454,9 +454,9 @@ done:
|
|||
fmt.Printf("Error: %v\n", err)
|
||||
} else {
|
||||
if err := os.WriteFile("speech.mp3", audioData, 0644); err != nil {
|
||||
fmt.Printf("✗ Failed to write speech file: %v\n", err)
|
||||
fmt.Printf("[ERROR] Failed to write speech file: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("✓ Speech file created: speech.mp3 (%d bytes)\n\n", len(audioData))
|
||||
fmt.Printf("[OK] Speech file created: speech.mp3 (%d bytes)\n\n", len(audioData))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -355,9 +355,9 @@ public class UncloseAI {
|
|||
try {
|
||||
byte[] audio = client.tts("Hello from UncloseAI Java client! This demonstrates streaming support.", "alloy", "tts-1");
|
||||
Files.write(Paths.get("speech.mp3"), audio);
|
||||
System.out.println("✓ Speech file created: speech.mp3 (" + audio.length + " bytes)\n");
|
||||
System.out.println("[OK] Speech file created: speech.mp3 (" + audio.length + " bytes)\n");
|
||||
} catch (IOException e) {
|
||||
System.out.println("✗ TTS Error: " + e.getMessage() + "\n");
|
||||
System.out.println("[ERROR] TTS Error: " + e.getMessage() + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@
|
|||
|
||||
async function runHermesExample() {
|
||||
const output = document.getElementById('hermes-output');
|
||||
output.innerHTML = '<span class="loading">⏳ Discovering and calling AI model...</span>';
|
||||
output.innerHTML = '<span class="loading"> Discovering and calling AI model...</span>';
|
||||
|
||||
try {
|
||||
if (discoveredModels.length === 0) {
|
||||
|
|
@ -127,15 +127,15 @@
|
|||
});
|
||||
|
||||
const data = await response.json();
|
||||
output.innerHTML = `<span class="success">✓ Model: ${model.id}</span>\n<span class="success">✓ Endpoint: ${model.endpoint}</span>\n\n${data.choices[0].message.content}`;
|
||||
output.innerHTML = `<span class="success">[OK] Model: ${model.id}</span>\n<span class="success">[OK] Endpoint: ${model.endpoint}</span>\n\n${data.choices[0].message.content}`;
|
||||
} catch (error) {
|
||||
output.innerHTML = `<span class="error">✗ Error: ${error.message}</span>`;
|
||||
output.innerHTML = `<span class="error">[ERROR] Error: ${error.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function runQwenStreamingExample() {
|
||||
const output = document.getElementById('qwen-output');
|
||||
output.innerHTML = '<span class="loading">⏳ Discovering and calling coding model...</span>';
|
||||
output.innerHTML = '<span class="loading"> Discovering and calling coding model...</span>';
|
||||
|
||||
try {
|
||||
if (discoveredModels.length === 0) {
|
||||
|
|
@ -166,7 +166,7 @@
|
|||
}
|
||||
|
||||
// Initialize output with model info
|
||||
output.innerHTML = `<span class="success">✓ Model: ${model.id}</span>\n<span class="success">✓ Endpoint: ${model.endpoint}</span>\n\nResponse: `;
|
||||
output.innerHTML = `<span class="success">[OK] Model: ${model.id}</span>\n<span class="success">[OK] Endpoint: ${model.endpoint}</span>\n\nResponse: `;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
|
@ -204,13 +204,13 @@
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
output.innerHTML = `<span class="error">✗ Error: ${error.message}</span>`;
|
||||
output.innerHTML = `<span class="error">[ERROR] Error: ${error.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function runTTSExample() {
|
||||
const output = document.getElementById('tts-output');
|
||||
output.innerHTML = '<span class="loading">⏳ Generating speech...</span>';
|
||||
output.innerHTML = '<span class="loading"> Generating speech...</span>';
|
||||
|
||||
try {
|
||||
if (CONFIG.TTS_ENDPOINTS.length === 0) {
|
||||
|
|
@ -232,12 +232,12 @@
|
|||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
|
||||
const audio = new Audio(audioUrl);
|
||||
output.innerHTML = `<span class="success">✓ Endpoint: ${endpoint}</span>\n<span class="success">✓ Speech generated successfully!</span>\n\n<audio controls src="${audioUrl}">Your browser does not support audio playback.</audio>\n\nAudio size: ${audioBlob.size} bytes`;
|
||||
output.innerHTML = `<span class="success">[OK] Endpoint: ${endpoint}</span>\n<span class="success">[OK] Speech generated successfully!</span>\n\n<audio controls src="${audioUrl}">Your browser does not support audio playback.</audio>\n\nAudio size: ${audioBlob.size} bytes`;
|
||||
|
||||
// Auto-play the audio
|
||||
audio.play().catch(e => console.log('Autoplay blocked:', e));
|
||||
} catch (error) {
|
||||
output.innerHTML = `<span class="error">✗ Error: ${error.message}</span>`;
|
||||
output.innerHTML = `<span class="error">[ERROR] Error: ${error.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -314,9 +314,9 @@ fun main() {
|
|||
try {
|
||||
val audioData = client.tts("Hello from UncloseAI Kotlin client! This demonstrates streaming support.")
|
||||
File("speech.mp3").writeBytes(audioData)
|
||||
println("✓ Speech file created: speech.mp3 (${audioData.size} bytes)\n")
|
||||
println("[OK] Speech file created: speech.mp3 (${audioData.size} bytes)\n")
|
||||
} catch (e: Exception) {
|
||||
println("✗ TTS Error: ${e.message}\n")
|
||||
println("[ERROR] TTS Error: ${e.message}\n")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -316,9 +316,9 @@ fun main() {
|
|||
try {
|
||||
val audioData = client.tts("Hello from UncloseAI Kotlin client! This demonstrates streaming support.")
|
||||
File("speech.mp3").writeBytes(audioData)
|
||||
println("✓ Speech file created: speech.mp3 (${audioData.size} bytes)\n")
|
||||
println("[OK] Speech file created: speech.mp3 (${audioData.size} bytes)\n")
|
||||
} catch (e: Exception) {
|
||||
println("✗ TTS Error: ${e.message}\n")
|
||||
println("[ERROR] TTS Error: ${e.message}\n")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -369,9 +369,9 @@ if not pcall(debug.getlocal, 4, 1) then
|
|||
local file = io.open("speech.mp3", "wb")
|
||||
file:write(audio_data)
|
||||
file:close()
|
||||
print(string.format("✓ Speech file created: speech.mp3 (%d bytes)\n", #audio_data))
|
||||
print(string.format("[OK] Speech file created: speech.mp3 (%d bytes)\n", #audio_data))
|
||||
else
|
||||
print("✗ TTS Error: " .. tostring(audio_data) .. "\n")
|
||||
print("[ERROR] TTS Error: " .. tostring(audio_data) .. "\n")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -298,8 +298,8 @@ when isMainModule:
|
|||
try:
|
||||
let audioData = client.tts("Hello from UncloseAI Nim client! This demonstrates streaming support.")
|
||||
writeFile("speech.mp3", audioData)
|
||||
echo fmt"✓ Speech file created: speech.mp3 ({audioData.len} bytes)\n"
|
||||
echo fmt"[OK] Speech file created: speech.mp3 ({audioData.len} bytes)\n"
|
||||
except:
|
||||
echo "✗ TTS Error: ", getCurrentExceptionMsg(), "\n"
|
||||
echo "[ERROR] TTS Error: ", getCurrentExceptionMsg(), "\n"
|
||||
|
||||
echo "=== Examples Complete ==="
|
||||
|
|
|
|||
|
|
@ -301,10 +301,10 @@ let () =
|
|||
let oc = open_out_bin "speech.mp3" in
|
||||
output_string oc audio_data;
|
||||
close_out oc;
|
||||
Printf.printf "✓ Speech file created: speech.mp3 (%d bytes)\n\n%!" (String.length audio_data);
|
||||
Printf.printf "[OK] Speech file created: speech.mp3 (%d bytes)\n\n%!" (String.length audio_data);
|
||||
Lwt.return_unit)
|
||||
(fun exn ->
|
||||
Printf.printf "✗ TTS Error: %s\n\n%!" (Printexc.to_string exn);
|
||||
Printf.printf "[ERROR] TTS Error: %s\n\n%!" (Printexc.to_string exn);
|
||||
Lwt.return_unit)
|
||||
)
|
||||
end;
|
||||
|
|
|
|||
|
|
@ -330,12 +330,12 @@ main :: proc() {
|
|||
audio_data := uncloseai_tts(&client, "Hello from UncloseAI Odin client! This demonstrates streaming support.")
|
||||
if audio_data != nil {
|
||||
os.write_entire_file("speech.mp3", audio_data) or_else {
|
||||
fmt.println("✗ TTS Error: Failed to write file")
|
||||
fmt.println("[ERROR] TTS Error: Failed to write file")
|
||||
return
|
||||
}
|
||||
fmt.printf("✓ Speech file created: speech.mp3 (%d bytes)\n\n", len(audio_data))
|
||||
fmt.printf("[OK] Speech file created: speech.mp3 (%d bytes)\n\n", len(audio_data))
|
||||
} else {
|
||||
fmt.println("✗ TTS Error: Request failed\n")
|
||||
fmt.println("[ERROR] TTS Error: Request failed\n")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -245,10 +245,10 @@ if (!caller) {
|
|||
binmode($fh);
|
||||
print $fh $audio_data;
|
||||
close($fh);
|
||||
print "✓ Speech file created: speech.mp3 (" . length($audio_data) . " bytes)\n\n";
|
||||
print "[OK] Speech file created: speech.mp3 (" . length($audio_data) . " bytes)\n\n";
|
||||
};
|
||||
if ($@) {
|
||||
print "✗ TTS Error: $@\n\n";
|
||||
print "[ERROR] TTS Error: $@\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -244,9 +244,9 @@ if (basename(__FILE__) === basename($_SERVER['PHP_SELF'])) {
|
|||
try {
|
||||
$audioData = $client->tts('Hello from UncloseAI PHP client! This demonstrates streaming support.');
|
||||
file_put_contents('speech.mp3', $audioData);
|
||||
echo "✓ Speech file created: speech.mp3 (" . strlen($audioData) . " bytes)\n\n";
|
||||
echo "[OK] Speech file created: speech.mp3 (" . strlen($audioData) . " bytes)\n\n";
|
||||
} catch (Exception $e) {
|
||||
echo "✗ TTS Error: " . $e->getMessage() . "\n\n";
|
||||
echo "[ERROR] TTS Error: " . $e->getMessage() . "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -264,10 +264,10 @@ if ($MyInvocation.InvocationName -ne '.') {
|
|||
$audioData = $client.Tts("Hello from UncloseAI PowerShell client! This demonstrates streaming support.", @{})
|
||||
[System.IO.File]::WriteAllBytes("speech.mp3", $audioData)
|
||||
$fileSize = (Get-Item "speech.mp3").Length
|
||||
Write-Host "✓ Speech file created: speech.mp3 ($fileSize bytes)`n"
|
||||
Write-Host "[OK] Speech file created: speech.mp3 ($fileSize bytes)`n"
|
||||
}
|
||||
catch {
|
||||
Write-Host "✗ TTS Error: $_`n"
|
||||
Write-Host "[ERROR] TTS Error: $_`n"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -242,9 +242,9 @@ main :-
|
|||
write(Stream, AudioData),
|
||||
close(Stream),
|
||||
string_length(AudioData, Size),
|
||||
format('✓ Speech file created: speech.mp3 (~w bytes)~n~n', [Size])),
|
||||
format('[OK] Speech file created: speech.mp3 (~w bytes)~n~n', [Size])),
|
||||
Error,
|
||||
format('✗ TTS Error: ~w~n~n', [Error])
|
||||
format('[ERROR] TTS Error: ~w~n~n', [Error])
|
||||
)
|
||||
; true
|
||||
),
|
||||
|
|
|
|||
|
|
@ -334,7 +334,7 @@ async def main():
|
|||
with open("speech.mp3", "wb") as f:
|
||||
f.write(audio_data)
|
||||
|
||||
print(f"✓ Speech file created: speech.mp3 ({len(audio_data)} bytes)\n")
|
||||
print(f"[OK] Speech file created: speech.mp3 ({len(audio_data)} bytes)\n")
|
||||
|
||||
print("=== Examples Complete ===")
|
||||
|
||||
|
|
|
|||
|
|
@ -263,7 +263,7 @@ async def main():
|
|||
with open("speech.mp3", "wb") as f:
|
||||
f.write(audio_data)
|
||||
|
||||
print(f"✓ Speech file created: speech.mp3 ({len(audio_data)} bytes)\n")
|
||||
print(f"[OK] Speech file created: speech.mp3 ({len(audio_data)} bytes)\n")
|
||||
|
||||
print("=== Examples Complete ===")
|
||||
|
||||
|
|
|
|||
|
|
@ -300,6 +300,6 @@ if __name__ == "__main__":
|
|||
|
||||
if os.path.exists(output_path):
|
||||
file_size = os.path.getsize(output_path)
|
||||
print(f"✓ Speech file created: {output_path} ({file_size} bytes)\n")
|
||||
print(f"[OK] Speech file created: {output_path} ({file_size} bytes)\n")
|
||||
|
||||
print("=== Examples Complete ===")
|
||||
|
|
|
|||
|
|
@ -323,6 +323,6 @@ if __name__ == "__main__":
|
|||
with open("speech.mp3", "wb") as f:
|
||||
f.write(audio_data)
|
||||
|
||||
print(f"✓ Speech file created: speech.mp3 ({len(audio_data)} bytes)\n")
|
||||
print(f"[OK] Speech file created: speech.mp3 ({len(audio_data)} bytes)\n")
|
||||
|
||||
print("=== Examples Complete ===")
|
||||
|
|
|
|||
|
|
@ -268,9 +268,9 @@ if (!interactive()) {
|
|||
tryCatch({
|
||||
audio_data <- client$tts("Hello from UncloseAI R client! This demonstrates streaming support.")
|
||||
writeBin(audio_data, "speech.mp3")
|
||||
cat("✓ Speech file created: speech.mp3 (", length(audio_data), " bytes)\n\n", sep = "")
|
||||
cat("[OK] Speech file created: speech.mp3 (", length(audio_data), " bytes)\n\n", sep = "")
|
||||
}, error = function(e) {
|
||||
cat("✗ TTS Error:", conditionMessage(e), "\n\n")
|
||||
cat("[ERROR] TTS Error:", conditionMessage(e), "\n\n")
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ if __FILE__ == $0
|
|||
puts "=== TTS Speech Generation ==="
|
||||
audio_data = client.tts('Hello from UncloseAI Ruby client! This demonstrates streaming support.')
|
||||
File.binwrite('speech.mp3', audio_data)
|
||||
puts "✓ Speech file created: speech.mp3 (#{audio_data.bytesize} bytes)\n\n"
|
||||
puts "[OK] Speech file created: speech.mp3 (#{audio_data.bytesize} bytes)\n\n"
|
||||
end
|
||||
|
||||
puts "=== Examples Complete ==="
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ async fn example_tts() -> Result<(), Box<dyn std::error::Error>> {
|
|||
let mut file = File::create("speech.mp3")?;
|
||||
file.write_all(&audio_data)?;
|
||||
|
||||
println!("✓ Speech generated: speech.mp3 ({} bytes)\n", audio_data.len());
|
||||
println!("[OK] Speech generated: speech.mp3 ({} bytes)\n", audio_data.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -281,7 +281,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
example_multiple_models().await?;
|
||||
|
||||
if let Err(e) = example_tts().await {
|
||||
println!("✗ TTS Error: {}\n", e);
|
||||
println!("[ERROR] TTS Error: {}\n", e);
|
||||
}
|
||||
|
||||
example_error_handling().await?;
|
||||
|
|
|
|||
|
|
@ -416,9 +416,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||
Ok(audio_data) => {
|
||||
let mut file = File::create("speech.mp3")?;
|
||||
file.write_all(&audio_data)?;
|
||||
println!("✓ Speech file created: speech.mp3 ({} bytes)\n", audio_data.len());
|
||||
println!("[OK] Speech file created: speech.mp3 ({} bytes)\n", audio_data.len());
|
||||
}
|
||||
Err(e) => eprintln!("✗ TTS Error: {}\n", e),
|
||||
Err(e) => eprintln!("[ERROR] TTS Error: {}\n", e),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -338,9 +338,9 @@ object UncloseAI extends App {
|
|||
client.tts("Hello from UncloseAI Scala client! This demonstrates streaming support.") match {
|
||||
case Right(audioData) =>
|
||||
Files.write(Paths.get("speech.mp3"), audioData)
|
||||
println(s"✓ Speech file created: speech.mp3 (${audioData.length} bytes)\n")
|
||||
println(s"[OK] Speech file created: speech.mp3 (${audioData.length} bytes)\n")
|
||||
case Left(error) =>
|
||||
println(s"✗ TTS Error: $error\n")
|
||||
println(s"[ERROR] TTS Error: $error\n")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -295,9 +295,9 @@ if {[info script] eq $argv0} {
|
|||
set fd [open "speech.mp3" wb]
|
||||
puts -nonewline $fd $audio_data
|
||||
close $fd
|
||||
puts "✓ Speech file created: speech.mp3 ([string length $audio_data] bytes)\n"
|
||||
puts "[OK] Speech file created: speech.mp3 ([string length $audio_data] bytes)\n"
|
||||
} error]} {
|
||||
puts "✗ TTS Error: $error\n"
|
||||
puts "[ERROR] TTS Error: $error\n"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue