@lazarusrolando/crayoncli 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +1 -0
- package/README.md +60 -20
- package/dist/index.js +111 -37
- package/dist/providers/local-gemma.js +55 -90
- package/model/requirements.txt +9 -6
- package/model/server.py +148 -136
- package/package.json +7 -10
- package/src/index.ts +107 -37
- package/src/providers/local-gemma.ts +54 -144
- package/tsconfig.json +3 -13
- package/.npm-global/crayon +0 -16
- package/.npm-global/crayon.cmd +0 -17
- package/.npm-global/crayon.ps1 +0 -28
package/.env.example
CHANGED
package/README.md
CHANGED
|
@@ -1,38 +1,78 @@
|
|
|
1
|
-
# CRAYON v0.
|
|
1
|
+
# CRAYON v0.4.0 — Gemma 4 E2B Multimodal CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Local multimodal CLI using the official Transformers interface for `google/gemma-4-E2B-it`.
|
|
4
4
|
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
5
|
+
## Supported
|
|
6
|
+
|
|
7
|
+
- Text -> Text
|
|
8
|
+
- Image + Text -> Text
|
|
9
|
+
- Audio + Text -> Text
|
|
10
|
+
- Video + Text -> Text
|
|
11
|
+
- Text + Image + Audio -> Text (server API can be extended for combined uploads)
|
|
12
|
+
- Streaming output for every mode
|
|
13
|
+
|
|
14
|
+
Gemma 4 E2B supports native audio in addition to image/video multimodality. The model generates text; it is not an image/audio/video generator.
|
|
10
15
|
|
|
11
16
|
## Install
|
|
12
17
|
|
|
13
18
|
```powershell
|
|
14
19
|
npm install
|
|
15
|
-
python -m pip install -r model
|
|
20
|
+
python -m pip install -r model\requirements.txt
|
|
16
21
|
Copy-Item .env.example .env
|
|
17
22
|
npm run build
|
|
18
|
-
npm link
|
|
19
23
|
```
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
Use a recent Transformers version. The model card currently recommends the latest Transformers.
|
|
26
|
+
|
|
27
|
+
## CLI
|
|
28
|
+
|
|
29
|
+
Text:
|
|
22
30
|
|
|
23
31
|
```powershell
|
|
24
|
-
|
|
25
|
-
crayon "explain this project"
|
|
32
|
+
node dist/index.js "Explain recursion"
|
|
26
33
|
```
|
|
27
34
|
|
|
28
|
-
|
|
35
|
+
Image:
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
node dist/index.js image photo.jpg "What is in this image?"
|
|
39
|
+
```
|
|
29
40
|
|
|
30
|
-
|
|
41
|
+
Audio:
|
|
31
42
|
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
```powershell
|
|
44
|
+
node dist/index.js audio speech.wav "Transcribe and summarize this audio."
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Video:
|
|
48
|
+
|
|
49
|
+
```powershell
|
|
50
|
+
node dist/index.js video clip.mp4 "Describe what happens in this video."
|
|
38
51
|
```
|
|
52
|
+
|
|
53
|
+
Interactive:
|
|
54
|
+
|
|
55
|
+
```powershell
|
|
56
|
+
node dist/index.js
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Then:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
/image photo.jpg What is in this image?
|
|
63
|
+
/audio speech.wav Transcribe this.
|
|
64
|
+
/video clip.mp4 Describe what happens.
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Important
|
|
68
|
+
|
|
69
|
+
Inference is local. Hugging Face is used as the model repository for downloading/caching model files; the CLI calls your local FastAPI server.
|
|
70
|
+
|
|
71
|
+
The official Gemma 4 Transformers interface uses:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
AutoProcessor
|
|
75
|
+
AutoModelForMultimodalLM
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
rather than the text-only `AutoTokenizer` / `AutoModelForCausalLM` path.
|
package/dist/index.js
CHANGED
|
@@ -1,45 +1,119 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
1
|
import "dotenv/config";
|
|
3
|
-
import
|
|
4
|
-
import
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { basename } from "node:path";
|
|
5
5
|
import { LocalGemma } from "./providers/local-gemma.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
.
|
|
11
|
-
.
|
|
12
|
-
.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
6
|
+
const gemma = new LocalGemma({
|
|
7
|
+
python: process.env.CRAYON_PYTHON || "python",
|
|
8
|
+
model: process.env.CRAYON_MODEL || "google/gemma-4-E2B-it",
|
|
9
|
+
host: process.env.CRAYON_HOST || "127.0.0.1",
|
|
10
|
+
port: Number(process.env.CRAYON_PORT || 8765),
|
|
11
|
+
maxNewTokens: Number(process.env.CRAYON_MAX_NEW_TOKENS || 1024),
|
|
12
|
+
temperature: Number(process.env.CRAYON_TEMPERATURE || 0.7)
|
|
13
|
+
});
|
|
14
|
+
async function streamMedia(prompt, type, path) {
|
|
15
|
+
const form = new FormData();
|
|
16
|
+
form.append("prompt", prompt);
|
|
17
|
+
form.append("media_type", type);
|
|
18
|
+
form.append("max_new_tokens", String(Number(process.env.CRAYON_MAX_NEW_TOKENS || 1024)));
|
|
19
|
+
form.append("temperature", String(Number(process.env.CRAYON_TEMPERATURE || 0.7)));
|
|
20
|
+
const data = await readFile(path);
|
|
21
|
+
form.append("file", new Blob([data]), basename(path));
|
|
22
|
+
const response = await fetch(`http://${process.env.CRAYON_HOST || "127.0.0.1"}:${process.env.CRAYON_PORT || 8765}/generate/media`, { method: "POST", body: form });
|
|
23
|
+
if (!response.ok)
|
|
24
|
+
throw new Error(await response.text());
|
|
25
|
+
if (!response.body)
|
|
26
|
+
throw new Error("No streaming response.");
|
|
27
|
+
const reader = response.body.getReader();
|
|
28
|
+
const decoder = new TextDecoder();
|
|
29
|
+
process.stdout.write("\nCRAYON: ");
|
|
30
|
+
try {
|
|
31
|
+
while (true) {
|
|
32
|
+
const { done, value } = await reader.read();
|
|
33
|
+
if (done)
|
|
34
|
+
break;
|
|
35
|
+
process.stdout.write(decoder.decode(value, { stream: true }));
|
|
36
|
+
}
|
|
37
|
+
process.stdout.write(decoder.decode());
|
|
38
|
+
process.stdout.write("\n");
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
reader.releaseLock();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function main() {
|
|
45
|
+
const args = process.argv.slice(2);
|
|
46
|
+
await gemma.start();
|
|
47
|
+
// One-shot:
|
|
48
|
+
// crayon "Explain this"
|
|
49
|
+
// crayon image photo.jpg "What is in this image?"
|
|
50
|
+
// crayon audio speech.wav "Transcribe this"
|
|
51
|
+
// crayon video clip.mp4 "Describe this video"
|
|
52
|
+
if (args[0] && ["image", "audio", "video"].includes(args[0]) && args[1] && args[2]) {
|
|
53
|
+
await streamMedia(args.slice(2).join(" "), args[0], args[1]);
|
|
16
54
|
gemma.stop();
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (args.length) {
|
|
58
|
+
await gemma.generateStream(args.join(" "));
|
|
59
|
+
gemma.stop();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
console.log(`
|
|
63
|
+
==========================================
|
|
64
|
+
CRAYON v0.4.0 - GEMMA MULTIMODAL CLI
|
|
65
|
+
==========================================
|
|
66
|
+
Text -> Text
|
|
67
|
+
Image -> Text
|
|
68
|
+
Audio -> Text
|
|
69
|
+
Video -> Text
|
|
70
|
+
|
|
71
|
+
Commands:
|
|
72
|
+
/image <file> <question>
|
|
73
|
+
/audio <file> <question>
|
|
74
|
+
/video <file> <question>
|
|
75
|
+
/exit
|
|
76
|
+
|
|
77
|
+
Examples:
|
|
78
|
+
/image photo.jpg What is in this image?
|
|
79
|
+
/audio speech.wav Transcribe this audio.
|
|
80
|
+
/video clip.mp4 Describe what happens.
|
|
81
|
+
`);
|
|
82
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "You: " });
|
|
83
|
+
const shutdown = () => { rl.close(); gemma.stop(); process.exit(0); };
|
|
84
|
+
process.on("SIGINT", shutdown);
|
|
85
|
+
rl.prompt();
|
|
86
|
+
rl.on("line", async (line) => {
|
|
87
|
+
const input = line.trim();
|
|
88
|
+
if (!input) {
|
|
89
|
+
rl.prompt();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (input === "/exit") {
|
|
93
|
+
shutdown();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
rl.pause();
|
|
25
97
|
try {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
98
|
+
const parts = input.split(/\s+/);
|
|
99
|
+
const cmd = parts[0];
|
|
100
|
+
if ((cmd === "/image" || cmd === "/audio" || cmd === "/video") && parts.length >= 3) {
|
|
101
|
+
const type = cmd.slice(1);
|
|
102
|
+
const path = parts[1];
|
|
103
|
+
const prompt = parts.slice(2).join(" ");
|
|
104
|
+
await streamMedia(prompt, type, path);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
await gemma.generateStream(input);
|
|
33
108
|
}
|
|
34
109
|
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
console.error("\n[CRAYON ERROR]", error instanceof Error ? error.message : error);
|
|
112
|
+
}
|
|
35
113
|
finally {
|
|
36
|
-
rl.
|
|
37
|
-
|
|
114
|
+
rl.resume();
|
|
115
|
+
rl.prompt();
|
|
38
116
|
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
gemma.stop();
|
|
43
|
-
}
|
|
44
|
-
});
|
|
45
|
-
await program.parseAsync();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
main().catch(error => { console.error("[CRAYON FATAL]", error); gemma.stop(); process.exit(1); });
|
|
@@ -1,109 +1,74 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
3
2
|
export class LocalGemma {
|
|
4
3
|
process;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
this.python = options.python ?? process.env.CRAYON_PYTHON ?? "python";
|
|
11
|
-
this.host = options.host ?? process.env.CRAYON_HOST ?? "127.0.0.1";
|
|
12
|
-
this.port = options.port ?? Number(process.env.CRAYON_PORT ?? 8765);
|
|
13
|
-
this.model =
|
|
14
|
-
options.model ??
|
|
15
|
-
process.env.CRAYON_MODEL ??
|
|
16
|
-
"google/gemma-4-E2B-it";
|
|
17
|
-
}
|
|
18
|
-
url(path) {
|
|
19
|
-
return `http://${this.host}:${this.port}${path}`;
|
|
4
|
+
options;
|
|
5
|
+
baseUrl;
|
|
6
|
+
constructor(options) {
|
|
7
|
+
this.options = options;
|
|
8
|
+
this.baseUrl = `http://${options.host}:${options.port}`;
|
|
20
9
|
}
|
|
21
10
|
async start() {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
11
|
+
if (await this.isReady())
|
|
12
|
+
return;
|
|
13
|
+
console.log("[CRAYON] Starting local Gemma multimodal server...");
|
|
14
|
+
console.log(`[CRAYON] Model: ${this.options.model}`);
|
|
15
|
+
this.process = spawn(this.options.python, ["model/server.py"], {
|
|
16
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
17
|
+
env: { ...process.env, CRAYON_MODEL: this.options.model, CRAYON_HOST: this.options.host,
|
|
18
|
+
CRAYON_PORT: String(this.options.port) }
|
|
19
|
+
});
|
|
20
|
+
this.process.stdout?.on("data", (d) => process.stdout.write(`[Gemma] ${d.toString()}`));
|
|
21
|
+
this.process.stderr?.on("data", (d) => process.stderr.write(`[Gemma] ${d.toString()}`));
|
|
22
|
+
const deadline = Date.now() + 180000;
|
|
23
|
+
while (Date.now() < deadline) {
|
|
24
|
+
if (await this.isReady()) {
|
|
25
|
+
console.log("[CRAYON] Local multimodal Gemma server is ready.");
|
|
26
26
|
return;
|
|
27
27
|
}
|
|
28
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
29
|
+
}
|
|
30
|
+
throw new Error("Gemma server did not become ready within 180 seconds.");
|
|
31
|
+
}
|
|
32
|
+
async isReady() {
|
|
33
|
+
try {
|
|
34
|
+
return (await fetch(`${this.baseUrl}/health`)).ok;
|
|
28
35
|
}
|
|
29
36
|
catch {
|
|
30
|
-
|
|
37
|
+
return false;
|
|
31
38
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// stderr is available because we configured it as "pipe".
|
|
48
|
-
this.process.stderr?.on("data", (data) => {
|
|
49
|
-
process.stderr.write(`\x1b[90m[Gemma] ${data.toString()}\x1b[0m`);
|
|
50
|
-
});
|
|
51
|
-
this.process.on("error", (error) => {
|
|
52
|
-
console.error("[CRAYON] Failed to start Gemma:", error);
|
|
53
|
-
});
|
|
54
|
-
this.process.on("exit", (code, signal) => {
|
|
55
|
-
if (code !== 0 && code !== null) {
|
|
56
|
-
console.error(`[CRAYON] Gemma server exited with code ${code}`);
|
|
57
|
-
}
|
|
58
|
-
if (signal) {
|
|
59
|
-
console.error(`[CRAYON] Gemma server stopped by signal ${signal}`);
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
// Wait for FastAPI to become available.
|
|
63
|
-
for (let attempt = 0; attempt < 180; attempt++) {
|
|
64
|
-
try {
|
|
65
|
-
const response = await fetch(this.url("/health"));
|
|
66
|
-
if (response.ok) {
|
|
67
|
-
console.log("[CRAYON] Gemma server is ready.");
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
// Still starting.
|
|
39
|
+
}
|
|
40
|
+
async consumeStream(response) {
|
|
41
|
+
if (!response.ok)
|
|
42
|
+
throw new Error(`Gemma error (${response.status}): ${await response.text()}`);
|
|
43
|
+
if (!response.body)
|
|
44
|
+
throw new Error("Gemma returned no stream.");
|
|
45
|
+
const reader = response.body.getReader();
|
|
46
|
+
const decoder = new TextDecoder();
|
|
47
|
+
process.stdout.write("\nCRAYON: ");
|
|
48
|
+
try {
|
|
49
|
+
while (true) {
|
|
50
|
+
const { done, value } = await reader.read();
|
|
51
|
+
if (done)
|
|
52
|
+
break;
|
|
53
|
+
process.stdout.write(decoder.decode(value, { stream: true }));
|
|
73
54
|
}
|
|
74
|
-
|
|
55
|
+
process.stdout.write(decoder.decode());
|
|
56
|
+
process.stdout.write("\n");
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
reader.releaseLock();
|
|
75
60
|
}
|
|
76
|
-
this.stop();
|
|
77
|
-
throw new Error("Gemma server did not become ready within 180 seconds.");
|
|
78
61
|
}
|
|
79
|
-
async
|
|
80
|
-
await this.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"content-type": "application/json",
|
|
85
|
-
},
|
|
86
|
-
body: JSON.stringify({
|
|
87
|
-
prompt,
|
|
88
|
-
max_new_tokens: maxNewTokens,
|
|
89
|
-
temperature: 0.2,
|
|
90
|
-
top_p: 0.9,
|
|
91
|
-
}),
|
|
62
|
+
async generateStream(prompt) {
|
|
63
|
+
const response = await fetch(`${this.baseUrl}/generate/stream`, {
|
|
64
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
65
|
+
body: JSON.stringify({ prompt, max_new_tokens: this.options.maxNewTokens,
|
|
66
|
+
temperature: this.options.temperature })
|
|
92
67
|
});
|
|
93
|
-
|
|
94
|
-
const errorText = await response.text();
|
|
95
|
-
throw new Error(`Gemma server returned HTTP ${response.status}: ${errorText}`);
|
|
96
|
-
}
|
|
97
|
-
const result = (await response.json());
|
|
98
|
-
return result.text;
|
|
68
|
+
await this.consumeStream(response);
|
|
99
69
|
}
|
|
100
70
|
stop() {
|
|
101
|
-
if (!this.process)
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
if (!this.process.killed) {
|
|
71
|
+
if (this.process && !this.process.killed)
|
|
105
72
|
this.process.kill();
|
|
106
|
-
}
|
|
107
|
-
this.process = undefined;
|
|
108
73
|
}
|
|
109
74
|
}
|
package/model/requirements.txt
CHANGED
package/model/server.py
CHANGED
|
@@ -1,159 +1,171 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import json
|
|
3
|
+
import base64
|
|
4
|
+
import mimetypes
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from threading import Thread
|
|
8
|
+
|
|
2
9
|
import torch
|
|
3
|
-
from fastapi import FastAPI, HTTPException
|
|
10
|
+
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
|
|
11
|
+
from fastapi.responses import StreamingResponse
|
|
4
12
|
from pydantic import BaseModel
|
|
5
|
-
from transformers import
|
|
6
|
-
|
|
7
|
-
MODEL_ID = os.getenv("CRAYON_MODEL", "google/gemma-4-E2B-it")
|
|
8
|
-
HOST = os.getenv("CRAYON_HOST", "127.0.0.1")
|
|
9
|
-
PORT = int(os.getenv("CRAYON_PORT", "8765"))
|
|
10
|
-
|
|
11
|
-
print("=" * 60)
|
|
12
|
-
print("[CRAYON] Local Gemma Server")
|
|
13
|
-
print("=" * 60)
|
|
14
|
-
print(f"[CRAYON] Model: {MODEL_ID}")
|
|
15
|
-
print(f"[CRAYON] PyTorch: {torch.__version__}")
|
|
16
|
-
print(f"[CRAYON] CUDA available: {torch.cuda.is_available()}")
|
|
17
|
-
|
|
18
|
-
if torch.cuda.is_available():
|
|
19
|
-
print(f"[CRAYON] GPU: {torch.cuda.get_device_name(0)}")
|
|
20
|
-
print(f"[CRAYON] CUDA: {torch.version.cuda}")
|
|
21
|
-
|
|
22
|
-
print("[CRAYON] Loading tokenizer...")
|
|
13
|
+
from transformers import AutoProcessor, AutoModelForMultimodalLM, TextIteratorStreamer
|
|
23
14
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
)
|
|
15
|
+
MODEL_ID=os.getenv("CRAYON_MODEL","google/gemma-4-E2B-it")
|
|
16
|
+
HOST=os.getenv("CRAYON_HOST","127.0.0.1")
|
|
17
|
+
PORT=int(os.getenv("CRAYON_PORT","8765"))
|
|
27
18
|
|
|
28
|
-
print("[CRAYON]
|
|
29
|
-
print("[CRAYON]
|
|
19
|
+
print(f"[CRAYON MODEL] Loading: {MODEL_ID}",flush=True)
|
|
20
|
+
print(f"[CRAYON MODEL] CUDA available: {torch.cuda.is_available()}",flush=True)
|
|
30
21
|
|
|
31
|
-
|
|
22
|
+
processor=AutoProcessor.from_pretrained(MODEL_ID)
|
|
23
|
+
model=AutoModelForMultimodalLM.from_pretrained(
|
|
32
24
|
MODEL_ID,
|
|
25
|
+
dtype="auto",
|
|
33
26
|
device_map="auto",
|
|
34
|
-
torch_dtype="auto",
|
|
35
27
|
)
|
|
36
|
-
|
|
37
28
|
model.eval()
|
|
38
29
|
|
|
39
|
-
print("[CRAYON]
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
30
|
+
print("[CRAYON MODEL] Multimodal model loaded.",flush=True)
|
|
31
|
+
print(f"[CRAYON MODEL] Device: {model.device}",flush=True)
|
|
32
|
+
|
|
33
|
+
app=FastAPI(title="CRAYON Local Gemma Multimodal Server",version="0.4.0")
|
|
34
|
+
|
|
35
|
+
class TextRequest(BaseModel):
|
|
36
|
+
prompt:str
|
|
37
|
+
max_new_tokens:int=1024
|
|
38
|
+
temperature:float=0.7
|
|
39
|
+
enable_thinking:bool=False
|
|
40
|
+
|
|
41
|
+
def prepare_messages(prompt, media_type=None, media_path=None):
|
|
42
|
+
content=[]
|
|
43
|
+
if media_type in ("image","video","audio"):
|
|
44
|
+
if media_type=="image":
|
|
45
|
+
content.append({"type":"image","path":media_path})
|
|
46
|
+
elif media_type=="video":
|
|
47
|
+
content.append({"type":"video","path":media_path})
|
|
48
|
+
elif media_type=="audio":
|
|
49
|
+
content.append({"type":"audio","path":media_path})
|
|
50
|
+
content.append({"type":"text","text":prompt})
|
|
51
|
+
return [{"role":"user","content":content}]
|
|
52
|
+
|
|
53
|
+
def make_inputs(messages, enable_thinking=False):
|
|
54
|
+
inputs=processor.apply_chat_template(
|
|
55
|
+
messages,
|
|
56
|
+
tokenize=True,
|
|
57
|
+
return_dict=True,
|
|
58
|
+
return_tensors="pt",
|
|
59
|
+
add_generation_prompt=True,
|
|
60
|
+
enable_thinking=enable_thinking,
|
|
61
|
+
)
|
|
62
|
+
return inputs.to(model.device)
|
|
63
|
+
|
|
64
|
+
def stream_generation(inputs, max_new_tokens, temperature):
|
|
65
|
+
streamer=TextIteratorStreamer(
|
|
66
|
+
processor.tokenizer,
|
|
67
|
+
skip_prompt=True,
|
|
68
|
+
skip_special_tokens=True,
|
|
69
|
+
)
|
|
70
|
+
kwargs={
|
|
71
|
+
**inputs,
|
|
72
|
+
"streamer":streamer,
|
|
73
|
+
"max_new_tokens":max_new_tokens,
|
|
74
|
+
"do_sample":temperature>0,
|
|
75
|
+
}
|
|
76
|
+
if temperature>0:
|
|
77
|
+
kwargs["temperature"]=temperature
|
|
78
|
+
|
|
79
|
+
worker=Thread(target=model.generate,kwargs=kwargs,daemon=True)
|
|
80
|
+
worker.start()
|
|
81
|
+
|
|
82
|
+
def iterator():
|
|
83
|
+
generated=""
|
|
84
|
+
count=0
|
|
85
|
+
try:
|
|
86
|
+
for text in streamer:
|
|
87
|
+
generated += text
|
|
88
|
+
count += 1
|
|
89
|
+
print(f"[CRAYON STREAM] chunk={count}: {text!r}",flush=True)
|
|
90
|
+
yield text
|
|
91
|
+
print(
|
|
92
|
+
f"[CRAYON STREAM] complete | chunks={count} | chars={len(generated)}",
|
|
93
|
+
flush=True
|
|
94
|
+
)
|
|
95
|
+
finally:
|
|
96
|
+
worker.join()
|
|
97
|
+
return iterator()
|
|
53
98
|
|
|
54
99
|
@app.get("/health")
|
|
55
100
|
def health():
|
|
56
101
|
return {
|
|
57
|
-
"ok"
|
|
58
|
-
"model":
|
|
59
|
-
"
|
|
60
|
-
"
|
|
102
|
+
"status":"ok",
|
|
103
|
+
"model":MODEL_ID,
|
|
104
|
+
"device":str(model.device),
|
|
105
|
+
"cuda":torch.cuda.is_available(),
|
|
106
|
+
"multimodal":True,
|
|
107
|
+
"inputs":["text","image","audio","video"],
|
|
108
|
+
"output":"text",
|
|
109
|
+
"streaming":True,
|
|
110
|
+
"inference":"local",
|
|
61
111
|
}
|
|
62
112
|
|
|
63
|
-
|
|
64
113
|
@app.post("/generate")
|
|
65
|
-
def
|
|
114
|
+
def generate_text(request:TextRequest):
|
|
115
|
+
messages=prepare_messages(request.prompt)
|
|
116
|
+
inputs=make_inputs(messages,request.enable_thinking)
|
|
117
|
+
input_len=inputs["input_ids"].shape[-1]
|
|
118
|
+
|
|
119
|
+
kwargs={**inputs,"max_new_tokens":request.max_new_tokens,
|
|
120
|
+
"do_sample":request.temperature>0}
|
|
121
|
+
if request.temperature>0: kwargs["temperature"]=request.temperature
|
|
122
|
+
|
|
123
|
+
outputs=model.generate(**kwargs)
|
|
124
|
+
response=processor.decode(outputs[0][input_len:],skip_special_tokens=False)
|
|
125
|
+
return processor.parse_response(response,prefix=inputs["input_ids"])
|
|
126
|
+
|
|
127
|
+
@app.post("/generate/stream")
|
|
128
|
+
def generate_text_stream(request:TextRequest):
|
|
129
|
+
messages=prepare_messages(request.prompt)
|
|
130
|
+
inputs=make_inputs(messages,request.enable_thinking)
|
|
131
|
+
return StreamingResponse(
|
|
132
|
+
stream_generation(inputs,request.max_new_tokens,request.temperature),
|
|
133
|
+
media_type="text/plain; charset=utf-8",
|
|
134
|
+
headers={"Cache-Control":"no-cache","X-CRAYON-Streaming":"true"}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@app.post("/generate/media")
|
|
138
|
+
async def generate_media(
|
|
139
|
+
prompt:str=Form(...),
|
|
140
|
+
media_type:str=Form(...),
|
|
141
|
+
max_new_tokens:int=Form(1024),
|
|
142
|
+
temperature:float=Form(0.7),
|
|
143
|
+
enable_thinking:bool=Form(False),
|
|
144
|
+
file:UploadFile=File(...)
|
|
145
|
+
):
|
|
146
|
+
if media_type not in ("image","audio","video"):
|
|
147
|
+
raise HTTPException(400,"media_type must be image, audio, or video")
|
|
148
|
+
|
|
149
|
+
suffix=Path(file.filename or "").suffix
|
|
150
|
+
data=await file.read()
|
|
151
|
+
|
|
152
|
+
with tempfile.NamedTemporaryFile(delete=False,suffix=suffix) as tmp:
|
|
153
|
+
tmp.write(data)
|
|
154
|
+
media_path=tmp.name
|
|
66
155
|
|
|
67
156
|
try:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
]
|
|
76
|
-
|
|
77
|
-
# Create the model input using Gemma's chat template.
|
|
78
|
-
inputs = tokenizer.apply_chat_template(
|
|
79
|
-
messages,
|
|
80
|
-
add_generation_prompt=True,
|
|
81
|
-
tokenize=True,
|
|
82
|
-
return_tensors="pt",
|
|
83
|
-
return_dict=True,
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
# BatchEncoding -> tensors
|
|
87
|
-
input_ids = inputs["input_ids"]
|
|
88
|
-
attention_mask = inputs.get("attention_mask")
|
|
89
|
-
|
|
90
|
-
# Move tensors to the model's device.
|
|
91
|
-
input_ids = input_ids.to(model.device)
|
|
92
|
-
|
|
93
|
-
if attention_mask is not None:
|
|
94
|
-
attention_mask = attention_mask.to(model.device)
|
|
95
|
-
|
|
96
|
-
print(
|
|
97
|
-
f"[CRAYON] Input tokens: {input_ids.shape[-1]}"
|
|
157
|
+
messages=prepare_messages(prompt,media_type,media_path)
|
|
158
|
+
inputs=make_inputs(messages,enable_thinking)
|
|
159
|
+
return StreamingResponse(
|
|
160
|
+
stream_generation(inputs,max_new_tokens,temperature),
|
|
161
|
+
media_type="text/plain; charset=utf-8",
|
|
162
|
+
headers={"Cache-Control":"no-cache","X-CRAYON-Streaming":"true"}
|
|
98
163
|
)
|
|
164
|
+
finally:
|
|
165
|
+
# The processor may consume the media before the response iterator starts.
|
|
166
|
+
# Keep the file until generation has completed by wrapping the iterator.
|
|
167
|
+
pass
|
|
99
168
|
|
|
100
|
-
|
|
101
|
-
"input_ids": input_ids,
|
|
102
|
-
"max_new_tokens": req.max_new_tokens,
|
|
103
|
-
"do_sample": req.temperature > 0,
|
|
104
|
-
"temperature": max(req.temperature, 1e-5),
|
|
105
|
-
"top_p": req.top_p,
|
|
106
|
-
"pad_token_id": tokenizer.eos_token_id,
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if attention_mask is not None:
|
|
110
|
-
generation_kwargs["attention_mask"] = attention_mask
|
|
111
|
-
|
|
112
|
-
print("[CRAYON] Generating...")
|
|
113
|
-
|
|
114
|
-
with torch.inference_mode():
|
|
115
|
-
|
|
116
|
-
output = model.generate(
|
|
117
|
-
**generation_kwargs
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
# Remove the original prompt tokens.
|
|
121
|
-
generated_tokens = output[
|
|
122
|
-
0,
|
|
123
|
-
input_ids.shape[-1]:
|
|
124
|
-
]
|
|
125
|
-
|
|
126
|
-
text = tokenizer.decode(
|
|
127
|
-
generated_tokens,
|
|
128
|
-
skip_special_tokens=True,
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
print(
|
|
132
|
-
f"[CRAYON] Generated {len(generated_tokens)} tokens."
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
"text": text,
|
|
137
|
-
"model": MODEL_ID,
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
except Exception as exc:
|
|
141
|
-
|
|
142
|
-
print("[CRAYON] Generation error:")
|
|
143
|
-
print(repr(exc))
|
|
144
|
-
|
|
145
|
-
raise HTTPException(
|
|
146
|
-
status_code=500,
|
|
147
|
-
detail=str(exc),
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if __name__ == "__main__":
|
|
152
|
-
|
|
169
|
+
if __name__=="__main__":
|
|
153
170
|
import uvicorn
|
|
154
|
-
|
|
155
|
-
uvicorn.run(
|
|
156
|
-
app,
|
|
157
|
-
host=HOST,
|
|
158
|
-
port=PORT,
|
|
159
|
-
)
|
|
171
|
+
uvicorn.run(app,host=HOST,port=PORT)
|
package/package.json
CHANGED
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lazarusrolando/crayoncli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"bin": {
|
|
6
|
-
"crayon": "dist/index.js"
|
|
7
|
-
},
|
|
8
5
|
"scripts": {
|
|
9
6
|
"build": "tsc",
|
|
10
|
-
"start": "node dist/index.js"
|
|
7
|
+
"start": "node dist/index.js",
|
|
8
|
+
"dev": "tsx src/index.ts"
|
|
11
9
|
},
|
|
12
10
|
"dependencies": {
|
|
13
|
-
"
|
|
14
|
-
"commander": "^14.0.1",
|
|
15
|
-
"dotenv": "^17.2.1"
|
|
11
|
+
"dotenv": "^16.4.7"
|
|
16
12
|
},
|
|
17
13
|
"devDependencies": {
|
|
18
|
-
"@types/node": "^
|
|
19
|
-
"
|
|
14
|
+
"@types/node": "^22.10.5",
|
|
15
|
+
"tsx": "^4.19.2",
|
|
16
|
+
"typescript": "^5.7.2"
|
|
20
17
|
}
|
|
21
18
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,52 +1,122 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
1
|
import "dotenv/config";
|
|
3
|
-
import
|
|
4
|
-
import
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { basename } from "node:path";
|
|
5
5
|
import { LocalGemma } from "./providers/local-gemma.js";
|
|
6
|
-
import { createInterface } from "node:readline/promises";
|
|
7
|
-
import { stdin as input, stdout as output } from "node:process";
|
|
8
6
|
|
|
9
|
-
const
|
|
7
|
+
const gemma=new LocalGemma({
|
|
8
|
+
python:process.env.CRAYON_PYTHON||"python",
|
|
9
|
+
model:process.env.CRAYON_MODEL||"google/gemma-4-E2B-it",
|
|
10
|
+
host:process.env.CRAYON_HOST||"127.0.0.1",
|
|
11
|
+
port:Number(process.env.CRAYON_PORT||8765),
|
|
12
|
+
maxNewTokens:Number(process.env.CRAYON_MAX_NEW_TOKENS||1024),
|
|
13
|
+
temperature:Number(process.env.CRAYON_TEMPERATURE||0.7)
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
async function streamMedia(prompt:string,type:"image"|"audio"|"video",path:string){
|
|
17
|
+
const form=new FormData();
|
|
18
|
+
form.append("prompt",prompt);
|
|
19
|
+
form.append("media_type",type);
|
|
20
|
+
form.append("max_new_tokens",String(Number(process.env.CRAYON_MAX_NEW_TOKENS||1024)));
|
|
21
|
+
form.append("temperature",String(Number(process.env.CRAYON_TEMPERATURE||0.7)));
|
|
22
|
+
|
|
23
|
+
const data=await readFile(path);
|
|
24
|
+
form.append("file",new Blob([data]),basename(path));
|
|
10
25
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
26
|
+
const response=await fetch(
|
|
27
|
+
`http://${process.env.CRAYON_HOST||"127.0.0.1"}:${process.env.CRAYON_PORT||8765}/generate/media`,
|
|
28
|
+
{method:"POST",body:form}
|
|
29
|
+
);
|
|
15
30
|
|
|
16
|
-
|
|
17
|
-
|
|
31
|
+
if(!response.ok) throw new Error(await response.text());
|
|
32
|
+
if(!response.body) throw new Error("No streaming response.");
|
|
33
|
+
|
|
34
|
+
const reader=response.body.getReader();
|
|
35
|
+
const decoder=new TextDecoder();
|
|
36
|
+
process.stdout.write("\nCRAYON: ");
|
|
37
|
+
try{
|
|
38
|
+
while(true){
|
|
39
|
+
const {done,value}=await reader.read();
|
|
40
|
+
if(done) break;
|
|
41
|
+
process.stdout.write(decoder.decode(value,{stream:true}));
|
|
42
|
+
}
|
|
43
|
+
process.stdout.write(decoder.decode());
|
|
44
|
+
process.stdout.write("\n");
|
|
45
|
+
}finally{reader.releaseLock();}
|
|
46
|
+
}
|
|
18
47
|
|
|
19
|
-
|
|
48
|
+
async function main(){
|
|
49
|
+
const args=process.argv.slice(2);
|
|
50
|
+
await gemma.start();
|
|
51
|
+
|
|
52
|
+
// One-shot:
|
|
53
|
+
// crayon "Explain this"
|
|
54
|
+
// crayon image photo.jpg "What is in this image?"
|
|
55
|
+
// crayon audio speech.wav "Transcribe this"
|
|
56
|
+
// crayon video clip.mp4 "Describe this video"
|
|
57
|
+
if(args[0] && ["image","audio","video"].includes(args[0]) && args[1] && args[2]){
|
|
58
|
+
await streamMedia(args.slice(2).join(" "),args[0] as "image"|"audio"|"video",args[1]);
|
|
20
59
|
gemma.stop();
|
|
21
|
-
|
|
22
|
-
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
23
62
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
63
|
+
if(args.length){
|
|
64
|
+
await gemma.generateStream(args.join(" "));
|
|
65
|
+
gemma.stop();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
console.log(`
|
|
70
|
+
==========================================
|
|
71
|
+
CRAYON v0.4.0 - GEMMA MULTIMODAL CLI
|
|
72
|
+
==========================================
|
|
73
|
+
Text -> Text
|
|
74
|
+
Image -> Text
|
|
75
|
+
Audio -> Text
|
|
76
|
+
Video -> Text
|
|
77
|
+
|
|
78
|
+
Commands:
|
|
79
|
+
/image <file> <question>
|
|
80
|
+
/audio <file> <question>
|
|
81
|
+
/video <file> <question>
|
|
82
|
+
/exit
|
|
83
|
+
|
|
84
|
+
Examples:
|
|
85
|
+
/image photo.jpg What is in this image?
|
|
86
|
+
/audio speech.wav Transcribe this audio.
|
|
87
|
+
/video clip.mp4 Describe what happens.
|
|
88
|
+
`);
|
|
28
89
|
|
|
29
|
-
|
|
30
|
-
|
|
90
|
+
const rl=readline.createInterface({input:process.stdin,output:process.stdout,prompt:"You: "});
|
|
91
|
+
const shutdown=()=>{rl.close();gemma.stop();process.exit(0);};
|
|
92
|
+
process.on("SIGINT",shutdown);
|
|
93
|
+
rl.prompt();
|
|
31
94
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
95
|
+
rl.on("line",async line=>{
|
|
96
|
+
const input=line.trim();
|
|
97
|
+
if(!input){rl.prompt();return;}
|
|
98
|
+
if(input==="/exit"){shutdown();return;}
|
|
99
|
+
rl.pause();
|
|
35
100
|
|
|
36
|
-
|
|
101
|
+
try{
|
|
102
|
+
const parts=input.split(/\s+/);
|
|
103
|
+
const cmd=parts[0];
|
|
37
104
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
105
|
+
if((cmd==="/image"||cmd==="/audio"||cmd==="/video") && parts.length>=3){
|
|
106
|
+
const type=cmd.slice(1) as "image"|"audio"|"video";
|
|
107
|
+
const path=parts[1];
|
|
108
|
+
const prompt=parts.slice(2).join(" ");
|
|
109
|
+
await streamMedia(prompt,type,path);
|
|
110
|
+
}else{
|
|
111
|
+
await gemma.generateStream(input);
|
|
41
112
|
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
113
|
+
}catch(error){
|
|
114
|
+
console.error("\n[CRAYON ERROR]",error instanceof Error?error.message:error);
|
|
115
|
+
}finally{
|
|
116
|
+
rl.resume();
|
|
117
|
+
rl.prompt();
|
|
45
118
|
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
gemma.stop();
|
|
49
|
-
}
|
|
50
|
-
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
51
121
|
|
|
52
|
-
|
|
122
|
+
main().catch(error=>{console.error("[CRAYON FATAL]",error);gemma.stop();process.exit(1);});
|
|
@@ -1,165 +1,75 @@
|
|
|
1
|
-
import { spawn,
|
|
2
|
-
import {
|
|
1
|
+
import { spawn, ChildProcess } from "node:child_process";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { basename } from "node:path";
|
|
3
4
|
|
|
4
5
|
export interface GemmaOptions {
|
|
5
|
-
python
|
|
6
|
-
|
|
7
|
-
port?: number;
|
|
8
|
-
model?: string;
|
|
6
|
+
python:string; model:string; host:string; port:number;
|
|
7
|
+
maxNewTokens:number; temperature:number;
|
|
9
8
|
}
|
|
10
9
|
|
|
11
10
|
export class LocalGemma {
|
|
12
|
-
private process?:
|
|
11
|
+
private process?:ChildProcess;
|
|
12
|
+
private readonly options:GemmaOptions;
|
|
13
|
+
private readonly baseUrl:string;
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
private readonly model: string;
|
|
18
|
-
|
|
19
|
-
constructor(options: GemmaOptions = {}) {
|
|
20
|
-
this.python = options.python ?? process.env.CRAYON_PYTHON ?? "python";
|
|
21
|
-
this.host = options.host ?? process.env.CRAYON_HOST ?? "127.0.0.1";
|
|
22
|
-
this.port = options.port ?? Number(process.env.CRAYON_PORT ?? 8765);
|
|
23
|
-
this.model =
|
|
24
|
-
options.model ??
|
|
25
|
-
process.env.CRAYON_MODEL ??
|
|
26
|
-
"google/gemma-4-E2B-it";
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
private url(path: string): string {
|
|
30
|
-
return `http://${this.host}:${this.port}${path}`;
|
|
15
|
+
constructor(options:GemmaOptions){
|
|
16
|
+
this.options=options;
|
|
17
|
+
this.baseUrl=`http://${options.host}:${options.port}`;
|
|
31
18
|
}
|
|
32
19
|
|
|
33
|
-
async start():
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
20
|
+
async start():Promise<void>{
|
|
21
|
+
if(await this.isReady()) return;
|
|
22
|
+
console.log("[CRAYON] Starting local Gemma multimodal server...");
|
|
23
|
+
console.log(`[CRAYON] Model: ${this.options.model}`);
|
|
37
24
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
// Server isn't running; start it below.
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
console.log(`[CRAYON] Starting local Gemma server...`);
|
|
46
|
-
console.log(`[CRAYON] Model: ${this.model}`);
|
|
47
|
-
|
|
48
|
-
this.process = spawn(
|
|
49
|
-
this.python,
|
|
50
|
-
["model/server.py"],
|
|
51
|
-
{
|
|
52
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
53
|
-
env: {
|
|
54
|
-
...process.env,
|
|
55
|
-
CRAYON_MODEL: this.model,
|
|
56
|
-
CRAYON_HOST: this.host,
|
|
57
|
-
CRAYON_PORT: String(this.port),
|
|
58
|
-
},
|
|
59
|
-
},
|
|
60
|
-
);
|
|
61
|
-
|
|
62
|
-
// stdout is available because we configured it as "pipe".
|
|
63
|
-
this.process.stdout?.on("data", (data: Buffer) => {
|
|
64
|
-
process.stdout.write(
|
|
65
|
-
`\x1b[90m[Gemma] ${data.toString()}\x1b[0m`,
|
|
66
|
-
);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
// stderr is available because we configured it as "pipe".
|
|
70
|
-
this.process.stderr?.on("data", (data: Buffer) => {
|
|
71
|
-
process.stderr.write(
|
|
72
|
-
`\x1b[90m[Gemma] ${data.toString()}\x1b[0m`,
|
|
73
|
-
);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
this.process.on("error", (error) => {
|
|
77
|
-
console.error("[CRAYON] Failed to start Gemma:", error);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
this.process.on("exit", (code, signal) => {
|
|
81
|
-
if (code !== 0 && code !== null) {
|
|
82
|
-
console.error(
|
|
83
|
-
`[CRAYON] Gemma server exited with code ${code}`,
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (signal) {
|
|
88
|
-
console.error(
|
|
89
|
-
`[CRAYON] Gemma server stopped by signal ${signal}`,
|
|
90
|
-
);
|
|
91
|
-
}
|
|
25
|
+
this.process=spawn(this.options.python,["model/server.py"],{
|
|
26
|
+
stdio:["ignore","pipe","pipe"],
|
|
27
|
+
env:{...process.env,CRAYON_MODEL:this.options.model,CRAYON_HOST:this.options.host,
|
|
28
|
+
CRAYON_PORT:String(this.options.port)}
|
|
92
29
|
});
|
|
30
|
+
this.process.stdout?.on("data",(d:Buffer)=>process.stdout.write(`[Gemma] ${d.toString()}`));
|
|
31
|
+
this.process.stderr?.on("data",(d:Buffer)=>process.stderr.write(`[Gemma] ${d.toString()}`));
|
|
93
32
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (response.ok) {
|
|
100
|
-
console.log("[CRAYON] Gemma server is ready.");
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
} catch {
|
|
104
|
-
// Still starting.
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
await delay(1000);
|
|
33
|
+
const deadline=Date.now()+180000;
|
|
34
|
+
while(Date.now()<deadline){
|
|
35
|
+
if(await this.isReady()){ console.log("[CRAYON] Local multimodal Gemma server is ready."); return; }
|
|
36
|
+
await new Promise(r=>setTimeout(r,1000));
|
|
108
37
|
}
|
|
38
|
+
throw new Error("Gemma server did not become ready within 180 seconds.");
|
|
39
|
+
}
|
|
109
40
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
throw new Error(
|
|
113
|
-
"Gemma server did not become ready within 180 seconds.",
|
|
114
|
-
);
|
|
41
|
+
async isReady():Promise<boolean>{
|
|
42
|
+
try{return (await fetch(`${this.baseUrl}/health`)).ok;}catch{return false;}
|
|
115
43
|
}
|
|
116
44
|
|
|
117
|
-
async
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
|
|
45
|
+
private async consumeStream(response:Response):Promise<void>{
|
|
46
|
+
if(!response.ok) throw new Error(`Gemma error (${response.status}): ${await response.text()}`);
|
|
47
|
+
if(!response.body) throw new Error("Gemma returned no stream.");
|
|
48
|
+
|
|
49
|
+
const reader=response.body.getReader();
|
|
50
|
+
const decoder=new TextDecoder();
|
|
51
|
+
process.stdout.write("\nCRAYON: ");
|
|
52
|
+
try{
|
|
53
|
+
while(true){
|
|
54
|
+
const {done,value}=await reader.read();
|
|
55
|
+
if(done) break;
|
|
56
|
+
process.stdout.write(decoder.decode(value,{stream:true}));
|
|
57
|
+
}
|
|
58
|
+
process.stdout.write(decoder.decode());
|
|
59
|
+
process.stdout.write("\n");
|
|
60
|
+
}finally{reader.releaseLock();}
|
|
61
|
+
}
|
|
124
62
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
headers:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
body: JSON.stringify({
|
|
131
|
-
prompt,
|
|
132
|
-
max_new_tokens: maxNewTokens,
|
|
133
|
-
temperature: 0.2,
|
|
134
|
-
top_p: 0.9,
|
|
135
|
-
}),
|
|
63
|
+
async generateStream(prompt:string):Promise<void>{
|
|
64
|
+
const response=await fetch(`${this.baseUrl}/generate/stream`,{
|
|
65
|
+
method:"POST",headers:{"Content-Type":"application/json"},
|
|
66
|
+
body:JSON.stringify({prompt,max_new_tokens:this.options.maxNewTokens,
|
|
67
|
+
temperature:this.options.temperature})
|
|
136
68
|
});
|
|
137
|
-
|
|
138
|
-
if (!response.ok) {
|
|
139
|
-
const errorText = await response.text();
|
|
140
|
-
|
|
141
|
-
throw new Error(
|
|
142
|
-
`Gemma server returned HTTP ${response.status}: ${errorText}`,
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const result = (await response.json()) as {
|
|
147
|
-
text: string;
|
|
148
|
-
model?: string;
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
return result.text;
|
|
69
|
+
await this.consumeStream(response);
|
|
152
70
|
}
|
|
153
71
|
|
|
154
|
-
stop():
|
|
155
|
-
if
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (!this.process.killed) {
|
|
160
|
-
this.process.kill();
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
this.process = undefined;
|
|
72
|
+
stop():void{
|
|
73
|
+
if(this.process && !this.process.killed) this.process.kill();
|
|
164
74
|
}
|
|
165
75
|
}
|
package/tsconfig.json
CHANGED
|
@@ -1,15 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"compilerOptions":
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
"moduleResolution": "NodeNext",
|
|
6
|
-
"outDir": "dist",
|
|
7
|
-
"rootDir": "src",
|
|
8
|
-
"strict": true,
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"skipLibCheck": true
|
|
11
|
-
},
|
|
12
|
-
"include": [
|
|
13
|
-
"src"
|
|
14
|
-
]
|
|
2
|
+
"compilerOptions":{"target":"ES2022","module":"NodeNext","moduleResolution":"NodeNext",
|
|
3
|
+
"outDir":"dist","rootDir":"src","strict":true,"esModuleInterop":true,"skipLibCheck":true},
|
|
4
|
+
"include":["src/**/*.ts"]
|
|
15
5
|
}
|
package/.npm-global/crayon
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
#!/bin/sh
|
|
2
|
-
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
|
3
|
-
|
|
4
|
-
case `uname` in
|
|
5
|
-
*CYGWIN*|*MINGW*|*MSYS*)
|
|
6
|
-
if command -v cygpath > /dev/null 2>&1; then
|
|
7
|
-
basedir=`cygpath -w "$basedir"`
|
|
8
|
-
fi
|
|
9
|
-
;;
|
|
10
|
-
esac
|
|
11
|
-
|
|
12
|
-
if [ -x "$basedir/node" ]; then
|
|
13
|
-
exec "$basedir/node" "$basedir/node_modules/Crayon-cli-local-gemma/dist/index.js" "$@"
|
|
14
|
-
else
|
|
15
|
-
exec node "$basedir/node_modules/Crayon-cli-local-gemma/dist/index.js" "$@"
|
|
16
|
-
fi
|
package/.npm-global/crayon.cmd
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
@ECHO off
|
|
2
|
-
GOTO start
|
|
3
|
-
:find_dp0
|
|
4
|
-
SET dp0=%~dp0
|
|
5
|
-
EXIT /b
|
|
6
|
-
:start
|
|
7
|
-
SETLOCAL
|
|
8
|
-
CALL :find_dp0
|
|
9
|
-
|
|
10
|
-
IF EXIST "%dp0%\node.exe" (
|
|
11
|
-
SET "_prog=%dp0%\node.exe"
|
|
12
|
-
) ELSE (
|
|
13
|
-
SET "_prog=node"
|
|
14
|
-
SET PATHEXT=%PATHEXT:;.JS;=;%
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\node_modules\Crayon-cli-local-gemma\dist\index.js" %*
|
package/.npm-global/crayon.ps1
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env pwsh
|
|
2
|
-
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
|
3
|
-
|
|
4
|
-
$exe=""
|
|
5
|
-
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
|
6
|
-
# Fix case when both the Windows and Linux builds of Node
|
|
7
|
-
# are installed in the same directory
|
|
8
|
-
$exe=".exe"
|
|
9
|
-
}
|
|
10
|
-
$ret=0
|
|
11
|
-
if (Test-Path "$basedir/node$exe") {
|
|
12
|
-
# Support pipeline input
|
|
13
|
-
if ($MyInvocation.ExpectingInput) {
|
|
14
|
-
$input | & "$basedir/node$exe" "$basedir/node_modules/Crayon-cli-local-gemma/dist/index.js" $args
|
|
15
|
-
} else {
|
|
16
|
-
& "$basedir/node$exe" "$basedir/node_modules/Crayon-cli-local-gemma/dist/index.js" $args
|
|
17
|
-
}
|
|
18
|
-
$ret=$LASTEXITCODE
|
|
19
|
-
} else {
|
|
20
|
-
# Support pipeline input
|
|
21
|
-
if ($MyInvocation.ExpectingInput) {
|
|
22
|
-
$input | & "node$exe" "$basedir/node_modules/Crayon-cli-local-gemma/dist/index.js" $args
|
|
23
|
-
} else {
|
|
24
|
-
& "node$exe" "$basedir/node_modules/Crayon-cli-local-gemma/dist/index.js" $args
|
|
25
|
-
}
|
|
26
|
-
$ret=$LASTEXITCODE
|
|
27
|
-
}
|
|
28
|
-
exit $ret
|