@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 CHANGED
@@ -1,12 +1,17 @@
1
- # CRAYON v0.3.0 — Local Gemma Streaming
1
+ # CRAYON v0.4.1 — Gemma 4 E2B Multimodal CLI
2
2
 
3
- Real local token streaming for the CRAYON CLI.
3
+ Local multimodal CLI using the official Transformers interface for `google/gemma-4-E2B-it`.
4
4
 
5
- ## Flow
5
+ ## Supported
6
6
 
7
- You → Node.js CLI → local FastAPI → Transformers TextIteratorStreamer → local Gemma → tokens → CLI
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
- Hugging Face is only the model repository. Inference is local.
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
- ## Run
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
- or:
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 "Explain recursion in JavaScript"
92
+ node dist/index.js -h
30
93
  ```
31
94
 
32
- ## What v0.3 adds
95
+ Inside interactive mode:
33
96
 
34
- - `TextIteratorStreamer` for local token streaming
35
- - background generation thread
36
- - FastAPI `StreamingResponse`
37
- - Node.js `ReadableStream` consumption
38
- - keeps `/generate` for non-streaming compatibility
97
+ ```text
98
+ /help
99
+ ```
39
100
 
40
- Streaming changes delivery timing, not the inference provider. The model still runs locally.
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 oneShot = process.argv.slice(2).join(" ").trim();
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
- if (oneShot) {
16
- await gemma.generateStream(oneShot);
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
- console.log("\n==========================================");
21
- console.log(" CRAYON v0.3.0 - LOCAL STREAMING");
22
- console.log("==========================================");
23
- console.log("Gemma inference: LOCAL");
24
- console.log("Hugging Face inference API: NOT USED");
25
- console.log("Streaming: ENABLED\n");
26
- console.log("Type /exit to quit.\n");
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 prompt = line.trim();
33
- if (!prompt) {
123
+ const input = line.trim();
124
+ if (!input) {
34
125
  rl.prompt();
35
126
  return;
36
127
  }
37
- if (prompt === "/exit") {
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
- await gemma.generateStream(prompt);
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 generateStream(prompt) {
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 streaming error (${response.status}): ${await response.text()}`);
42
+ throw new Error(`Gemma error (${response.status}): ${await response.text()}`);
47
43
  if (!response.body)
48
- throw new Error("Gemma returned no streaming body.");
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 generate(prompt) {
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, temperature: this.options.temperature })
65
+ body: JSON.stringify({ prompt, max_new_tokens: this.options.maxNewTokens,
66
+ temperature: this.options.temperature })
70
67
  });
71
- if (!response.ok)
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)
@@ -4,3 +4,6 @@ transformers
4
4
  torch
5
5
  accelerate
6
6
  sentencepiece
7
+ torchvision
8
+ librosa
9
+ Pillow
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 AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
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
- tokenizer=AutoTokenizer.from_pretrained(MODEL_ID)
16
- model=AutoModelForCausalLM.from_pretrained(MODEL_ID,device_map="auto",torch_dtype="auto")
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
- print("[CRAYON MODEL] Model loaded.",flush=True)
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.3.0")
33
+ app=FastAPI(title="CRAYON Local Gemma Multimodal Server",version="0.4.0")
22
34
 
23
- class GenerateRequest(BaseModel):
35
+ class TextRequest(BaseModel):
24
36
  prompt:str
25
37
  max_new_tokens:int=1024
26
38
  temperature:float=0.7
27
-
28
- @app.get("/health")
29
- def health():
30
- return {"status":"ok","model":MODEL_ID,"device":str(model.device),
31
- "cuda":torch.cuda.is_available(),"streaming":True,"inference":"local"}
32
-
33
- def build_inputs(prompt):
34
- messages=[{"role":"user","content":prompt}]
35
- inputs=tokenizer.apply_chat_template(messages,tokenize=True,add_generation_prompt=True,
36
- return_tensors="pt",return_dict=True)
37
- input_ids=inputs["input_ids"].to(model.device)
38
- attention_mask=inputs.get("attention_mask")
39
- if attention_mask is not None:
40
- attention_mask=attention_mask.to(model.device)
41
- return input_ids,attention_mask
42
-
43
- @app.post("/generate/stream")
44
- def generate_stream(request:GenerateRequest):
45
- input_ids,attention_mask=build_inputs(request.prompt)
46
- streamer=TextIteratorStreamer(tokenizer,skip_prompt=True,skip_special_tokens=True)
47
- kwargs={"input_ids":input_ids,"streamer":streamer,
48
- "max_new_tokens":request.max_new_tokens,
49
- "do_sample":request.temperature>0}
50
- if attention_mask is not None: kwargs["attention_mask"]=attention_mask
51
- if request.temperature>0: kwargs["temperature"]=request.temperature
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 token_iterator():
57
- generated_text = ""
58
- chunk_count = 0
59
-
82
+ def iterator():
83
+ generated=""
84
+ count=0
60
85
  try:
61
86
  for text in streamer:
62
- chunk_count += 1
63
- generated_text += text
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] Generation complete | "
90
- f"chunks={chunk_count} | "
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
- return StreamingResponse(token_iterator(),media_type="text/plain; charset=utf-8",
98
- headers={"Cache-Control":"no-cache","X-CRAYON-Streaming":"true"})
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 generate(request:GenerateRequest):
102
- input_ids,attention_mask=build_inputs(request.prompt)
103
- kwargs={"input_ids":input_ids,"max_new_tokens":request.max_new_tokens,
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
- output=model.generate(**kwargs)
108
- new_tokens=output[0][input_ids.shape[-1]:]
109
- return {"text":tokenizer.decode(new_tokens,skip_special_tokens=True)}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazarusrolando/crayoncli",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "tsc",
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 oneShot=process.argv.slice(2).join(" ").trim();
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
- if(oneShot){ await gemma.generateStream(oneShot); gemma.stop(); return; }
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("\n==========================================");
21
- console.log(" CRAYON v0.3.0 - LOCAL STREAMING");
22
- console.log("==========================================");
23
- console.log("Gemma inference: LOCAL");
24
- console.log("Hugging Face inference API: NOT USED");
25
- console.log("Streaming: ENABLED\n");
26
- console.log("Type /exit to quit.\n");
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 prompt=line.trim();
35
- if(!prompt){rl.prompt();return;}
36
- if(prompt==="/exit"){shutdown();return;}
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
- try{await gemma.generateStream(prompt);}
39
- catch(error){console.error("\n[CRAYON ERROR]",error instanceof Error?error.message:error);}
40
- finally{rl.resume();rl.prompt();}
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 generateStream(prompt:string):Promise<void>{
45
- const response=await fetch(`${this.baseUrl}/generate/stream`,{
46
- method:"POST",headers:{"Content-Type":"application/json"},
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 generate(prompt:string):Promise<string>{
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,temperature:this.options.temperature})
66
+ body:JSON.stringify({prompt,max_new_tokens:this.options.maxNewTokens,
67
+ temperature:this.options.temperature})
70
68
  });
71
- if(!response.ok) throw new Error(`Gemma error (${response.status}): ${await response.text()}`);
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
- {"compilerOptions":{"target":"ES2022","module":"NodeNext","moduleResolution":"NodeNext","outDir":"dist","rootDir":"src","strict":true,"esModuleInterop":true,"skipLibCheck":true},"include":["src/**/*.ts"]}
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
+ }