feat: add retry logic to AI client and interactive unstaged file selector
This commit is contained in:
@@ -37,8 +37,8 @@ ${BOLD}Usage:${RESET}
|
||||
${BOLD}Configuration:${RESET}
|
||||
Set via ${CYAN}gai config${RESET} or environment variables:
|
||||
GAI_API_KEY OpenAI-compatible API key
|
||||
GAI_API_BASE API base URL (default: https://api.openai.com/v1)
|
||||
GAI_MODEL Model name (default: gpt-4o)
|
||||
GAI_API_BASE API base URL (default: https://api.deepseek.com/v1)
|
||||
GAI_MODEL Model name (default: deepseek-chat)
|
||||
GAI_MAX_TOKENS Max tokens (default: 500)
|
||||
GAI_TEMPERATURE Temperature (default: 0.7)
|
||||
`);
|
||||
|
||||
@@ -8,15 +8,42 @@ interface ChatMessage {
|
||||
interface ChatCompletionResponse {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string;
|
||||
content?: string | null;
|
||||
};
|
||||
finish_reason?: string;
|
||||
}>;
|
||||
error?: {
|
||||
message?: string;
|
||||
type?: string;
|
||||
code?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 1000;
|
||||
|
||||
function cleanMessage(raw: string): string {
|
||||
let msg = raw.trim();
|
||||
if (msg.startsWith("```") && msg.endsWith("```")) {
|
||||
const lines = msg.split("\n");
|
||||
if (lines.length > 2) {
|
||||
lines.shift();
|
||||
lines.pop();
|
||||
msg = lines.join("\n").trim();
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function generateCommitMessage(
|
||||
config: Config,
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
retries = MAX_RETRIES,
|
||||
): Promise<string> {
|
||||
const url = `${config.apiBase.replace(/\/$/, "")}/chat/completions`;
|
||||
|
||||
@@ -25,31 +52,72 @@ export async function generateCommitMessage(
|
||||
{ role: "user", content: userPrompt },
|
||||
];
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: config.model,
|
||||
max_tokens: config.maxTokens,
|
||||
temperature: config.temperature,
|
||||
messages,
|
||||
}),
|
||||
});
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: config.model,
|
||||
max_tokens: config.maxTokens,
|
||||
temperature: config.temperature,
|
||||
messages,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`API request failed (${response.status}): ${text}`);
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 429 && attempt < retries) {
|
||||
await sleep(RETRY_DELAY * attempt);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`API request failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ChatCompletionResponse;
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(
|
||||
`API error: ${data.error.message ?? JSON.stringify(data.error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const raw = data.choices?.[0]?.message?.content;
|
||||
const finishReason = data.choices?.[0]?.finish_reason;
|
||||
|
||||
if (raw && raw.trim()) {
|
||||
return cleanMessage(raw);
|
||||
}
|
||||
|
||||
if (finishReason === "length") {
|
||||
throw new Error(
|
||||
"Response truncated (max_tokens too low). Try increasing GAI_MAX_TOKENS.",
|
||||
);
|
||||
}
|
||||
|
||||
if (finishReason === "content_filter") {
|
||||
throw new Error("Response blocked by content filter.");
|
||||
}
|
||||
|
||||
if (attempt < retries) {
|
||||
await sleep(RETRY_DELAY * attempt);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Empty response from AI after ${retries} attempts. finish_reason: ${finishReason ?? "unknown"}`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (attempt >= retries) throw err;
|
||||
if (err instanceof Error && err.message.startsWith("API error")) throw err;
|
||||
if (err instanceof Error && err.message.includes("max_tokens")) throw err;
|
||||
if (err instanceof Error && err.message.includes("content filter")) throw err;
|
||||
await sleep(RETRY_DELAY * attempt);
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ChatCompletionResponse;
|
||||
const message = data.choices?.[0]?.message?.content?.trim();
|
||||
|
||||
if (!message) {
|
||||
throw new Error("Empty response from AI");
|
||||
}
|
||||
|
||||
return message;
|
||||
throw new Error("Failed to generate commit message");
|
||||
}
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ import type { Config } from "./types";
|
||||
|
||||
const DEFAULT_CONFIG: Config = {
|
||||
apiKey: "",
|
||||
apiBase: "https://api.openai.com/v1",
|
||||
model: "gpt-4o",
|
||||
apiBase: "https://api.deepseek.com/v1",
|
||||
model: "deepseek-chat",
|
||||
maxTokens: 500,
|
||||
temperature: 0.7,
|
||||
};
|
||||
|
||||
+145
-39
@@ -1,6 +1,37 @@
|
||||
import * as readline from "node:readline";
|
||||
import type { FileEntry } from "./types";
|
||||
import { BOLD, GREEN, YELLOW, CYAN, RESET } from "./terminal";
|
||||
import { BOLD, GREEN, YELLOW, CYAN, DIM, RESET } from "./terminal";
|
||||
|
||||
const UP = "\x1b[A";
|
||||
const DOWN = "\x1b[B";
|
||||
const SPACE = " ";
|
||||
const ENTER = "\r";
|
||||
const CTRL_C = "\x03";
|
||||
|
||||
function hideCursor() {
|
||||
process.stdout.write("\x1b[?25l");
|
||||
}
|
||||
|
||||
function showCursor() {
|
||||
process.stdout.write("\x1b[?25h");
|
||||
}
|
||||
|
||||
function moveUp(n: number) {
|
||||
process.stdout.write(`\x1b[${n}A`);
|
||||
}
|
||||
|
||||
function moveDown(n: number) {
|
||||
process.stdout.write(`\x1b[${n}B`);
|
||||
}
|
||||
|
||||
function clearLine() {
|
||||
process.stdout.write("\x1b[2K\r");
|
||||
}
|
||||
|
||||
interface SelectItem {
|
||||
label: string;
|
||||
path: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export async function selectFiles(
|
||||
stagedFiles: FileEntry[],
|
||||
@@ -9,54 +40,129 @@ export async function selectFiles(
|
||||
if (unstagedFiles.length === 0) return [];
|
||||
|
||||
if (stagedFiles.length > 0) {
|
||||
console.log(`\n ${BOLD}Staged files (will be included):${RESET}`);
|
||||
process.stdout.write(`\n ${BOLD}Staged files (will be included):${RESET}\n`);
|
||||
for (const f of stagedFiles) {
|
||||
console.log(` ${GREEN}✓${RESET} ${f.path} (${YELLOW}${f.label}${RESET})`);
|
||||
process.stdout.write(` ${GREEN}✓${RESET} ${f.path} (${YELLOW}${f.label}${RESET})\n`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n ${BOLD}Unstaged files:${RESET}`);
|
||||
for (let i = 0; i < unstagedFiles.length; i++) {
|
||||
const f = unstagedFiles[i]!;
|
||||
console.log(
|
||||
` ${CYAN}${i + 1}.${RESET} ${f.path} (${YELLOW}${f.label}${RESET})`,
|
||||
);
|
||||
const items: SelectItem[] = [
|
||||
{ label: "Select all", path: "__all__", selected: false },
|
||||
...unstagedFiles.map((f) => ({
|
||||
label: `${f.path} (${f.label})`,
|
||||
path: f.path,
|
||||
selected: false,
|
||||
})),
|
||||
];
|
||||
|
||||
let cursor = 0;
|
||||
|
||||
process.stdout.write(`\n ${BOLD}Select files to stage:${RESET}\n`);
|
||||
process.stdout.write(` ${DIM}↑/↓ navigate, space select, enter confirm${RESET}\n\n`);
|
||||
|
||||
const itemStartRow = 4 + (stagedFiles.length > 0 ? stagedFiles.length + 2 : 0);
|
||||
|
||||
function render() {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
process.stdout.write("\x1b[2K\r");
|
||||
const item = items[i]!;
|
||||
const isAll = i === 0;
|
||||
const cursor_ = i === cursor ? `${CYAN}❯${RESET} ` : " ";
|
||||
const checkbox = item.selected ? `${GREEN}◉${RESET}` : `${DIM}○${RESET}`;
|
||||
|
||||
if (isAll) {
|
||||
process.stdout.write(`${cursor_} ${checkbox} ${BOLD}${item.label}${RESET}\n`);
|
||||
} else {
|
||||
process.stdout.write(`${cursor_} ${checkbox} ${item.path.includes("(") ? item.label : `${item.label}`}\n`);
|
||||
}
|
||||
}
|
||||
moveUp(items.length);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
function toggleItem(index: number) {
|
||||
const item = items[index]!;
|
||||
item.selected = !item.selected;
|
||||
|
||||
if (index === 0) {
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
items[i]!.selected = item.selected;
|
||||
}
|
||||
} else {
|
||||
const allSelected = items.slice(1).every((it) => it.selected);
|
||||
items[0]!.selected = allSelected;
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(
|
||||
" Enter files to stage (e.g. 1,3) or 'a' for all: ",
|
||||
(answer) => {
|
||||
rl.close();
|
||||
const trimmed = answer.trim().toLowerCase();
|
||||
if (process.stdin.isTTY !== true) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "a" || trimmed === "all") {
|
||||
resolve(unstagedFiles.map((f) => f.path));
|
||||
return;
|
||||
const wasRaw = process.stdin.isRaw;
|
||||
if (wasRaw !== true) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.resume();
|
||||
hideCursor();
|
||||
|
||||
render();
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
const key = data.toString();
|
||||
|
||||
if (key === CTRL_C) {
|
||||
process.stdin.setRawMode(wasRaw === true);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
showCursor();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
process.stdout.write("\x1b[2K\n");
|
||||
}
|
||||
moveUp(items.length);
|
||||
process.stdout.write(`\n Aborted.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (key === UP) {
|
||||
if (cursor > 0) {
|
||||
cursor--;
|
||||
render();
|
||||
}
|
||||
} else if (key === DOWN) {
|
||||
if (cursor < items.length - 1) {
|
||||
cursor++;
|
||||
render();
|
||||
}
|
||||
} else if (key === SPACE) {
|
||||
toggleItem(cursor);
|
||||
render();
|
||||
} else if (key === ENTER) {
|
||||
process.stdin.setRawMode(wasRaw === true);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
process.stdout.write("\x1b[2K\n");
|
||||
}
|
||||
moveUp(items.length);
|
||||
|
||||
const selected = items
|
||||
.slice(1)
|
||||
.filter((it) => it.selected)
|
||||
.map((it) => it.path);
|
||||
|
||||
if (selected.length > 0) {
|
||||
process.stdout.write(
|
||||
` ${GREEN}Staged ${selected.length} file(s):${RESET} ${selected.join(", ")}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (trimmed === "") {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
showCursor();
|
||||
resolve(selected);
|
||||
}
|
||||
};
|
||||
|
||||
const indices = trimmed
|
||||
.split(/[,\s]+/)
|
||||
.map((s) => parseInt(s.trim()))
|
||||
.filter(
|
||||
(n) => !isNaN(n) && n >= 1 && n <= unstagedFiles.length,
|
||||
)
|
||||
.map((n) => n - 1);
|
||||
|
||||
const uniqueIndices = [...new Set(indices)];
|
||||
resolve(uniqueIndices.map((i) => unstagedFiles[i]!.path));
|
||||
},
|
||||
);
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user