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}
|
${BOLD}Configuration:${RESET}
|
||||||
Set via ${CYAN}gai config${RESET} or environment variables:
|
Set via ${CYAN}gai config${RESET} or environment variables:
|
||||||
GAI_API_KEY OpenAI-compatible API key
|
GAI_API_KEY OpenAI-compatible API key
|
||||||
GAI_API_BASE API base URL (default: https://api.openai.com/v1)
|
GAI_API_BASE API base URL (default: https://api.deepseek.com/v1)
|
||||||
GAI_MODEL Model name (default: gpt-4o)
|
GAI_MODEL Model name (default: deepseek-chat)
|
||||||
GAI_MAX_TOKENS Max tokens (default: 500)
|
GAI_MAX_TOKENS Max tokens (default: 500)
|
||||||
GAI_TEMPERATURE Temperature (default: 0.7)
|
GAI_TEMPERATURE Temperature (default: 0.7)
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -8,15 +8,42 @@ interface ChatMessage {
|
|||||||
interface ChatCompletionResponse {
|
interface ChatCompletionResponse {
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
message?: {
|
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(
|
export async function generateCommitMessage(
|
||||||
config: Config,
|
config: Config,
|
||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
userPrompt: string,
|
userPrompt: string,
|
||||||
|
retries = MAX_RETRIES,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const url = `${config.apiBase.replace(/\/$/, "")}/chat/completions`;
|
const url = `${config.apiBase.replace(/\/$/, "")}/chat/completions`;
|
||||||
|
|
||||||
@@ -25,6 +52,8 @@ export async function generateCommitMessage(
|
|||||||
{ role: "user", content: userPrompt },
|
{ role: "user", content: userPrompt },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -41,15 +70,54 @@ export async function generateCommitMessage(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
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}`);
|
throw new Error(`API request failed (${response.status}): ${text}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as ChatCompletionResponse;
|
const data = (await response.json()) as ChatCompletionResponse;
|
||||||
const message = data.choices?.[0]?.message?.content?.trim();
|
|
||||||
|
|
||||||
if (!message) {
|
if (data.error) {
|
||||||
throw new Error("Empty response from AI");
|
throw new Error(
|
||||||
|
`API error: ${data.error.message ?? JSON.stringify(data.error)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return message;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Failed to generate commit message");
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@ import type { Config } from "./types";
|
|||||||
|
|
||||||
const DEFAULT_CONFIG: Config = {
|
const DEFAULT_CONFIG: Config = {
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
apiBase: "https://api.openai.com/v1",
|
apiBase: "https://api.deepseek.com/v1",
|
||||||
model: "gpt-4o",
|
model: "deepseek-chat",
|
||||||
maxTokens: 500,
|
maxTokens: 500,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
};
|
};
|
||||||
|
|||||||
+143
-37
@@ -1,6 +1,37 @@
|
|||||||
import * as readline from "node:readline";
|
|
||||||
import type { FileEntry } from "./types";
|
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(
|
export async function selectFiles(
|
||||||
stagedFiles: FileEntry[],
|
stagedFiles: FileEntry[],
|
||||||
@@ -9,54 +40,129 @@ export async function selectFiles(
|
|||||||
if (unstagedFiles.length === 0) return [];
|
if (unstagedFiles.length === 0) return [];
|
||||||
|
|
||||||
if (stagedFiles.length > 0) {
|
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) {
|
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}`);
|
const items: SelectItem[] = [
|
||||||
for (let i = 0; i < unstagedFiles.length; i++) {
|
{ label: "Select all", path: "__all__", selected: false },
|
||||||
const f = unstagedFiles[i]!;
|
...unstagedFiles.map((f) => ({
|
||||||
console.log(
|
label: `${f.path} (${f.label})`,
|
||||||
` ${CYAN}${i + 1}.${RESET} ${f.path} (${YELLOW}${f.label}${RESET})`,
|
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("");
|
function toggleItem(index: number) {
|
||||||
const rl = readline.createInterface({
|
const item = items[index]!;
|
||||||
input: process.stdin,
|
item.selected = !item.selected;
|
||||||
output: process.stdout,
|
|
||||||
});
|
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) => {
|
return new Promise((resolve) => {
|
||||||
rl.question(
|
if (process.stdin.isTTY !== true) {
|
||||||
" Enter files to stage (e.g. 1,3) or 'a' for all: ",
|
|
||||||
(answer) => {
|
|
||||||
rl.close();
|
|
||||||
const trimmed = answer.trim().toLowerCase();
|
|
||||||
|
|
||||||
if (trimmed === "a" || trimmed === "all") {
|
|
||||||
resolve(unstagedFiles.map((f) => f.path));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmed === "") {
|
|
||||||
resolve([]);
|
resolve([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const indices = trimmed
|
const wasRaw = process.stdin.isRaw;
|
||||||
.split(/[,\s]+/)
|
if (wasRaw !== true) {
|
||||||
.map((s) => parseInt(s.trim()))
|
process.stdin.setRawMode(true);
|
||||||
.filter(
|
}
|
||||||
(n) => !isNaN(n) && n >= 1 && n <= unstagedFiles.length,
|
process.stdin.resume();
|
||||||
)
|
hideCursor();
|
||||||
.map((n) => n - 1);
|
|
||||||
|
|
||||||
const uniqueIndices = [...new Set(indices)];
|
render();
|
||||||
resolve(uniqueIndices.map((i) => unstagedFiles[i]!.path));
|
|
||||||
},
|
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`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
showCursor();
|
||||||
|
resolve(selected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stdin.on("data", onData);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user