fix(chat): gemini empty-answer fallback + empty-completion guard; chat routes accept workspace key

This commit is contained in:
2026-06-01 13:25:10 -07:00
parent ef0d84cf5f
commit 2d1691575f
4 changed files with 35 additions and 7 deletions

View File

@@ -397,9 +397,9 @@ export async function POST(request: Request) {
const principal = await requireWorkspacePrincipal(request);
if (principal instanceof NextResponse) return principal;
const userRow = await queryOne<{ data: any }>(
const userRow = await queryOne<{ data: { email?: string } }>(
`SELECT data FROM fs_users WHERE id = $1 LIMIT 1`,
[principal.userId]
[principal.userId],
);
if (!userRow?.data?.email) {
return NextResponse.json({ error: "Unauthorized user" }, { status: 401 });
@@ -1056,6 +1056,25 @@ export async function POST(request: Request) {
}
}
// Last-resort guard: the model produced NO user-facing text and NO
// tools (e.g. a "thinking" turn that returned only reasoning with an
// empty answer part). The tool-tray recovery above doesn't cover this
// case, so without this the user gets a silent blank bubble. Emit a
// short deterministic fallback so every turn says *something*.
if (
!aborted &&
assistantText.trim().length === 0 &&
!anyToolsExecuted
) {
const fallback =
"I didn't produce a response for that — I may have spent the turn " +
"reasoning without writing an answer. Could you rephrase or add a " +
"bit more detail?";
assistantText = fallback;
assistantTextSegments.push(fallback);
emit({ type: "text", text: fallback });
}
// Persist final assistant message. We include `textSegments`
// alongside the legacy concatenated `content` so the client
// can render reloaded threads with the same per-round bubble

View File

@@ -11,7 +11,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
const principal = await requireWorkspacePrincipal(request);
if (principal instanceof NextResponse) return principal;
const userRow = await queryOne<{ data: any }>(
const userRow = await queryOne<{ data: { email?: string } }>(
`SELECT data FROM fs_users WHERE id = $1 LIMIT 1`,
[principal.userId]
);
@@ -42,7 +42,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const principal = await requireWorkspacePrincipal(request);
if (principal instanceof NextResponse) return principal;
const userRow = await queryOne<{ data: any }>(
const userRow = await queryOne<{ data: { email?: string } }>(
`SELECT data FROM fs_users WHERE id = $1 LIMIT 1`,
[principal.userId]
);
@@ -64,7 +64,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i
const principal = await requireWorkspacePrincipal(request);
if (principal instanceof NextResponse) return principal;
const userRow = await queryOne<{ data: any }>(
const userRow = await queryOne<{ data: { email?: string } }>(
`SELECT data FROM fs_users WHERE id = $1 LIMIT 1`,
[principal.userId]
);

View File

@@ -57,7 +57,7 @@ export async function GET(request: Request) {
const principal = await requireWorkspacePrincipal(request);
if (principal instanceof NextResponse) return principal;
const userRow = await queryOne<{ data: any }>(
const userRow = await queryOne<{ data: { email?: string } }>(
`SELECT data FROM fs_users WHERE id = $1 LIMIT 1`,
[principal.userId]
);
@@ -120,7 +120,7 @@ export async function POST(request: Request) {
const principal = await requireWorkspacePrincipal(request);
if (principal instanceof NextResponse) return principal;
const userRow = await queryOne<{ data: any }>(
const userRow = await queryOne<{ data: { email?: string } }>(
`SELECT data FROM fs_users WHERE id = $1 LIMIT 1`,
[principal.userId]
);

View File

@@ -155,6 +155,15 @@ export async function callGeminiChat(opts: {
}
}
// Empty-answer fallback: Gemini "thinking" responses can spend the whole
// turn emitting `thought` parts and return NO answer part — leaving `text`
// empty. Mirror the OpenAI-compatible adapter (which promotes
// reasoning_content when content is empty) so the user never gets a blank
// bubble. Only promote when there's also no tool call to render.
if (!text.trim() && thoughts.trim() && toolCalls.length === 0) {
text = thoughts.trim();
}
return {
text,
thoughts,