mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
* feat(ci): add AI-powered release notes generation - Add GitHub Models integration for automated release notes - Support bilingual (EN) release notes with highlights extraction - Fallback to conventional commit grouping when LLM unavailable - Trigger on tag push or manual workflow dispatch - Zero configuration: uses GitHub Models (gpt-4o-mini) by default * chore: pin action versions and update gitignore - Pin checkout and setup-deno to commit hashes matching project convention - Add .playwright-mcp/ and .mcp.json to gitignore
286 lines
7.1 KiB
TypeScript
286 lines
7.1 KiB
TypeScript
import { IssueLlmConfig } from "./issue-llm-types.ts";
|
|
|
|
interface OpenAiCompatibleResponse {
|
|
choices?: Array<{
|
|
message?: {
|
|
content?: string | Array<{ type?: string; text?: string }>;
|
|
};
|
|
}>;
|
|
}
|
|
|
|
export async function requestOpenAiCompatibleJson(
|
|
config: IssueLlmConfig,
|
|
systemPrompt: string,
|
|
userPrompt: string,
|
|
) {
|
|
let lastError: Error | null = null;
|
|
|
|
for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) {
|
|
try {
|
|
return await requestOnce(config, systemPrompt, userPrompt);
|
|
} catch (error) {
|
|
const normalized = normalizeRequestError(
|
|
error,
|
|
attempt,
|
|
config.maxAttempts,
|
|
);
|
|
lastError = normalized;
|
|
|
|
if (!shouldRetry(error) || attempt >= config.maxAttempts) {
|
|
throw normalized;
|
|
}
|
|
|
|
await sleep(resolveRetryDelay(error, config.retryBackoffMs, attempt));
|
|
}
|
|
}
|
|
|
|
throw lastError ?? new Error("LLM request failed for an unknown reason.");
|
|
}
|
|
|
|
export async function requestOpenAiCompatibleMarkdown(
|
|
config: IssueLlmConfig,
|
|
systemPrompt: string,
|
|
userPrompt: string,
|
|
): Promise<string> {
|
|
let lastError: Error | null = null;
|
|
|
|
for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) {
|
|
try {
|
|
return await requestOnceMarkdown(config, systemPrompt, userPrompt);
|
|
} catch (error) {
|
|
const normalized = normalizeRequestError(
|
|
error,
|
|
attempt,
|
|
config.maxAttempts,
|
|
);
|
|
lastError = normalized;
|
|
|
|
if (!shouldRetry(error) || attempt >= config.maxAttempts) {
|
|
throw normalized;
|
|
}
|
|
|
|
await sleep(resolveRetryDelay(error, config.retryBackoffMs, attempt));
|
|
}
|
|
}
|
|
|
|
throw lastError ?? new Error("LLM request failed for an unknown reason.");
|
|
}
|
|
|
|
async function requestOnce(
|
|
config: IssueLlmConfig,
|
|
systemPrompt: string,
|
|
userPrompt: string,
|
|
) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
|
|
try {
|
|
const response = await fetch(`${config.baseUrl}/chat/completions`, {
|
|
method: "POST",
|
|
signal: controller.signal,
|
|
headers: {
|
|
Authorization: `Bearer ${config.apiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
model: config.model,
|
|
temperature: config.temperature,
|
|
messages: [
|
|
{ role: "system", content: systemPrompt },
|
|
{ role: "user", content: userPrompt },
|
|
],
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const message =
|
|
`LLM request failed with status ${response.status}: ${await response
|
|
.text()}`;
|
|
throw new RetryableHttpError(
|
|
message,
|
|
response.status,
|
|
response.headers.get("retry-after"),
|
|
);
|
|
}
|
|
|
|
const payload = (await response.json()) as OpenAiCompatibleResponse;
|
|
const content = payload.choices?.[0]?.message?.content;
|
|
const text = normalizeMessageContent(content);
|
|
|
|
if (!text) {
|
|
throw new Error("LLM response did not include message content.");
|
|
}
|
|
|
|
return extractJsonObject(text);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
async function requestOnceMarkdown(
|
|
config: IssueLlmConfig,
|
|
systemPrompt: string,
|
|
userPrompt: string,
|
|
): Promise<string> {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
|
|
try {
|
|
const response = await fetch(`${config.baseUrl}/chat/completions`, {
|
|
method: "POST",
|
|
signal: controller.signal,
|
|
headers: {
|
|
Authorization: `Bearer ${config.apiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
model: config.model,
|
|
temperature: config.temperature,
|
|
messages: [
|
|
{ role: "system", content: systemPrompt },
|
|
{ role: "user", content: userPrompt },
|
|
],
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const message =
|
|
`LLM request failed with status ${response.status}: ${await response
|
|
.text()}`;
|
|
throw new RetryableHttpError(
|
|
message,
|
|
response.status,
|
|
response.headers.get("retry-after"),
|
|
);
|
|
}
|
|
|
|
const payload = (await response.json()) as OpenAiCompatibleResponse;
|
|
const content = payload.choices?.[0]?.message?.content;
|
|
const text = normalizeMessageContent(content);
|
|
|
|
if (!text) {
|
|
throw new Error("LLM response did not include message content.");
|
|
}
|
|
|
|
return text;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
class RetryableHttpError extends Error {
|
|
status: number;
|
|
retryAfterSeconds: number | null;
|
|
|
|
constructor(
|
|
message: string,
|
|
status: number,
|
|
retryAfterHeader: string | null,
|
|
) {
|
|
super(message);
|
|
this.name = "RetryableHttpError";
|
|
this.status = status;
|
|
this.retryAfterSeconds = parseRetryAfterSeconds(retryAfterHeader);
|
|
}
|
|
}
|
|
|
|
function shouldRetry(error: unknown) {
|
|
if (error instanceof RetryableHttpError) {
|
|
return error.status === 408 || error.status === 429 || error.status >= 500;
|
|
}
|
|
|
|
if (error instanceof DOMException && error.name === "AbortError") {
|
|
return true;
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
const message = error.message.toLowerCase();
|
|
return message.includes("network") || message.includes("connection");
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function normalizeRequestError(
|
|
error: unknown,
|
|
attempt: number,
|
|
maxAttempts: number,
|
|
) {
|
|
if (error instanceof DOMException && error.name === "AbortError") {
|
|
return new Error(
|
|
`LLM request timed out on attempt ${attempt}/${maxAttempts}.`,
|
|
);
|
|
}
|
|
|
|
if (error instanceof RetryableHttpError) {
|
|
return new Error(
|
|
`LLM request failed on attempt ${attempt}/${maxAttempts}: ${error.message}`,
|
|
);
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
return new Error(
|
|
`LLM request failed on attempt ${attempt}/${maxAttempts}: ${error.message}`,
|
|
);
|
|
}
|
|
|
|
return new Error(
|
|
`LLM request failed on attempt ${attempt}/${maxAttempts}: ${String(error)}`,
|
|
);
|
|
}
|
|
|
|
function resolveRetryDelay(
|
|
error: unknown,
|
|
retryBackoffMs: number,
|
|
attempt: number,
|
|
) {
|
|
if (error instanceof RetryableHttpError && error.retryAfterSeconds !== null) {
|
|
return error.retryAfterSeconds * 1000;
|
|
}
|
|
|
|
return retryBackoffMs * attempt;
|
|
}
|
|
|
|
function parseRetryAfterSeconds(value: string | null) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
const seconds = Number.parseInt(value, 10);
|
|
return Number.isNaN(seconds) ? null : Math.max(0, seconds);
|
|
}
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function normalizeMessageContent(
|
|
content: string | Array<{ type?: string; text?: string }> | undefined,
|
|
) {
|
|
if (!content) {
|
|
return "";
|
|
}
|
|
|
|
if (typeof content === "string") {
|
|
return content;
|
|
}
|
|
|
|
return content
|
|
.map((item) => item.text ?? "")
|
|
.join("\n")
|
|
.trim();
|
|
}
|
|
|
|
function extractJsonObject(text: string) {
|
|
const trimmed = text.trim();
|
|
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
const candidate = fenced?.[1]?.trim() ?? trimmed;
|
|
const start = candidate.indexOf("{");
|
|
const end = candidate.lastIndexOf("}");
|
|
|
|
if (start < 0 || end < 0 || end <= start) {
|
|
throw new Error(`LLM response did not contain a JSON object: ${trimmed}`);
|
|
}
|
|
|
|
return candidate.slice(start, end + 1);
|
|
}
|