diff --git a/src/index.ts b/src/index.ts index dded459..9f9050c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -486,6 +486,7 @@ async function reviewWithAi( body: JSON.stringify({ model: env.AI_MODEL || "gpt-4.1-mini", temperature: 0, + stream: false, messages: [ { role: "system", @@ -520,16 +521,8 @@ async function reviewWithAi( throw new HttpError(502, `ai_request_failed:${response.status}:${details.slice(0, 500)}`); } - const payload = (await response.json()) as { - choices?: Array<{ message?: { content?: string | Array<{ type: string; text?: string }> } }>; - }; - const rawContent = payload.choices?.[0]?.message?.content; - const text = Array.isArray(rawContent) - ? rawContent - .map((item) => item.text ?? "") - .join("\n") - .trim() - : rawContent?.trim(); + const responseText = await response.text(); + const text = extractAiCompletionText(responseText); if (!text) { throw new HttpError(502, "ai_empty_response"); @@ -552,7 +545,10 @@ async function safelyReviewWithAi( ): Promise { try { return await reviewWithAi(env, input); - } catch { + } catch (error) { + console.warn("AI review unavailable", { + error: error instanceof Error ? error.message : String(error), + }); return null; } } @@ -801,6 +797,11 @@ async function approveJoinRequest(env: Env, chatId: number, userId: number): Pro return; } + if (error instanceof HttpError && error.message.includes("HIDE_REQUESTER_MISSING")) { + console.log("join request missing when approving", { chatId, userId }); + return; + } + throw error; } } @@ -1151,18 +1152,83 @@ function escapeHtml(value: string): string { } function parseJsonFromText(value: string): unknown { + const cleaned = value + .trim() + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim(); + try { - return JSON.parse(value); + return JSON.parse(cleaned); } catch { - const start = value.indexOf("{"); - const end = value.lastIndexOf("}"); + const start = cleaned.indexOf("{"); + const end = cleaned.lastIndexOf("}"); if (start === -1 || end === -1 || end <= start) { throw new HttpError(502, "ai_invalid_json"); } - return JSON.parse(value.slice(start, end + 1)); + return JSON.parse(cleaned.slice(start, end + 1)); } } +function extractAiCompletionText(responseText: string): string | undefined { + const trimmed = responseText.trim(); + if (!trimmed) { + return undefined; + } + + if (trimmed.startsWith("data:")) { + let output = ""; + for (const line of trimmed.split(/\r?\n/)) { + const current = line.trim(); + if (!current.startsWith("data:")) { + continue; + } + + const data = current.slice(5).trim(); + if (!data || data === "[DONE]") { + continue; + } + + const chunk = JSON.parse(data) as { + choices?: Array<{ delta?: { content?: string }; message?: { content?: unknown } }>; + }; + output += chunk.choices?.[0]?.delta?.content ?? extractAiText(chunk.choices?.[0]?.message?.content) ?? ""; + } + + return output.trim(); + } + + const payload = JSON.parse(trimmed) as { + choices?: Array<{ message?: { content?: unknown }; delta?: { content?: string } }>; + }; + return extractAiText(payload.choices?.[0]?.message?.content) ?? payload.choices?.[0]?.delta?.content?.trim(); +} + +function extractAiText(value: unknown): string | undefined { + if (typeof value === "string") { + return value.trim(); + } + + if (Array.isArray(value)) { + return value + .map((item) => { + if (typeof item === "string") return item; + if (item && typeof item === "object" && "text" in item && typeof item.text === "string") return item.text; + if (item && typeof item === "object" && "content" in item && typeof item.content === "string") return item.content; + return ""; + }) + .join("\n") + .trim(); + } + + if (value && typeof value === "object") { + if ("text" in value && typeof value.text === "string") return value.text.trim(); + if ("content" in value && typeof value.content === "string") return value.content.trim(); + } + + return undefined; +} + function json(payload: unknown, status = 200): Response { return new Response(JSON.stringify(payload), { status,