feat(refactor): premium zed-style chat UI, collapsible reasoning, and comprehensive strict type sweeps
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
|
||||
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || "";
|
||||
const GEMINI_MODEL = process.env.VIBN_CHAT_MODEL || "gemini-3.1-pro-preview";
|
||||
|
||||
// Add a clear visual log so we always know exactly which model is active in the terminal
|
||||
console.log(`[GeminiChat] Initialized — Model: ${GEMINI_MODEL}`);
|
||||
|
||||
if (!GEMINI_API_KEY) {
|
||||
console.warn(`[GeminiChat] WARNING: GOOGLE_API_KEY is not set. Chat stream will fail with 403 Forbidden.`);
|
||||
console.warn(
|
||||
`[GeminiChat] WARNING: GOOGLE_API_KEY is not set. Chat stream will fail with 403 Forbidden.`,
|
||||
);
|
||||
}
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
|
||||
@@ -38,18 +43,24 @@ export interface ChatChunk {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type GeminiPart = Record<string, unknown>;
|
||||
type GeminiContent = {
|
||||
role: string;
|
||||
parts: GeminiPart[];
|
||||
};
|
||||
|
||||
function toGeminiContents(messages: ChatMessage[]) {
|
||||
const contents: any[] = [];
|
||||
const contents: GeminiContent[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
contents.push({ role: "user", parts: [{ text: msg.content }] });
|
||||
} else if (msg.role === "assistant") {
|
||||
const parts: any[] = [];
|
||||
const parts: GeminiPart[] = [];
|
||||
if (msg.content) parts.push({ text: msg.content });
|
||||
if (msg.toolCalls?.length) {
|
||||
for (const tc of msg.toolCalls) {
|
||||
const part: any = {
|
||||
const part: GeminiPart = {
|
||||
functionCall: { name: tc.name, args: tc.args },
|
||||
};
|
||||
if (tc.thoughtSignature) {
|
||||
@@ -84,7 +95,7 @@ function toGeminiFunctions(tools: ToolDefinition[]) {
|
||||
functionDeclarations: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters as any,
|
||||
parameters: t.parameters as Record<string, unknown>,
|
||||
})),
|
||||
},
|
||||
];
|
||||
@@ -104,16 +115,14 @@ export async function callGeminiChat(opts: {
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const config: any = {
|
||||
const config: Record<string, unknown> = {
|
||||
temperature: opts.temperature ?? 0.7,
|
||||
maxOutputTokens: 8192,
|
||||
};
|
||||
|
||||
if (opts.systemPrompt) {
|
||||
config.systemInstruction = opts.systemPrompt;
|
||||
}
|
||||
|
||||
|
||||
if (opts.systemPrompt) {
|
||||
config.systemInstruction = opts.systemPrompt;
|
||||
}
|
||||
|
||||
const fns = toGeminiFunctions(opts.tools ?? []);
|
||||
if (fns) config.tools = fns;
|
||||
@@ -121,37 +130,37 @@ export async function callGeminiChat(opts: {
|
||||
const response = await ai.models.generateContent({
|
||||
model: GEMINI_MODEL,
|
||||
contents: toGeminiContents(opts.messages),
|
||||
config
|
||||
config,
|
||||
});
|
||||
|
||||
let text = "";
|
||||
let thoughts = "";
|
||||
const toolCalls: ToolCall[] = [];
|
||||
|
||||
|
||||
const parts = response.candidates?.[0]?.content?.parts ?? [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.text) {
|
||||
if ((part as any).thought) thoughts += part.text;
|
||||
if ((part as { thought?: unknown }).thought) thoughts += part.text;
|
||||
else text += part.text;
|
||||
}
|
||||
if (part.functionCall) {
|
||||
toolCalls.push({
|
||||
id: `tc-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
name: part.functionCall.name,
|
||||
args: part.functionCall.args as Record<string, unknown> ?? {},
|
||||
thoughtSignature: (part as any).thoughtSignature,
|
||||
args: (part.functionCall.args as Record<string, unknown>) ?? {},
|
||||
thoughtSignature: (part as { thoughtSignature?: string })
|
||||
.thoughtSignature,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
thoughts,
|
||||
toolCalls,
|
||||
finishReason: response.candidates?.[0]?.finishReason
|
||||
return {
|
||||
text,
|
||||
thoughts,
|
||||
toolCalls,
|
||||
finishReason: response.candidates?.[0]?.finishReason,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
text: "",
|
||||
@@ -169,14 +178,13 @@ export async function* streamGeminiChat(opts: {
|
||||
temperature?: number;
|
||||
}): AsyncGenerator<ChatChunk> {
|
||||
try {
|
||||
const config: any = {
|
||||
const config: Record<string, unknown> = {
|
||||
temperature: opts.temperature ?? 0.7,
|
||||
maxOutputTokens: 8192,
|
||||
|
||||
};
|
||||
|
||||
if (opts.systemPrompt) {
|
||||
config.systemInstruction = opts.systemPrompt;
|
||||
config.systemInstruction = opts.systemPrompt;
|
||||
}
|
||||
|
||||
const fns = toGeminiFunctions(opts.tools ?? []);
|
||||
@@ -185,14 +193,14 @@ export async function* streamGeminiChat(opts: {
|
||||
const streamResult = await ai.models.generateContentStream({
|
||||
model: GEMINI_MODEL,
|
||||
contents: toGeminiContents(opts.messages),
|
||||
config
|
||||
config,
|
||||
});
|
||||
|
||||
for await (const chunk of streamResult) {
|
||||
const parts = chunk.candidates?.[0]?.content?.parts ?? [];
|
||||
for (const part of parts) {
|
||||
if (part.text) {
|
||||
yield (part as any).thought
|
||||
yield (part as { thought?: unknown }).thought
|
||||
? { type: "thinking", text: part.text }
|
||||
: { type: "text", text: part.text };
|
||||
}
|
||||
@@ -200,7 +208,6 @@ export async function* streamGeminiChat(opts: {
|
||||
}
|
||||
|
||||
yield { type: "done" };
|
||||
|
||||
} catch (error) {
|
||||
yield {
|
||||
type: "error",
|
||||
|
||||
@@ -289,17 +289,24 @@ export async function callOpenAiCompatibleChat(opts: {
|
||||
if (tools?.length) body.tools = tools;
|
||||
|
||||
// ── Request logging (DeepSeek 400 debug) ──────────────────────────────
|
||||
const msgSummary = oaiMessages.map((m: any) => ({
|
||||
role: m.role,
|
||||
has_tool_calls:
|
||||
m.role === "assistant" ? Boolean(m.tool_calls?.length) : undefined,
|
||||
tool_calls_ids:
|
||||
m.role === "assistant" && m.tool_calls?.length
|
||||
? m.tool_calls.map((tc: any) => tc.id)
|
||||
: undefined,
|
||||
tool_call_id: m.role === "tool" ? m.tool_call_id : undefined,
|
||||
content_len: typeof m.content === "string" ? m.content.length : 0,
|
||||
}));
|
||||
const msgSummary = oaiMessages.map(
|
||||
(m: {
|
||||
role: string;
|
||||
content?: unknown;
|
||||
tool_calls?: Array<{ id: string }>;
|
||||
tool_call_id?: string;
|
||||
}) => ({
|
||||
role: m.role,
|
||||
has_tool_calls:
|
||||
m.role === "assistant" ? Boolean(m.tool_calls?.length) : undefined,
|
||||
tool_calls_ids:
|
||||
m.role === "assistant" && m.tool_calls?.length
|
||||
? m.tool_calls.map((tc: { id: string }) => tc.id)
|
||||
: undefined,
|
||||
tool_call_id: m.role === "tool" ? m.tool_call_id : undefined,
|
||||
content_len: typeof m.content === "string" ? m.content.length : 0,
|
||||
}),
|
||||
);
|
||||
console.error(
|
||||
"[deepseek] request",
|
||||
JSON.stringify({
|
||||
@@ -309,7 +316,7 @@ export async function callOpenAiCompatibleChat(opts: {
|
||||
has_tools: Boolean(tools?.length),
|
||||
tool_count: tools?.length ?? 0,
|
||||
msg_summary: msgSummary,
|
||||
last_5_roles: msgSummary.slice(-5).map((m: any) => m.role),
|
||||
last_5_roles: msgSummary.slice(-5).map((m: { role: string }) => m.role),
|
||||
}),
|
||||
);
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1675,28 +1675,35 @@ After this returns, ALWAYS call apps_deploy { uuid } to regenerate the live Trae
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "plan_decision_log",
|
||||
name: "plan_document_update",
|
||||
description:
|
||||
"Log a decision the user has made. Call this PROACTIVELY whenever a non-trivial choice gets settled in conversation (database engine, auth approach, framework, pricing model, copy, branding…) — so it shows up in the Plan tab and you stop re-asking it next session. Don't ask permission; log it and move on.",
|
||||
"Overwrite the content of one of the Blueprint documents in the Plan tab. These documents define the specifications for the product. ALWAYS use this instead of `fs_write` when a user asks you to update the PRD, Spec, or Architecture plan.",
|
||||
parameters: {
|
||||
type: "OBJECT",
|
||||
properties: {
|
||||
projectId: { type: "STRING", description: "The Vibn project ID." },
|
||||
title: {
|
||||
projectId: { type: "STRING" },
|
||||
docId: {
|
||||
type: "STRING",
|
||||
description: "The specific document to update.",
|
||||
enum: [
|
||||
"stories",
|
||||
"acceptance",
|
||||
"success",
|
||||
"ui_design",
|
||||
"tech_context",
|
||||
"data_model",
|
||||
"file_structure",
|
||||
"tasks",
|
||||
"checklist",
|
||||
],
|
||||
},
|
||||
content: {
|
||||
type: "STRING",
|
||||
description:
|
||||
'Short topic of the decision (e.g. "Database engine", "Auth provider").',
|
||||
},
|
||||
choice: {
|
||||
type: "STRING",
|
||||
description: 'What was chosen (e.g. "Postgres", "Stripe Checkout").',
|
||||
},
|
||||
why: {
|
||||
type: "STRING",
|
||||
description: "Optional 1-2 sentence reasoning. Strongly recommended.",
|
||||
"The full markdown content for the document. This will completely overwrite the existing document.",
|
||||
},
|
||||
},
|
||||
required: ["projectId", "title", "choice"],
|
||||
required: ["projectId", "docId", "content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
81
vibn-frontend/lib/email/mailgun.ts
Normal file
81
vibn-frontend/lib/email/mailgun.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import Mailgun from "mailgun.js";
|
||||
import formData from "form-data";
|
||||
import mjml2html from "mjml";
|
||||
|
||||
const mailgun = new Mailgun(formData);
|
||||
|
||||
const API_KEY = process.env.MAILGUN_API_KEY || "";
|
||||
const DOMAIN = process.env.MAILGUN_DOMAIN || "";
|
||||
const API_URL = process.env.MAILGUN_API_URL || "https://api.mailgun.net";
|
||||
|
||||
// Initialize the Mailgun client
|
||||
const mg = mailgun.client({
|
||||
username: "api",
|
||||
key: API_KEY,
|
||||
url: API_URL,
|
||||
});
|
||||
|
||||
export interface SendEmailOptions {
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
mjml?: string; // Optional: If provided, this will be compiled to HTML and override the `html` field.
|
||||
from?: string; // Optional: If not provided, defaults to the Mailgun system domain
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an email using the Mailgun API.
|
||||
* If `mjml` is provided, it compiles it to responsive HTML before sending.
|
||||
*/
|
||||
export async function sendEmail(options: SendEmailOptions) {
|
||||
// Use the provided from address, or fallback to a default on the live domain
|
||||
const fromAddress = options.from || `Vibn AI <mailgun@${DOMAIN}>`;
|
||||
|
||||
// Format the 'to' address
|
||||
const toAddresses = Array.isArray(options.to)
|
||||
? options.to.join(",")
|
||||
: options.to;
|
||||
|
||||
// Process MJML if it exists
|
||||
let finalHtml = options.html;
|
||||
if (options.mjml) {
|
||||
try {
|
||||
const compiled = mjml2html(options.mjml, {
|
||||
validationLevel: "soft", // Warn on errors but try to compile anyway
|
||||
minify: true,
|
||||
});
|
||||
if (compiled.errors && compiled.errors.length > 0) {
|
||||
console.warn("[MJML Compiler Warnings]:", compiled.errors);
|
||||
}
|
||||
finalHtml = compiled.html;
|
||||
} catch (e) {
|
||||
console.error("[MJML Compiler Error]:", e);
|
||||
// Fallback to plain text if MJML fails catastrophically
|
||||
finalHtml = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await mg.messages.create(DOMAIN, {
|
||||
from: fromAddress,
|
||||
to: toAddresses,
|
||||
subject: options.subject,
|
||||
text: options.text,
|
||||
html: finalHtml,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id: response.id,
|
||||
message: response.message,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("[Mailgun Error]:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || "Failed to send email via Mailgun",
|
||||
status: error.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user