Skip to content

Commit 0e8d1d9

Browse files
fadeevhernan-clich
andauthored
feat: ask command (#61)
Co-authored-by: Hernan Clich <hernan.g.clich@gmail.com>
1 parent ed8d7bd commit 0e8d1d9

File tree

17 files changed

+758
-41
lines changed

17 files changed

+758
-41
lines changed

docs/index.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,3 +1252,18 @@ Options:
12521252
-h, --help display help for command
12531253
12541254
```
1255+
1256+
## zetachain ask
1257+
1258+
```
1259+
Usage: zetachain ask [options] [prompt...]
1260+
1261+
Chat with ZetaChain Docs AI
1262+
1263+
Arguments:
1264+
prompt Prompt to send to AI
1265+
1266+
Options:
1267+
-h, --help display help for command
1268+
1269+
```

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,18 @@
3434
"dependencies": {
3535
"@zetachain/localnet": "12.0.3",
3636
"@zetachain/toolkit": "16.1.2",
37+
"axios": "^1.7.7",
3738
"commander": "^13.1.0",
39+
"cors": "^2.8.5",
3840
"fs-extra": "^11.3.0",
3941
"inquirer": "^12.3.2",
4042
"marked": "^15.0.6",
4143
"marked-terminal": "^7.2.1",
42-
"node-fetch": "^3.3.2",
44+
"ora": "^5.4.1",
4345
"posthog-node": "^5.8.1",
4446
"simple-git": "^3.27.0",
45-
"uuid": "^11.1.0"
47+
"uuid": "^11.1.0",
48+
"zod": "^4.1.8"
4649
},
4750
"devDependencies": {
4851
"@types/clear": "^0.1.4",

src/commands/ask.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { Command } from "commander";
2+
import inquirer from "inquirer";
3+
4+
import { DEFAULT_CHAT_API_URL, DEFAULT_CHATBOT_ID } from "../constants";
5+
import { streamChatResponse } from "./ask/streaming";
6+
import { createChatSpinner } from "./ask/ui";
7+
import { validateAndSanitizePrompt } from "./ask/validation";
8+
9+
const main = async (promptParts: string[]): Promise<void> => {
10+
let nextPrompt = promptParts.join(" ").trim();
11+
while (true) {
12+
let prompt = nextPrompt;
13+
if (!prompt) {
14+
try {
15+
const { input } = await inquirer.prompt([
16+
{ message: "Ask ZetaChain", name: "input", type: "input" },
17+
]);
18+
prompt = String(input ?? "").trim();
19+
} catch (err) {
20+
// Handle inquirer exit scenarios
21+
if (err instanceof Error) {
22+
if (
23+
err.name === "ExitPromptError" ||
24+
err.message.includes("User force closed the prompt")
25+
) {
26+
return;
27+
}
28+
}
29+
throw err;
30+
}
31+
}
32+
const lower = (prompt || "").toLowerCase();
33+
if (!prompt || lower === "exit" || lower === "quit" || lower === ":q") {
34+
return;
35+
}
36+
37+
try {
38+
// Validate and sanitize input
39+
const sanitizedPrompt = validateAndSanitizePrompt(prompt);
40+
41+
const payload = {
42+
chatbotId: DEFAULT_CHATBOT_ID,
43+
messages: [{ content: sanitizedPrompt, role: "user" }],
44+
stream: true,
45+
};
46+
47+
const spinner = createChatSpinner();
48+
const onFirstOutput = () => {
49+
spinner.stop();
50+
};
51+
52+
await streamChatResponse(DEFAULT_CHAT_API_URL, payload, onFirstOutput);
53+
} catch (securityError) {
54+
const message =
55+
securityError instanceof Error
56+
? securityError.message
57+
: String(securityError);
58+
console.error(`Error: ${message}`);
59+
process.exitCode = 1;
60+
}
61+
nextPrompt = "";
62+
}
63+
};
64+
65+
export const askCommand = new Command("ask")
66+
.description("Chat with ZetaChain Docs AI")
67+
.argument("[prompt...]", "Prompt to send to AI")
68+
.action(main);

src/commands/ask/http.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { AxiosHeaders, AxiosResponse, RawAxiosResponseHeaders } from "axios";
2+
3+
import { ASK_BASE_DELAY_MS, ASK_MAX_RETRIES } from "../../constants";
4+
import { fetchWithRetry } from "../../utils/http";
5+
import { readStreamBody } from "../../utils/stream";
6+
import { ErrorResponseSchema } from "./schemas";
7+
8+
// Ask command specific HTTP functionality
9+
10+
export const fetchChatStream = async (
11+
url: string,
12+
body: unknown,
13+
signal: AbortSignal,
14+
): Promise<AxiosResponse> => {
15+
return fetchWithRetry(
16+
url,
17+
{
18+
data: body,
19+
headers: {
20+
Accept: "text/event-stream",
21+
"Content-Type": "application/json",
22+
},
23+
method: "POST",
24+
responseType: "stream",
25+
signal,
26+
timeout: 360_000,
27+
},
28+
{
29+
baseDelay: ASK_BASE_DELAY_MS,
30+
maxRetries: ASK_MAX_RETRIES,
31+
},
32+
);
33+
};
34+
35+
export const parseErrorResponse = (
36+
res: AxiosResponse,
37+
text: string,
38+
): string | undefined => {
39+
let contentType: string | undefined;
40+
if (res?.headers instanceof AxiosHeaders) {
41+
const v = res.headers.get?.("content-type");
42+
contentType = typeof v === "string" ? v : undefined;
43+
} else if (res?.headers) {
44+
const raw = res.headers as RawAxiosResponseHeaders;
45+
const v = raw["content-type"] ?? raw["Content-Type"];
46+
contentType = typeof v === "string" ? v : undefined;
47+
}
48+
49+
const isJson = contentType?.includes("application/json");
50+
let parsed: unknown = text;
51+
if (isJson) {
52+
try {
53+
parsed = JSON.parse(text);
54+
} catch (_) {
55+
parsed = null;
56+
}
57+
}
58+
59+
const errorResult = ErrorResponseSchema.safeParse(parsed);
60+
if (errorResult.success) {
61+
return errorResult.data.error || errorResult.data.message;
62+
}
63+
return undefined;
64+
};
65+
66+
export const validateChatResponse = async (
67+
res: AxiosResponse,
68+
): Promise<void> => {
69+
if (res.status >= 200 && res.status < 300) {
70+
return; // Success
71+
}
72+
73+
const text = await readStreamBody(res);
74+
const message = parseErrorResponse(res, text);
75+
throw new Error(
76+
`Chat API error ${res.status}: ${message || res.statusText || "Error"}`,
77+
);
78+
};

src/commands/ask/schemas.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { z } from "zod";
2+
3+
// Ask command specific schemas for chat/AI APIs
4+
5+
export const StreamChoiceSchema = z.object({
6+
delta: z
7+
.object({
8+
content: z.string().optional(),
9+
})
10+
.optional(),
11+
});
12+
13+
export const StreamResponseSchema = z.object({
14+
choices: z.array(StreamChoiceSchema).optional(),
15+
});
16+
17+
export const TextResponseSchema = z.object({
18+
text: z.string(),
19+
});
20+
21+
export const ErrorResponseSchema = z.object({
22+
error: z.string().optional(),
23+
message: z.string().optional(),
24+
});

src/commands/ask/sse.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { StreamLike } from "../../utils/stream";
2+
import { StreamResponseSchema, TextResponseSchema } from "./schemas";
3+
4+
// Ask command specific SSE processing (could be reusable for other SSE APIs)
5+
6+
export interface SSEProcessor {
7+
onData: (chunk: unknown) => void;
8+
onEnd: () => void;
9+
onError: (err: unknown) => void;
10+
}
11+
12+
export const createSSEProcessor = (onFirstOutput: () => void): SSEProcessor => {
13+
let buffer = "";
14+
let prebuffer = "";
15+
let sawSseData = false;
16+
let eventBuf: string[] = [];
17+
let sawDone = false;
18+
let notifiedFirstOutput = false;
19+
20+
const notifyFirstOutput = () => {
21+
if (notifiedFirstOutput) return;
22+
notifiedFirstOutput = true;
23+
try {
24+
onFirstOutput();
25+
} catch (_) {
26+
// ignore callback errors
27+
}
28+
};
29+
30+
const flushEvent = () => {
31+
if (!eventBuf.length) return;
32+
const payload = eventBuf.join("\n");
33+
eventBuf = [];
34+
35+
if (payload === "[DONE]") {
36+
sawDone = true;
37+
return;
38+
}
39+
40+
let json: unknown = null;
41+
try {
42+
json = JSON.parse(payload);
43+
} catch (_) {
44+
json = null;
45+
}
46+
47+
let textOut: string | null = null;
48+
49+
// Try parsing as text response first
50+
const textResult = TextResponseSchema.safeParse(json);
51+
if (textResult.success) {
52+
textOut = textResult.data.text;
53+
} else {
54+
// Try parsing as stream response
55+
const streamResult = StreamResponseSchema.safeParse(json);
56+
if (
57+
streamResult.success &&
58+
streamResult.data.choices?.[0]?.delta?.content
59+
) {
60+
textOut = streamResult.data.choices[0].delta.content;
61+
} else if (typeof json === "string") {
62+
textOut = json;
63+
}
64+
}
65+
66+
notifyFirstOutput();
67+
if (typeof textOut === "string") {
68+
process.stdout.write(textOut);
69+
} else if (payload && payload.trim() !== "[object Object]") {
70+
process.stdout.write(payload);
71+
}
72+
};
73+
74+
const onData = (chunk: unknown) => {
75+
if (sawDone) return;
76+
77+
const chunkStr = Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk);
78+
79+
// Stream raw text immediately until we detect SSE lines (data: ...)
80+
if (!sawSseData) {
81+
const combined = prebuffer + chunkStr;
82+
if (/(\r?\n|^)data:/.test(combined)) {
83+
// Detected SSE, switch to SSE parsing mode
84+
sawSseData = true;
85+
buffer += combined;
86+
prebuffer = "";
87+
} else {
88+
prebuffer = "";
89+
notifyFirstOutput();
90+
process.stdout.write(chunkStr);
91+
return;
92+
}
93+
} else {
94+
buffer += chunkStr;
95+
}
96+
97+
const lines = buffer.split(/\r?\n/);
98+
buffer = lines.pop() ?? "";
99+
100+
for (const line of lines) {
101+
const trimmed = line.trimStart();
102+
if (trimmed === "") {
103+
flushEvent();
104+
continue;
105+
}
106+
if (trimmed.startsWith(":")) continue; // comment
107+
if (trimmed.startsWith("event:")) continue; // ignore event name
108+
if (trimmed.startsWith("data:")) {
109+
const p = trimmed.slice(5).trimStart();
110+
eventBuf.push(p);
111+
continue;
112+
}
113+
}
114+
};
115+
116+
const onEnd = () => {
117+
if (!sawSseData && prebuffer) {
118+
notifyFirstOutput();
119+
process.stdout.write(prebuffer);
120+
prebuffer = "";
121+
}
122+
123+
if (buffer) {
124+
const trimmed = buffer.trimStart();
125+
if (!sawSseData || !/(\r?\n|^)data:/.test(trimmed)) {
126+
notifyFirstOutput();
127+
process.stdout.write(buffer);
128+
} else {
129+
// finalize any pending event
130+
if (trimmed) {
131+
const maybe = trimmed.startsWith("data:")
132+
? trimmed.slice(5).trimStart()
133+
: trimmed;
134+
if (maybe) {
135+
eventBuf.push(maybe);
136+
}
137+
}
138+
flushEvent();
139+
}
140+
}
141+
142+
process.stdout.write("\n");
143+
};
144+
145+
const onError = (err: unknown) => {
146+
throw err;
147+
};
148+
149+
return { onData, onEnd, onError };
150+
};
151+
152+
export const processStream = async (
153+
stream: StreamLike,
154+
processor: SSEProcessor,
155+
): Promise<void> => {
156+
return new Promise<void>((resolve, reject) => {
157+
stream.on("data", processor.onData);
158+
stream.on("end", () => {
159+
processor.onEnd();
160+
resolve();
161+
});
162+
stream.on("error", (err: unknown) => {
163+
try {
164+
processor.onError(err);
165+
} catch (processedError) {
166+
reject(processedError);
167+
}
168+
});
169+
});
170+
};

0 commit comments

Comments
 (0)