@lazarusrolando/crayoncli 0.3.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/README.md CHANGED
@@ -1,12 +1,17 @@
1
- # CRAYON v0.3.0 — Local Gemma Streaming
1
+ # CRAYON v0.4.0 — 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,57 @@ 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:
21
30
 
22
31
  ```powershell
23
- node dist/index.js
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."
24
45
  ```
25
46
 
26
- or:
47
+ Video:
27
48
 
28
49
  ```powershell
29
- node dist/index.js "Explain recursion in JavaScript"
50
+ node dist/index.js video clip.mp4 "Describe what happens in this video."
30
51
  ```
31
52
 
32
- ## What v0.3 adds
53
+ Interactive:
33
54
 
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
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
+ ```
39
77
 
40
- Streaming changes delivery timing, not the inference provider. The model still runs locally.
78
+ rather than the text-only `AutoTokenizer` / `AutoModelForCausalLM` path.
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,101 @@ 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
+ }
12
44
  async function main() {
13
- const oneShot = process.argv.slice(2).join(" ").trim();
45
+ const args = process.argv.slice(2);
14
46
  await gemma.start();
15
- if (oneShot) {
16
- await gemma.generateStream(oneShot);
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]);
54
+ gemma.stop();
55
+ return;
56
+ }
57
+ if (args.length) {
58
+ await gemma.generateStream(args.join(" "));
17
59
  gemma.stop();
18
60
  return;
19
61
  }
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");
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
+ `);
27
82
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "You: " });
28
83
  const shutdown = () => { rl.close(); gemma.stop(); process.exit(0); };
29
84
  process.on("SIGINT", shutdown);
30
85
  rl.prompt();
31
86
  rl.on("line", async (line) => {
32
- const prompt = line.trim();
33
- if (!prompt) {
87
+ const input = line.trim();
88
+ if (!input) {
34
89
  rl.prompt();
35
90
  return;
36
91
  }
37
- if (prompt === "/exit") {
92
+ if (input === "/exit") {
38
93
  shutdown();
39
94
  return;
40
95
  }
41
96
  rl.pause();
42
97
  try {
43
- await gemma.generateStream(prompt);
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);
108
+ }
44
109
  }
45
110
  catch (error) {
46
111
  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.0",
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,79 @@ 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
+
14
48
  async function main(){
15
- const oneShot=process.argv.slice(2).join(" ").trim();
49
+ const args=process.argv.slice(2);
16
50
  await gemma.start();
17
51
 
18
- if(oneShot){ await gemma.generateStream(oneShot); gemma.stop(); return; }
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]);
59
+ gemma.stop();
60
+ return;
61
+ }
19
62
 
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");
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
+ `);
27
89
 
28
90
  const rl=readline.createInterface({input:process.stdin,output:process.stdout,prompt:"You: "});
29
91
  const shutdown=()=>{rl.close();gemma.stop();process.exit(0);};
@@ -31,13 +93,30 @@ async function main(){
31
93
  rl.prompt();
32
94
 
33
95
  rl.on("line",async line=>{
34
- const prompt=line.trim();
35
- if(!prompt){rl.prompt();return;}
36
- if(prompt==="/exit"){shutdown();return;}
96
+ const input=line.trim();
97
+ if(!input){rl.prompt();return;}
98
+ if(input==="/exit"){shutdown();return;}
37
99
  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();}
100
+
101
+ try{
102
+ const parts=input.split(/\s+/);
103
+ const cmd=parts[0];
104
+
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);
112
+ }
113
+ }catch(error){
114
+ console.error("\n[CRAYON ERROR]",error instanceof Error?error.message:error);
115
+ }finally{
116
+ rl.resume();
117
+ rl.prompt();
118
+ }
41
119
  });
42
120
  }
121
+
43
122
  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
+ }