@lazarusrolando/crayoncli 0.3.0 → 0.4.1
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/README.md +76 -15
- package/dist/index.js +120 -14
- package/dist/providers/local-gemma.js +10 -15
- package/model/requirements.txt +3 -0
- package/model/server.py +132 -74
- package/package.json +1 -1
- package/src/index.ts +138 -15
- package/src/providers/local-gemma.ts +13 -16
- package/tsconfig.json +5 -1
package/README.md
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
# CRAYON v0.
|
|
1
|
+
# CRAYON v0.4.1 — 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
|
-
##
|
|
5
|
+
## Supported
|
|
6
6
|
|
|
7
|
-
|
|
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
|
|
8
13
|
|
|
9
|
-
|
|
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
|
|
|
@@ -17,24 +22,80 @@ Copy-Item .env.example .env
|
|
|
17
22
|
npm run build
|
|
18
23
|
```
|
|
19
24
|
|
|
20
|
-
|
|
25
|
+
Use a recent Transformers version. The model card currently recommends the latest Transformers.
|
|
26
|
+
|
|
27
|
+
## CLI
|
|
28
|
+
|
|
29
|
+
Text:
|
|
30
|
+
|
|
31
|
+
```powershell
|
|
32
|
+
node dist/index.js "Explain recursion"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Image:
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
node dist/index.js image photo.jpg "What is in this image?"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Audio:
|
|
42
|
+
|
|
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."
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Interactive:
|
|
21
54
|
|
|
22
55
|
```powershell
|
|
23
56
|
node dist/index.js
|
|
24
57
|
```
|
|
25
58
|
|
|
26
|
-
|
|
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.
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
## Help
|
|
82
|
+
|
|
83
|
+
Show help before starting the model:
|
|
84
|
+
|
|
85
|
+
```powershell
|
|
86
|
+
node dist/index.js --help
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Short form:
|
|
27
90
|
|
|
28
91
|
```powershell
|
|
29
|
-
node dist/index.js
|
|
92
|
+
node dist/index.js -h
|
|
30
93
|
```
|
|
31
94
|
|
|
32
|
-
|
|
95
|
+
Inside interactive mode:
|
|
33
96
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
- Node.js `ReadableStream` consumption
|
|
38
|
-
- keeps `/generate` for non-streaming compatibility
|
|
97
|
+
```text
|
|
98
|
+
/help
|
|
99
|
+
```
|
|
39
100
|
|
|
40
|
-
|
|
101
|
+
The help screen lists text, image, audio, video, exit, and one-shot CLI commands.
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import "dotenv/config";
|
|
2
2
|
import readline from "node:readline";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { basename } from "node:path";
|
|
3
5
|
import { LocalGemma } from "./providers/local-gemma.js";
|
|
4
6
|
const gemma = new LocalGemma({
|
|
5
7
|
python: process.env.CRAYON_PYTHON || "python",
|
|
@@ -9,38 +11,142 @@ const gemma = new LocalGemma({
|
|
|
9
11
|
maxNewTokens: Number(process.env.CRAYON_MAX_NEW_TOKENS || 1024),
|
|
10
12
|
temperature: Number(process.env.CRAYON_TEMPERATURE || 0.7)
|
|
11
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
|
+
function printHelp() {
|
|
45
|
+
console.log(`
|
|
46
|
+
CRAYON v0.4.1 - Gemma 4 E2B Multimodal CLI
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
crayon [prompt]
|
|
50
|
+
crayon image <file> <question>
|
|
51
|
+
crayon audio <file> <question>
|
|
52
|
+
crayon video <file> <question>
|
|
53
|
+
crayon --help
|
|
54
|
+
crayon -h
|
|
55
|
+
|
|
56
|
+
Commands:
|
|
57
|
+
/help Show available interactive commands
|
|
58
|
+
/image <file> <question> Analyze an image
|
|
59
|
+
/audio <file> <question> Analyze/transcribe audio
|
|
60
|
+
/video <file> <question> Analyze a video
|
|
61
|
+
/exit Exit CRAYON
|
|
62
|
+
|
|
63
|
+
CLI commands:
|
|
64
|
+
image <file> <question> One-shot image analysis
|
|
65
|
+
audio <file> <question> One-shot audio analysis
|
|
66
|
+
video <file> <question> One-shot video analysis
|
|
67
|
+
--help, -h Show this help
|
|
68
|
+
|
|
69
|
+
Examples:
|
|
70
|
+
crayon \"Explain recursion in JavaScript\"
|
|
71
|
+
crayon image photo.jpg \"What is in this image?\"
|
|
72
|
+
crayon audio speech.wav \"Transcribe this audio.\"
|
|
73
|
+
crayon video clip.mp4 \"Describe what happens in this video.\"
|
|
74
|
+
`);
|
|
75
|
+
}
|
|
12
76
|
async function main() {
|
|
13
|
-
const
|
|
77
|
+
const args = process.argv.slice(2);
|
|
78
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
79
|
+
printHelp();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
14
82
|
await gemma.start();
|
|
15
|
-
|
|
16
|
-
|
|
83
|
+
// One-shot:
|
|
84
|
+
// crayon "Explain this"
|
|
85
|
+
// crayon image photo.jpg "What is in this image?"
|
|
86
|
+
// crayon audio speech.wav "Transcribe this"
|
|
87
|
+
// crayon video clip.mp4 "Describe this video"
|
|
88
|
+
if (args[0] && ["image", "audio", "video"].includes(args[0]) && args[1] && args[2]) {
|
|
89
|
+
await streamMedia(args.slice(2).join(" "), args[0], args[1]);
|
|
17
90
|
gemma.stop();
|
|
18
91
|
return;
|
|
19
92
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
console.log(
|
|
26
|
-
|
|
93
|
+
if (args.length) {
|
|
94
|
+
await gemma.generateStream(args.join(" "));
|
|
95
|
+
gemma.stop();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
console.log(`
|
|
99
|
+
==========================================
|
|
100
|
+
CRAYON v0.4.0 - GEMMA MULTIMODAL CLI
|
|
101
|
+
==========================================
|
|
102
|
+
Text -> Text
|
|
103
|
+
Image -> Text
|
|
104
|
+
Audio -> Text
|
|
105
|
+
Video -> Text
|
|
106
|
+
|
|
107
|
+
Commands:
|
|
108
|
+
/image <file> <question>
|
|
109
|
+
/audio <file> <question>
|
|
110
|
+
/video <file> <question>
|
|
111
|
+
/exit
|
|
112
|
+
|
|
113
|
+
Examples:
|
|
114
|
+
/image photo.jpg What is in this image?
|
|
115
|
+
/audio speech.wav Transcribe this audio.
|
|
116
|
+
/video clip.mp4 Describe what happens.
|
|
117
|
+
`);
|
|
27
118
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "You: " });
|
|
28
119
|
const shutdown = () => { rl.close(); gemma.stop(); process.exit(0); };
|
|
29
120
|
process.on("SIGINT", shutdown);
|
|
30
121
|
rl.prompt();
|
|
31
122
|
rl.on("line", async (line) => {
|
|
32
|
-
const
|
|
33
|
-
if (!
|
|
123
|
+
const input = line.trim();
|
|
124
|
+
if (!input) {
|
|
34
125
|
rl.prompt();
|
|
35
126
|
return;
|
|
36
127
|
}
|
|
37
|
-
if (
|
|
128
|
+
if (input === "/exit") {
|
|
38
129
|
shutdown();
|
|
39
130
|
return;
|
|
40
131
|
}
|
|
132
|
+
if (input === "/help" || input === "help" || input === "?") {
|
|
133
|
+
printHelp();
|
|
134
|
+
rl.prompt();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
41
137
|
rl.pause();
|
|
42
138
|
try {
|
|
43
|
-
|
|
139
|
+
const parts = input.split(/\s+/);
|
|
140
|
+
const cmd = parts[0];
|
|
141
|
+
if ((cmd === "/image" || cmd === "/audio" || cmd === "/video") && parts.length >= 3) {
|
|
142
|
+
const type = cmd.slice(1);
|
|
143
|
+
const path = parts[1];
|
|
144
|
+
const prompt = parts.slice(2).join(" ");
|
|
145
|
+
await streamMedia(prompt, type, path);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
await gemma.generateStream(input);
|
|
149
|
+
}
|
|
44
150
|
}
|
|
45
151
|
catch (error) {
|
|
46
152
|
console.error("\n[CRAYON ERROR]", error instanceof Error ? error.message : error);
|
|
@@ -10,7 +10,7 @@ export class LocalGemma {
|
|
|
10
10
|
async start() {
|
|
11
11
|
if (await this.isReady())
|
|
12
12
|
return;
|
|
13
|
-
console.log("[CRAYON] Starting local Gemma server...");
|
|
13
|
+
console.log("[CRAYON] Starting local Gemma multimodal server...");
|
|
14
14
|
console.log(`[CRAYON] Model: ${this.options.model}`);
|
|
15
15
|
this.process = spawn(this.options.python, ["model/server.py"], {
|
|
16
16
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -22,7 +22,7 @@ export class LocalGemma {
|
|
|
22
22
|
const deadline = Date.now() + 180000;
|
|
23
23
|
while (Date.now() < deadline) {
|
|
24
24
|
if (await this.isReady()) {
|
|
25
|
-
console.log("[CRAYON] Local Gemma server is ready.");
|
|
25
|
+
console.log("[CRAYON] Local multimodal Gemma server is ready.");
|
|
26
26
|
return;
|
|
27
27
|
}
|
|
28
28
|
await new Promise(r => setTimeout(r, 1000));
|
|
@@ -37,15 +37,11 @@ export class LocalGemma {
|
|
|
37
37
|
return false;
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
async
|
|
41
|
-
const response = await fetch(`${this.baseUrl}/generate/stream`, {
|
|
42
|
-
method: "POST", headers: { "Content-Type": "application/json" },
|
|
43
|
-
body: JSON.stringify({ prompt, max_new_tokens: this.options.maxNewTokens, temperature: this.options.temperature })
|
|
44
|
-
});
|
|
40
|
+
async consumeStream(response) {
|
|
45
41
|
if (!response.ok)
|
|
46
|
-
throw new Error(`Gemma
|
|
42
|
+
throw new Error(`Gemma error (${response.status}): ${await response.text()}`);
|
|
47
43
|
if (!response.body)
|
|
48
|
-
throw new Error("Gemma returned no
|
|
44
|
+
throw new Error("Gemma returned no stream.");
|
|
49
45
|
const reader = response.body.getReader();
|
|
50
46
|
const decoder = new TextDecoder();
|
|
51
47
|
process.stdout.write("\nCRAYON: ");
|
|
@@ -63,14 +59,13 @@ export class LocalGemma {
|
|
|
63
59
|
reader.releaseLock();
|
|
64
60
|
}
|
|
65
61
|
}
|
|
66
|
-
async
|
|
67
|
-
const response = await fetch(`${this.baseUrl}/generate`, {
|
|
62
|
+
async generateStream(prompt) {
|
|
63
|
+
const response = await fetch(`${this.baseUrl}/generate/stream`, {
|
|
68
64
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
69
|
-
body: JSON.stringify({ prompt, max_new_tokens: this.options.maxNewTokens,
|
|
65
|
+
body: JSON.stringify({ prompt, max_new_tokens: this.options.maxNewTokens,
|
|
66
|
+
temperature: this.options.temperature })
|
|
70
67
|
});
|
|
71
|
-
|
|
72
|
-
throw new Error(`Gemma error (${response.status}): ${await response.text()}`);
|
|
73
|
-
return (await response.json()).text;
|
|
68
|
+
await this.consumeStream(response);
|
|
74
69
|
}
|
|
75
70
|
stop() {
|
|
76
71
|
if (this.process && !this.process.killed)
|
package/model/requirements.txt
CHANGED
package/model/server.py
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import json
|
|
3
|
+
import base64
|
|
4
|
+
import mimetypes
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
2
7
|
from threading import Thread
|
|
8
|
+
|
|
3
9
|
import torch
|
|
4
|
-
from fastapi import FastAPI
|
|
10
|
+
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
|
|
5
11
|
from fastapi.responses import StreamingResponse
|
|
6
12
|
from pydantic import BaseModel
|
|
7
|
-
from transformers import
|
|
13
|
+
from transformers import AutoProcessor, AutoModelForMultimodalLM, TextIteratorStreamer
|
|
8
14
|
|
|
9
15
|
MODEL_ID=os.getenv("CRAYON_MODEL","google/gemma-4-E2B-it")
|
|
10
16
|
HOST=os.getenv("CRAYON_HOST","127.0.0.1")
|
|
@@ -12,101 +18,153 @@ PORT=int(os.getenv("CRAYON_PORT","8765"))
|
|
|
12
18
|
|
|
13
19
|
print(f"[CRAYON MODEL] Loading: {MODEL_ID}",flush=True)
|
|
14
20
|
print(f"[CRAYON MODEL] CUDA available: {torch.cuda.is_available()}",flush=True)
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
|
|
22
|
+
processor=AutoProcessor.from_pretrained(MODEL_ID)
|
|
23
|
+
model=AutoModelForMultimodalLM.from_pretrained(
|
|
24
|
+
MODEL_ID,
|
|
25
|
+
dtype="auto",
|
|
26
|
+
device_map="auto",
|
|
27
|
+
)
|
|
17
28
|
model.eval()
|
|
18
|
-
|
|
29
|
+
|
|
30
|
+
print("[CRAYON MODEL] Multimodal model loaded.",flush=True)
|
|
19
31
|
print(f"[CRAYON MODEL] Device: {model.device}",flush=True)
|
|
20
32
|
|
|
21
|
-
app=FastAPI(title="CRAYON Local Gemma Server",version="0.
|
|
33
|
+
app=FastAPI(title="CRAYON Local Gemma Multimodal Server",version="0.4.0")
|
|
22
34
|
|
|
23
|
-
class
|
|
35
|
+
class TextRequest(BaseModel):
|
|
24
36
|
prompt:str
|
|
25
37
|
max_new_tokens:int=1024
|
|
26
38
|
temperature:float=0.7
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
52
78
|
|
|
53
79
|
worker=Thread(target=model.generate,kwargs=kwargs,daemon=True)
|
|
54
80
|
worker.start()
|
|
55
81
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
82
|
+
def iterator():
|
|
83
|
+
generated=""
|
|
84
|
+
count=0
|
|
60
85
|
try:
|
|
61
86
|
for text in streamer:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
# TextIteratorStreamer emits decoded text chunks. Re-tokenize
|
|
66
|
-
# accumulated output to report the generated-token count.
|
|
67
|
-
token_count = len(
|
|
68
|
-
tokenizer.encode(
|
|
69
|
-
generated_text,
|
|
70
|
-
add_special_tokens=False,
|
|
71
|
-
)
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
print(
|
|
75
|
-
f"[CRAYON TOKEN] #{token_count}: {text!r}",
|
|
76
|
-
flush=True,
|
|
77
|
-
)
|
|
78
|
-
|
|
87
|
+
generated += text
|
|
88
|
+
count += 1
|
|
89
|
+
print(f"[CRAYON STREAM] chunk={count}: {text!r}",flush=True)
|
|
79
90
|
yield text
|
|
80
|
-
|
|
81
|
-
final_token_count = len(
|
|
82
|
-
tokenizer.encode(
|
|
83
|
-
generated_text,
|
|
84
|
-
add_special_tokens=False,
|
|
85
|
-
)
|
|
86
|
-
)
|
|
87
|
-
|
|
88
91
|
print(
|
|
89
|
-
f"[CRAYON STREAM]
|
|
90
|
-
|
|
91
|
-
f"generated_tokens={final_token_count}",
|
|
92
|
-
flush=True,
|
|
92
|
+
f"[CRAYON STREAM] complete | chunks={count} | chars={len(generated)}",
|
|
93
|
+
flush=True
|
|
93
94
|
)
|
|
94
95
|
finally:
|
|
95
96
|
worker.join()
|
|
97
|
+
return iterator()
|
|
96
98
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
@app.get("/health")
|
|
100
|
+
def health():
|
|
101
|
+
return {
|
|
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",
|
|
111
|
+
}
|
|
99
112
|
|
|
100
113
|
@app.post("/generate")
|
|
101
|
-
def
|
|
102
|
-
|
|
103
|
-
|
|
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,
|
|
104
120
|
"do_sample":request.temperature>0}
|
|
105
|
-
if attention_mask is not None: kwargs["attention_mask"]=attention_mask
|
|
106
121
|
if request.temperature>0: kwargs["temperature"]=request.temperature
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
155
|
+
|
|
156
|
+
try:
|
|
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"}
|
|
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
|
|
110
168
|
|
|
111
169
|
if __name__=="__main__":
|
|
112
170
|
import uvicorn
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import "dotenv/config";
|
|
2
2
|
import readline from "node:readline";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { basename } from "node:path";
|
|
3
5
|
import { LocalGemma } from "./providers/local-gemma.js";
|
|
4
6
|
|
|
5
7
|
const gemma=new LocalGemma({
|
|
@@ -11,19 +13,118 @@ const gemma=new LocalGemma({
|
|
|
11
13
|
temperature:Number(process.env.CRAYON_TEMPERATURE||0.7)
|
|
12
14
|
});
|
|
13
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));
|
|
25
|
+
|
|
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
|
+
);
|
|
30
|
+
|
|
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
|
+
}
|
|
47
|
+
|
|
48
|
+
function printHelp() {
|
|
49
|
+
console.log(`
|
|
50
|
+
CRAYON v0.4.1 - Gemma 4 E2B Multimodal CLI
|
|
51
|
+
|
|
52
|
+
Usage:
|
|
53
|
+
crayon [prompt]
|
|
54
|
+
crayon image <file> <question>
|
|
55
|
+
crayon audio <file> <question>
|
|
56
|
+
crayon video <file> <question>
|
|
57
|
+
crayon --help
|
|
58
|
+
crayon -h
|
|
59
|
+
|
|
60
|
+
Commands:
|
|
61
|
+
/help Show available interactive commands
|
|
62
|
+
/image <file> <question> Analyze an image
|
|
63
|
+
/audio <file> <question> Analyze/transcribe audio
|
|
64
|
+
/video <file> <question> Analyze a video
|
|
65
|
+
/exit Exit CRAYON
|
|
66
|
+
|
|
67
|
+
CLI commands:
|
|
68
|
+
image <file> <question> One-shot image analysis
|
|
69
|
+
audio <file> <question> One-shot audio analysis
|
|
70
|
+
video <file> <question> One-shot video analysis
|
|
71
|
+
--help, -h Show this help
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
crayon \"Explain recursion in JavaScript\"
|
|
75
|
+
crayon image photo.jpg \"What is in this image?\"
|
|
76
|
+
crayon audio speech.wav \"Transcribe this audio.\"
|
|
77
|
+
crayon video clip.mp4 \"Describe what happens in this video.\"
|
|
78
|
+
`);
|
|
79
|
+
}
|
|
80
|
+
|
|
14
81
|
async function main(){
|
|
15
|
-
const
|
|
82
|
+
const args=process.argv.slice(2);
|
|
83
|
+
|
|
84
|
+
if(args.includes('--help') || args.includes('-h')){
|
|
85
|
+
printHelp();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
16
89
|
await gemma.start();
|
|
17
90
|
|
|
18
|
-
|
|
91
|
+
// One-shot:
|
|
92
|
+
// crayon "Explain this"
|
|
93
|
+
// crayon image photo.jpg "What is in this image?"
|
|
94
|
+
// crayon audio speech.wav "Transcribe this"
|
|
95
|
+
// crayon video clip.mp4 "Describe this video"
|
|
96
|
+
if(args[0] && ["image","audio","video"].includes(args[0]) && args[1] && args[2]){
|
|
97
|
+
await streamMedia(args.slice(2).join(" "),args[0] as "image"|"audio"|"video",args[1]);
|
|
98
|
+
gemma.stop();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if(args.length){
|
|
103
|
+
await gemma.generateStream(args.join(" "));
|
|
104
|
+
gemma.stop();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
19
107
|
|
|
20
|
-
console.log(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
108
|
+
console.log(`
|
|
109
|
+
==========================================
|
|
110
|
+
CRAYON v0.4.0 - GEMMA MULTIMODAL CLI
|
|
111
|
+
==========================================
|
|
112
|
+
Text -> Text
|
|
113
|
+
Image -> Text
|
|
114
|
+
Audio -> Text
|
|
115
|
+
Video -> Text
|
|
116
|
+
|
|
117
|
+
Commands:
|
|
118
|
+
/image <file> <question>
|
|
119
|
+
/audio <file> <question>
|
|
120
|
+
/video <file> <question>
|
|
121
|
+
/exit
|
|
122
|
+
|
|
123
|
+
Examples:
|
|
124
|
+
/image photo.jpg What is in this image?
|
|
125
|
+
/audio speech.wav Transcribe this audio.
|
|
126
|
+
/video clip.mp4 Describe what happens.
|
|
127
|
+
`);
|
|
27
128
|
|
|
28
129
|
const rl=readline.createInterface({input:process.stdin,output:process.stdout,prompt:"You: "});
|
|
29
130
|
const shutdown=()=>{rl.close();gemma.stop();process.exit(0);};
|
|
@@ -31,13 +132,35 @@ async function main(){
|
|
|
31
132
|
rl.prompt();
|
|
32
133
|
|
|
33
134
|
rl.on("line",async line=>{
|
|
34
|
-
const
|
|
35
|
-
if(!
|
|
36
|
-
if(
|
|
135
|
+
const input=line.trim();
|
|
136
|
+
if(!input){rl.prompt();return;}
|
|
137
|
+
if(input==="/exit"){shutdown();return;}
|
|
138
|
+
if(input==="/help" || input==="help" || input==="?"){
|
|
139
|
+
printHelp();
|
|
140
|
+
rl.prompt();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
37
143
|
rl.pause();
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
144
|
+
|
|
145
|
+
try{
|
|
146
|
+
const parts=input.split(/\s+/);
|
|
147
|
+
const cmd=parts[0];
|
|
148
|
+
|
|
149
|
+
if((cmd==="/image"||cmd==="/audio"||cmd==="/video") && parts.length>=3){
|
|
150
|
+
const type=cmd.slice(1) as "image"|"audio"|"video";
|
|
151
|
+
const path=parts[1];
|
|
152
|
+
const prompt=parts.slice(2).join(" ");
|
|
153
|
+
await streamMedia(prompt,type,path);
|
|
154
|
+
}else{
|
|
155
|
+
await gemma.generateStream(input);
|
|
156
|
+
}
|
|
157
|
+
}catch(error){
|
|
158
|
+
console.error("\n[CRAYON ERROR]",error instanceof Error?error.message:error);
|
|
159
|
+
}finally{
|
|
160
|
+
rl.resume();
|
|
161
|
+
rl.prompt();
|
|
162
|
+
}
|
|
41
163
|
});
|
|
42
164
|
}
|
|
165
|
+
|
|
43
166
|
main().catch(error=>{console.error("[CRAYON FATAL]",error);gemma.stop();process.exit(1);});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawn, ChildProcess } from "node:child_process";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { basename } from "node:path";
|
|
2
4
|
|
|
3
5
|
export interface GemmaOptions {
|
|
4
6
|
python:string; model:string; host:string; port:number;
|
|
@@ -17,7 +19,7 @@ export class LocalGemma {
|
|
|
17
19
|
|
|
18
20
|
async start():Promise<void>{
|
|
19
21
|
if(await this.isReady()) return;
|
|
20
|
-
console.log("[CRAYON] Starting local Gemma server...");
|
|
22
|
+
console.log("[CRAYON] Starting local Gemma multimodal server...");
|
|
21
23
|
console.log(`[CRAYON] Model: ${this.options.model}`);
|
|
22
24
|
|
|
23
25
|
this.process=spawn(this.options.python,["model/server.py"],{
|
|
@@ -25,13 +27,12 @@ export class LocalGemma {
|
|
|
25
27
|
env:{...process.env,CRAYON_MODEL:this.options.model,CRAYON_HOST:this.options.host,
|
|
26
28
|
CRAYON_PORT:String(this.options.port)}
|
|
27
29
|
});
|
|
28
|
-
|
|
29
30
|
this.process.stdout?.on("data",(d:Buffer)=>process.stdout.write(`[Gemma] ${d.toString()}`));
|
|
30
31
|
this.process.stderr?.on("data",(d:Buffer)=>process.stderr.write(`[Gemma] ${d.toString()}`));
|
|
31
32
|
|
|
32
33
|
const deadline=Date.now()+180000;
|
|
33
34
|
while(Date.now()<deadline){
|
|
34
|
-
if(await this.isReady()){ console.log("[CRAYON] Local Gemma server is ready."); return; }
|
|
35
|
+
if(await this.isReady()){ console.log("[CRAYON] Local multimodal Gemma server is ready."); return; }
|
|
35
36
|
await new Promise(r=>setTimeout(r,1000));
|
|
36
37
|
}
|
|
37
38
|
throw new Error("Gemma server did not become ready within 180 seconds.");
|
|
@@ -41,13 +42,9 @@ export class LocalGemma {
|
|
|
41
42
|
try{return (await fetch(`${this.baseUrl}/health`)).ok;}catch{return false;}
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
async
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
body:JSON.stringify({prompt,max_new_tokens:this.options.maxNewTokens,temperature:this.options.temperature})
|
|
48
|
-
});
|
|
49
|
-
if(!response.ok) throw new Error(`Gemma streaming error (${response.status}): ${await response.text()}`);
|
|
50
|
-
if(!response.body) throw new Error("Gemma returned no streaming body.");
|
|
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.");
|
|
51
48
|
|
|
52
49
|
const reader=response.body.getReader();
|
|
53
50
|
const decoder=new TextDecoder();
|
|
@@ -63,16 +60,16 @@ export class LocalGemma {
|
|
|
63
60
|
}finally{reader.releaseLock();}
|
|
64
61
|
}
|
|
65
62
|
|
|
66
|
-
async
|
|
67
|
-
const response=await fetch(`${this.baseUrl}/generate`,{
|
|
63
|
+
async generateStream(prompt:string):Promise<void>{
|
|
64
|
+
const response=await fetch(`${this.baseUrl}/generate/stream`,{
|
|
68
65
|
method:"POST",headers:{"Content-Type":"application/json"},
|
|
69
|
-
body:JSON.stringify({prompt,max_new_tokens:this.options.maxNewTokens,
|
|
66
|
+
body:JSON.stringify({prompt,max_new_tokens:this.options.maxNewTokens,
|
|
67
|
+
temperature:this.options.temperature})
|
|
70
68
|
});
|
|
71
|
-
|
|
72
|
-
return ((await response.json()) as {text:string}).text;
|
|
69
|
+
await this.consumeStream(response);
|
|
73
70
|
}
|
|
74
71
|
|
|
75
72
|
stop():void{
|
|
76
73
|
if(this.process && !this.process.killed) this.process.kill();
|
|
77
74
|
}
|
|
78
|
-
}
|
|
75
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions":{"target":"ES2022","module":"NodeNext","moduleResolution":"NodeNext",
|
|
3
|
+
"outDir":"dist","rootDir":"src","strict":true,"esModuleInterop":true,"skipLibCheck":true},
|
|
4
|
+
"include":["src/**/*.ts"]
|
|
5
|
+
}
|