Initial commit

This commit is contained in:
ilya-bov
2026-02-25 16:14:15 +03:00
commit 75ab0cecf0
254 changed files with 113531 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from "next/server";
import { getSettings, saveSettings } from "@/lib/storage/settings-store";
import { hashPassword } from "@/lib/auth/password";
import {
AUTH_COOKIE_NAME,
createSessionToken,
getSessionCookieOptionsForRequest,
isRequestSecure,
verifySessionToken,
} from "@/lib/auth/session";
interface CredentialsBody {
username?: unknown;
password?: unknown;
}
function normalizeUsername(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function normalizePassword(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function validateUsername(username: string): string | null {
if (username.length < 3) {
return "Username must be at least 3 characters.";
}
if (username.length > 64) {
return "Username must be at most 64 characters.";
}
if (!/^[a-zA-Z0-9._-]+$/.test(username)) {
return "Username may contain only letters, numbers, dots, underscores, and hyphens.";
}
return null;
}
function validatePassword(password: string): string | null {
if (password.length < 8) {
return "Password must be at least 8 characters.";
}
if (password.length > 128) {
return "Password must be at most 128 characters.";
}
return null;
}
export async function PUT(req: NextRequest) {
const token = req.cookies.get(AUTH_COOKIE_NAME)?.value || "";
const session = token ? await verifySessionToken(token) : null;
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = (await req.json()) as CredentialsBody;
const username = normalizeUsername(body.username);
const password = normalizePassword(body.password);
const usernameError = validateUsername(username);
if (usernameError) {
return Response.json({ error: usernameError }, { status: 400 });
}
const passwordError = validatePassword(password);
if (passwordError) {
return Response.json({ error: passwordError }, { status: 400 });
}
const current = await getSettings();
await saveSettings({
auth: {
...current.auth,
username,
passwordHash: hashPassword(password),
mustChangeCredentials: false,
},
});
const nextToken = await createSessionToken(username, false);
const response = NextResponse.json({
success: true,
username,
mustChangeCredentials: false,
});
response.cookies.set(
AUTH_COOKIE_NAME,
nextToken,
getSessionCookieOptionsForRequest(isRequestSecure(req.url, req.headers))
);
return response;
} catch (error) {
return Response.json(
{
error: error instanceof Error ? error.message : "Failed to update credentials.",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { getSettings } from "@/lib/storage/settings-store";
import {
isDefaultAuthCredentials,
verifyPassword,
} from "@/lib/auth/password";
import {
AUTH_COOKIE_NAME,
createSessionToken,
getSessionCookieOptionsForRequest,
isRequestSecure,
} from "@/lib/auth/session";
interface LoginBody {
username?: unknown;
password?: unknown;
}
function toTrimmedString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as LoginBody;
const username = toTrimmedString(body.username);
const password = toTrimmedString(body.password);
if (!username || !password) {
return Response.json(
{ error: "Username and password are required." },
{ status: 400 }
);
}
const settings = await getSettings();
if (!settings.auth.enabled) {
return Response.json(
{ error: "Authentication is disabled." },
{ status: 403 }
);
}
const userMatches = username === settings.auth.username;
const passwordMatches = verifyPassword(password, settings.auth.passwordHash);
if (!userMatches || !passwordMatches) {
return Response.json({ error: "Invalid credentials." }, { status: 401 });
}
const mustChangeCredentials = isDefaultAuthCredentials(
settings.auth.username,
settings.auth.passwordHash
);
const token = await createSessionToken(username, mustChangeCredentials);
const response = NextResponse.json({
success: true,
mustChangeCredentials,
});
response.cookies.set(
AUTH_COOKIE_NAME,
token,
getSessionCookieOptionsForRequest(isRequestSecure(req.url, req.headers))
);
return response;
} catch (error) {
return Response.json(
{
error: error instanceof Error ? error.message : "Login failed.",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import {
AUTH_COOKIE_NAME,
getClearedSessionCookieOptions,
isRequestSecure,
} from "@/lib/auth/session";
export async function POST(req: NextRequest) {
const response = NextResponse.json({ success: true });
response.cookies.set(
AUTH_COOKIE_NAME,
"",
getClearedSessionCookieOptions(isRequestSecure(req.url, req.headers))
);
return response;
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { getSettings } from "@/lib/storage/settings-store";
import { isDefaultAuthCredentials } from "@/lib/auth/password";
import {
AUTH_COOKIE_NAME,
getClearedSessionCookieOptions,
isRequestSecure,
verifySessionToken,
} from "@/lib/auth/session";
export async function GET(req: NextRequest) {
const token = req.cookies.get(AUTH_COOKIE_NAME)?.value || "";
const session = token ? await verifySessionToken(token) : null;
if (!session) {
const response = NextResponse.json(
{ authenticated: false, username: null, mustChangeCredentials: false },
{ status: 401 }
);
if (token) {
response.cookies.set(
AUTH_COOKIE_NAME,
"",
getClearedSessionCookieOptions(isRequestSecure(req.url, req.headers))
);
}
return response;
}
const settings = await getSettings();
const mustChangeCredentials =
session.mustChangeCredentials ||
isDefaultAuthCredentials(settings.auth.username, settings.auth.passwordHash);
return Response.json({
authenticated: true,
username: session.username,
mustChangeCredentials,
});
}

View File

@@ -0,0 +1,102 @@
import { NextRequest } from "next/server";
import {
getChatFiles,
saveChatFile,
deleteChatFile,
} from "@/lib/storage/chat-files-store";
/**
* GET /api/chat/files?chatId=xxx
* List all files uploaded to a chat
*/
export async function GET(req: NextRequest) {
const chatId = req.nextUrl.searchParams.get("chatId");
if (!chatId) {
return Response.json(
{ error: "chatId is required" },
{ status: 400 }
);
}
try {
const files = await getChatFiles(chatId);
return Response.json({ files });
} catch (error) {
console.error("Error getting chat files:", error);
return Response.json(
{ error: "Failed to get chat files" },
{ status: 500 }
);
}
}
/**
* POST /api/chat/files
* Upload a file to a chat (multipart/form-data)
*/
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const chatId = formData.get("chatId") as string;
const file = formData.get("file") as File | null;
if (!chatId) {
return Response.json(
{ error: "chatId is required" },
{ status: 400 }
);
}
if (!file) {
return Response.json(
{ error: "file is required" },
{ status: 400 }
);
}
const buffer = Buffer.from(await file.arrayBuffer());
const savedFile = await saveChatFile(chatId, buffer, file.name);
return Response.json({ file: savedFile });
} catch (error) {
console.error("Error uploading chat file:", error);
return Response.json(
{ error: "Failed to upload file" },
{ status: 500 }
);
}
}
/**
* DELETE /api/chat/files?chatId=xxx&filename=yyy
* Delete a file from a chat
*/
export async function DELETE(req: NextRequest) {
const chatId = req.nextUrl.searchParams.get("chatId");
const filename = req.nextUrl.searchParams.get("filename");
if (!chatId || !filename) {
return Response.json(
{ error: "chatId and filename are required" },
{ status: 400 }
);
}
try {
const deleted = await deleteChatFile(chatId, filename);
if (!deleted) {
return Response.json(
{ error: "File not found" },
{ status: 404 }
);
}
return Response.json({ success: true });
} catch (error) {
console.error("Error deleting chat file:", error);
return Response.json(
{ error: "Failed to delete file" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,41 @@
import { NextRequest } from "next/server";
import { getAllChats, getChat, deleteChat } from "@/lib/storage/chat-store";
export async function GET(req: NextRequest) {
const chatId = req.nextUrl.searchParams.get("id");
if (chatId) {
const chat = await getChat(chatId);
if (!chat) {
return Response.json({ error: "Chat not found" }, { status: 404 });
}
return Response.json(chat);
}
const projectId = req.nextUrl.searchParams.get("projectId");
let chats = await getAllChats();
// Filter by project: "none" means global chats (no project),
// a project ID filters to that project's chats
if (projectId === "none") {
chats = chats.filter((c) => !c.projectId);
} else if (projectId) {
chats = chats.filter((c) => c.projectId === projectId);
}
return Response.json(chats);
}
export async function DELETE(req: NextRequest) {
const chatId = req.nextUrl.searchParams.get("id");
if (!chatId) {
return Response.json({ error: "Chat ID required" }, { status: 400 });
}
const deleted = await deleteChat(chatId);
if (!deleted) {
return Response.json({ error: "Chat not found" }, { status: 404 });
}
return Response.json({ success: true });
}

74
src/app/api/chat/route.ts Normal file
View File

@@ -0,0 +1,74 @@
import { NextRequest } from "next/server";
import { runAgent } from "@/lib/agent/agent";
import { createChat, getChat } from "@/lib/storage/chat-store";
import { ensureCronSchedulerStarted } from "@/lib/cron/runtime";
export const maxDuration = 300; // 5 min max for long agent runs
export async function POST(req: NextRequest) {
try {
await ensureCronSchedulerStarted();
const body = await req.json();
const { chatId, projectId, currentPath } = body;
let message: string | undefined = body.message;
// Support AI SDK's DefaultChatTransport format which sends a `messages` array
if (!message && Array.isArray(body.messages)) {
const lastUserMsg = [...body.messages]
.reverse()
.find((m: Record<string, unknown>) => m.role === "user");
if (lastUserMsg) {
if (typeof lastUserMsg.content === "string") {
message = lastUserMsg.content;
} else if (Array.isArray(lastUserMsg.parts)) {
message = lastUserMsg.parts
.filter((p: Record<string, unknown>) => p.type === "text")
.map((p: Record<string, string>) => p.text)
.join("");
}
}
}
if (!message || typeof message !== "string") {
return Response.json(
{ error: "Message is required" },
{ status: 400 }
);
}
// Create chat if needed
let resolvedChatId = chatId;
if (!resolvedChatId) {
resolvedChatId = crypto.randomUUID();
await createChat(resolvedChatId, "New Chat", projectId);
} else {
const existing = await getChat(resolvedChatId);
if (!existing) {
await createChat(resolvedChatId, "New Chat", projectId);
}
}
// Run agent and return streaming response
const result = await runAgent({
chatId: resolvedChatId,
userMessage: message,
projectId,
currentPath: typeof currentPath === "string" ? currentPath : undefined,
});
return result.toUIMessageStreamResponse({
headers: {
"X-Chat-Id": resolvedChatId,
},
});
} catch (error) {
console.error("Chat API error:", error);
return Response.json(
{
error:
error instanceof Error ? error.message : "Internal server error",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,79 @@
import { NextRequest } from "next/server";
import { subscribeUiSyncEvents } from "@/lib/realtime/event-bus";
import type { UiSyncEvent } from "@/lib/realtime/types";
export const dynamic = "force-dynamic";
function encodeSseEvent<T>(eventName: string, payload: T): Uint8Array {
const body =
`event: ${eventName}\n` +
`data: ${JSON.stringify(payload)}\n\n`;
return new TextEncoder().encode(body);
}
function encodeSseComment(comment: string): Uint8Array {
return new TextEncoder().encode(`: ${comment}\n\n`);
}
export async function GET(req: NextRequest) {
let heartbeat: ReturnType<typeof setInterval> | null = null;
let unsubscribe: (() => void) | null = null;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const sendSync = (payload: UiSyncEvent) => {
controller.enqueue(encodeSseEvent("sync", payload));
};
controller.enqueue(
encodeSseEvent("ready", {
at: new Date().toISOString(),
})
);
unsubscribe = subscribeUiSyncEvents(sendSync);
heartbeat = setInterval(() => {
controller.enqueue(encodeSseComment(`ping ${Date.now()}`));
}, 15000);
const onAbort = () => {
if (heartbeat) {
clearInterval(heartbeat);
heartbeat = null;
}
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
try {
controller.close();
} catch {
// Stream may already be closed.
}
};
req.signal.addEventListener("abort", onAbort, { once: true });
},
cancel() {
if (heartbeat) {
clearInterval(heartbeat);
heartbeat = null;
}
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

92
src/app/api/external/message/route.ts vendored Normal file
View File

@@ -0,0 +1,92 @@
import { NextRequest } from "next/server";
import { timingSafeEqual } from "node:crypto";
import {
ExternalMessageError,
handleExternalMessage,
} from "@/lib/external/handle-external-message";
import { getExternalApiToken } from "@/lib/storage/external-api-token-store";
interface ExternalMessageBody {
sessionId?: unknown;
message?: unknown;
projectId?: unknown;
chatId?: unknown;
currentPath?: unknown;
}
function parseBearerToken(req: NextRequest): string | null {
const header = req.headers.get("authorization");
if (!header) return null;
const [scheme, token] = header.trim().split(/\s+/, 2);
if (!scheme || scheme.toLowerCase() !== "bearer" || !token) {
return null;
}
return token;
}
function safeTokenMatch(actual: string, expected: string): boolean {
const actualBytes = Buffer.from(actual);
const expectedBytes = Buffer.from(expected);
if (actualBytes.length !== expectedBytes.length) {
return false;
}
return timingSafeEqual(actualBytes, expectedBytes);
}
export async function POST(req: NextRequest) {
try {
const storedToken = await getExternalApiToken();
const envToken = process.env.EXTERNAL_API_TOKEN?.trim();
const expectedToken = storedToken || envToken;
if (!expectedToken) {
return Response.json(
{
error:
"External API token is not configured. Set EXTERNAL_API_TOKEN or generate token in API page.",
},
{ status: 503 }
);
}
const providedToken = parseBearerToken(req);
if (!providedToken || !safeTokenMatch(providedToken, expectedToken)) {
return Response.json(
{ error: "Unauthorized" },
{
status: 401,
headers: {
"WWW-Authenticate": 'Bearer realm="external-message"',
},
}
);
}
const body = (await req.json()) as ExternalMessageBody;
const result = await handleExternalMessage({
sessionId:
typeof body.sessionId === "string" ? body.sessionId : "",
message: typeof body.message === "string" ? body.message : "",
projectId:
typeof body.projectId === "string" ? body.projectId : undefined,
chatId: typeof body.chatId === "string" ? body.chatId : undefined,
currentPath:
typeof body.currentPath === "string" ? body.currentPath : undefined,
});
return Response.json(result);
} catch (error) {
if (error instanceof ExternalMessageError) {
return Response.json(error.payload, { status: error.status });
}
return Response.json(
{
error: error instanceof Error ? error.message : "Internal server error",
},
{ status: 500 }
);
}
}

64
src/app/api/external/token/route.ts vendored Normal file
View File

@@ -0,0 +1,64 @@
import {
generateExternalApiToken,
getExternalApiTokenStatus,
maskExternalApiToken,
saveExternalApiToken,
} from "@/lib/storage/external-api-token-store";
function resolveEnvToken(): string | null {
const envToken = process.env.EXTERNAL_API_TOKEN?.trim();
return envToken || null;
}
export async function GET() {
const storedStatus = await getExternalApiTokenStatus();
if (storedStatus.configured) {
return Response.json({
configured: true,
source: "stored" as const,
maskedToken: storedStatus.maskedToken,
updatedAt: storedStatus.updatedAt,
});
}
const envToken = resolveEnvToken();
if (envToken) {
return Response.json({
configured: true,
source: "env" as const,
maskedToken: maskExternalApiToken(envToken),
updatedAt: null as string | null,
});
}
return Response.json({
configured: false,
source: "none" as const,
maskedToken: null,
updatedAt: null as string | null,
});
}
export async function POST() {
try {
const token = generateExternalApiToken();
await saveExternalApiToken(token);
return Response.json({
success: true,
token,
maskedToken: maskExternalApiToken(token),
source: "stored" as const,
});
} catch (error) {
return Response.json(
{
error:
error instanceof Error
? error.message
: "Failed to generate token",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest } from "next/server";
import fs from "fs/promises";
import path from "path";
import { getWorkDir } from "@/lib/storage/project-store";
export async function GET(req: NextRequest) {
const projectId = req.nextUrl.searchParams.get("project");
const filePath = req.nextUrl.searchParams.get("path");
if (!projectId || !filePath) {
return Response.json(
{ error: "Project ID and file path required" },
{ status: 400 }
);
}
const workDir = getWorkDir(projectId);
const fullPath = path.join(workDir, filePath);
// Security check
const resolvedPath = path.resolve(fullPath);
const resolvedWorkDir = path.resolve(workDir);
if (!resolvedPath.startsWith(resolvedWorkDir)) {
return Response.json(
{ error: "Invalid file path" },
{ status: 403 }
);
}
try {
const content = await fs.readFile(fullPath);
const fileName = path.basename(filePath);
return new Response(content, {
headers: {
"Content-Disposition": `attachment; filename="${fileName}"`,
"Content-Type": "application/octet-stream",
},
});
} catch {
return Response.json({ error: "File not found" }, { status: 404 });
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest } from "next/server";
import fs from "fs/promises";
import path from "path";
import { getProjectFiles, getWorkDir } from "@/lib/storage/project-store";
import { publishUiSyncEvent } from "@/lib/realtime/event-bus";
export async function GET(req: NextRequest) {
const projectId = req.nextUrl.searchParams.get("project");
const subPath = req.nextUrl.searchParams.get("path") || "";
if (!projectId) {
return Response.json(
{ error: "Project ID required" },
{ status: 400 }
);
}
const files = await getProjectFiles(projectId, subPath);
return Response.json(files);
}
export async function DELETE(req: NextRequest) {
const projectId = req.nextUrl.searchParams.get("project");
const filePath = req.nextUrl.searchParams.get("path");
if (!projectId || !filePath) {
return Response.json(
{ error: "Project ID and file path required" },
{ status: 400 }
);
}
const workDir = getWorkDir(projectId);
const fullPath = path.join(workDir, filePath);
// Security: ensure the path stays within the project directory
const resolvedPath = path.resolve(fullPath);
const resolvedWorkDir = path.resolve(workDir);
if (!resolvedPath.startsWith(resolvedWorkDir)) {
return Response.json(
{ error: "Invalid file path" },
{ status: 403 }
);
}
try {
const stat = await fs.stat(fullPath);
if (stat.isDirectory()) {
await fs.rm(fullPath, { recursive: true });
} else {
await fs.unlink(fullPath);
}
publishUiSyncEvent({
topic: "files",
projectId: projectId === "none" ? null : projectId,
reason: "file_deleted",
});
return Response.json({ success: true });
} catch {
return Response.json({ error: "File not found" }, { status: 404 });
}
}

View File

@@ -0,0 +1,7 @@
export async function GET() {
return Response.json({
status: "ok",
timestamp: new Date().toISOString(),
version: "0.1.0",
});
}

View File

@@ -0,0 +1,30 @@
import { NextRequest } from "next/server";
import { createTelegramAccessCode } from "@/lib/storage/telegram-integration-store";
export async function POST(req: NextRequest) {
try {
const body = (await req.json().catch(() => ({}))) as {
ttlMinutes?: unknown;
};
const code = await createTelegramAccessCode({
ttlMinutes: body.ttlMinutes,
});
return Response.json({
success: true,
code: code.code,
createdAt: code.createdAt,
expiresAt: code.expiresAt,
});
} catch (error) {
return Response.json(
{
error:
error instanceof Error
? error.message
: "Failed to generate Telegram access code",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,44 @@
import { NextRequest } from "next/server";
import {
getTelegramIntegrationPublicSettings,
saveTelegramIntegrationFromPublicInput,
} from "@/lib/storage/telegram-integration-store";
export async function GET() {
try {
const settings = await getTelegramIntegrationPublicSettings();
return Response.json(settings);
} catch (error) {
return Response.json(
{
error:
error instanceof Error
? error.message
: "Failed to load Telegram integration settings",
},
{ status: 500 }
);
}
}
export async function PUT(req: NextRequest) {
try {
const body = (await req.json()) as Record<string, unknown>;
await saveTelegramIntegrationFromPublicInput(body);
const settings = await getTelegramIntegrationPublicSettings();
return Response.json({
success: true,
...settings,
});
} catch (error) {
return Response.json(
{
error:
error instanceof Error
? error.message
: "Failed to save Telegram integration settings",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,93 @@
import {
getTelegramIntegrationPublicSettings,
getTelegramIntegrationRuntimeConfig,
getTelegramIntegrationStoredSettings,
saveTelegramIntegrationStoredSettings,
} from "@/lib/storage/telegram-integration-store";
interface TelegramApiResponse {
ok?: boolean;
description?: string;
}
function parseTelegramError(status: number, payload: TelegramApiResponse | null): string {
const description = payload?.description?.trim();
return description
? `Telegram API error (${status}): ${description}`
: `Telegram API error (${status})`;
}
async function deleteTelegramWebhook(botToken: string): Promise<void> {
const response = await fetch(`https://api.telegram.org/bot${botToken}/deleteWebhook`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
drop_pending_updates: false,
}),
});
const payload = (await response.json().catch(() => null)) as
| TelegramApiResponse
| null;
if (!response.ok || !payload?.ok) {
throw new Error(parseTelegramError(response.status, payload));
}
}
export async function POST() {
try {
const runtime = await getTelegramIntegrationRuntimeConfig();
const stored = await getTelegramIntegrationStoredSettings();
const botToken = runtime.botToken.trim();
let webhookRemoved = false;
let webhookWarning: string | null = null;
if (botToken) {
try {
await deleteTelegramWebhook(botToken);
webhookRemoved = true;
} catch (error) {
webhookWarning =
error instanceof Error
? error.message
: "Failed to remove Telegram webhook";
}
}
await saveTelegramIntegrationStoredSettings({
botToken: "",
webhookSecret: "",
publicBaseUrl: stored.publicBaseUrl,
defaultProjectId: stored.defaultProjectId,
});
const settings = await getTelegramIntegrationPublicSettings();
const note =
settings.sources.botToken === "env"
? "Token is still provided by .env. Remove TELEGRAM_BOT_TOKEN and TELEGRAM_WEBHOOK_SECRET to fully disconnect."
: null;
return Response.json({
success: true,
message: "Telegram disconnected",
webhookRemoved,
webhookWarning,
note,
settings,
});
} catch (error) {
return Response.json(
{
error:
error instanceof Error
? error.message
: "Failed to disconnect Telegram integration",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,742 @@
import { NextRequest } from "next/server";
import { timingSafeEqual } from "node:crypto";
import {
ExternalMessageError,
handleExternalMessage,
} from "@/lib/external/handle-external-message";
import {
createDefaultTelegramSessionId,
createFreshTelegramSessionId,
getTelegramChatSessionId,
setTelegramChatSessionId,
} from "@/lib/storage/telegram-session-store";
import {
claimTelegramUpdate,
releaseTelegramUpdate,
} from "@/lib/storage/telegram-update-store";
import {
consumeTelegramAccessCode,
getTelegramIntegrationRuntimeConfig,
normalizeTelegramUserId,
} from "@/lib/storage/telegram-integration-store";
import { saveChatFile } from "@/lib/storage/chat-files-store";
import { createChat, getChat } from "@/lib/storage/chat-store";
import {
contextKey,
type ExternalSession,
getOrCreateExternalSession,
saveExternalSession,
} from "@/lib/storage/external-session-store";
import { getAllProjects } from "@/lib/storage/project-store";
const TELEGRAM_TEXT_LIMIT = 4096;
const TELEGRAM_FILE_MAX_BYTES = 30 * 1024 * 1024;
interface TelegramUpdate {
update_id?: unknown;
message?: TelegramMessage;
}
interface TelegramMessage {
message_id?: unknown;
text?: unknown;
caption?: unknown;
from?: {
id?: unknown;
};
chat?: {
id?: unknown;
type?: unknown;
};
document?: {
file_id?: unknown;
file_name?: unknown;
mime_type?: unknown;
};
photo?: Array<{
file_id?: unknown;
width?: unknown;
height?: unknown;
}>;
audio?: {
file_id?: unknown;
file_name?: unknown;
mime_type?: unknown;
};
video?: {
file_id?: unknown;
file_name?: unknown;
mime_type?: unknown;
};
voice?: {
file_id?: unknown;
mime_type?: unknown;
};
}
interface TelegramApiResponse {
ok?: boolean;
description?: string;
result?: Record<string, unknown>;
}
interface TelegramIncomingFile {
fileId: string;
fileName: string;
}
interface TelegramExternalChatContext {
chatId: string;
projectId?: string;
currentPath: string;
}
function normalizeTelegramCurrentPath(rawPath: string | undefined): string {
const value = (rawPath ?? "").trim();
if (!value || value === "/telegram") {
return "";
}
return value;
}
interface TelegramResolvedProjectContext {
session: ExternalSession;
resolvedProjectId?: string;
projectName?: string;
}
function parseTelegramError(status: number, payload: TelegramApiResponse | null): string {
const description = payload?.description?.trim();
return description
? `Telegram API error (${status}): ${description}`
: `Telegram API error (${status})`;
}
async function callTelegramApi(
botToken: string,
method: string,
body?: Record<string, unknown>
): Promise<TelegramApiResponse> {
const response = await fetch(`https://api.telegram.org/bot${botToken}/${method}`, {
method: body ? "POST" : "GET",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const payload = (await response.json().catch(() => null)) as
| TelegramApiResponse
| null;
if (!response.ok || !payload?.ok) {
throw new Error(parseTelegramError(response.status, payload));
}
return payload;
}
function safeTokenMatch(actual: string, expected: string): boolean {
const actualBytes = Buffer.from(actual);
const expectedBytes = Buffer.from(expected);
if (actualBytes.length !== expectedBytes.length) {
return false;
}
return timingSafeEqual(actualBytes, expectedBytes);
}
function getBotId(botToken: string): string {
const [rawBotId] = botToken.trim().split(":", 1);
const botId = rawBotId?.trim() || "default";
return botId.replace(/[^a-zA-Z0-9._:-]/g, "_").slice(0, 128) || "default";
}
function chatBelongsToProject(
chatProjectId: string | undefined,
projectId: string | undefined
): boolean {
const left = chatProjectId ?? null;
const right = projectId ?? null;
return left === right;
}
async function ensureTelegramExternalChatContext(params: {
sessionId: string;
defaultProjectId?: string;
}): Promise<TelegramExternalChatContext> {
const { session, resolvedProjectId } = await resolveTelegramProjectContext({
sessionId: params.sessionId,
defaultProjectId: params.defaultProjectId,
});
const projectKey = contextKey(resolvedProjectId);
let resolvedChatId = session.activeChats[projectKey];
if (resolvedChatId) {
const existing = await getChat(resolvedChatId);
if (!existing || !chatBelongsToProject(existing.projectId, resolvedProjectId)) {
resolvedChatId = "";
}
}
if (!resolvedChatId) {
resolvedChatId = crypto.randomUUID();
await createChat(
resolvedChatId,
`External session ${session.id}`,
resolvedProjectId
);
}
session.activeChats[projectKey] = resolvedChatId;
session.currentPaths[projectKey] = normalizeTelegramCurrentPath(
session.currentPaths[projectKey]
);
session.updatedAt = new Date().toISOString();
await saveExternalSession(session);
return {
chatId: resolvedChatId,
projectId: resolvedProjectId,
currentPath: session.currentPaths[projectKey] ?? "",
};
}
async function resolveTelegramProjectContext(params: {
sessionId: string;
defaultProjectId?: string;
}): Promise<TelegramResolvedProjectContext> {
const session = await getOrCreateExternalSession(params.sessionId);
const projects = await getAllProjects();
const projectById = new Map(projects.map((project) => [project.id, project]));
let resolvedProjectId: string | undefined;
const explicitProjectId = params.defaultProjectId?.trim() || "";
if (explicitProjectId) {
if (!projectById.has(explicitProjectId)) {
throw new Error(`Project "${explicitProjectId}" not found`);
}
resolvedProjectId = explicitProjectId;
session.activeProjectId = explicitProjectId;
} else if (session.activeProjectId && projectById.has(session.activeProjectId)) {
resolvedProjectId = session.activeProjectId;
} else if (projects.length > 0) {
resolvedProjectId = projects[0].id;
session.activeProjectId = projects[0].id;
} else {
session.activeProjectId = null;
}
return {
session,
resolvedProjectId,
projectName: resolvedProjectId ? projectById.get(resolvedProjectId)?.name : undefined,
};
}
function extensionFromMime(mimeType: string): string {
const lower = mimeType.toLowerCase();
if (lower.includes("pdf")) return ".pdf";
if (lower.includes("png")) return ".png";
if (lower.includes("jpeg") || lower.includes("jpg")) return ".jpg";
if (lower.includes("webp")) return ".webp";
if (lower.includes("gif")) return ".gif";
if (lower.includes("mp4")) return ".mp4";
if (lower.includes("mpeg") || lower.includes("mp3")) return ".mp3";
if (lower.includes("ogg")) return ".ogg";
if (lower.includes("wav")) return ".wav";
if (lower.includes("plain")) return ".txt";
return "";
}
function buildIncomingFileName(params: {
base: string;
messageId?: number;
mimeType?: string;
}): string {
const suffix = params.messageId ?? Date.now();
const ext = params.mimeType ? extensionFromMime(params.mimeType) : "";
return `${params.base}-${suffix}${ext}`;
}
function sanitizeFileName(value: string): string {
const base = value.trim().replace(/[\\/]+/g, "_");
return base || `file-${Date.now()}`;
}
function withMessageIdPrefix(fileName: string, messageId?: number): string {
if (typeof messageId !== "number") return fileName;
return `${messageId}-${fileName}`;
}
function extractIncomingFile(
message: TelegramMessage,
messageId?: number
): TelegramIncomingFile | null {
const documentFileId =
typeof message.document?.file_id === "string"
? message.document.file_id.trim()
: "";
if (documentFileId) {
const docNameRaw =
typeof message.document?.file_name === "string"
? message.document.file_name
: "";
const fallback = buildIncomingFileName({
base: "document",
messageId,
mimeType:
typeof message.document?.mime_type === "string"
? message.document.mime_type
: undefined,
});
return {
fileId: documentFileId,
fileName: withMessageIdPrefix(sanitizeFileName(docNameRaw || fallback), messageId),
};
}
const photos: Array<{ file_id?: unknown }> = Array.isArray(message.photo)
? message.photo
: [];
for (let i = photos.length - 1; i >= 0; i -= 1) {
const photo = photos[i];
const fileId = typeof photo?.file_id === "string" ? photo.file_id.trim() : "";
if (fileId) {
return {
fileId,
fileName: sanitizeFileName(
buildIncomingFileName({ base: "photo", messageId, mimeType: "image/jpeg" })
),
};
}
}
const audioFileId =
typeof message.audio?.file_id === "string" ? message.audio.file_id.trim() : "";
if (audioFileId) {
const audioNameRaw =
typeof message.audio?.file_name === "string" ? message.audio.file_name : "";
const fallback = buildIncomingFileName({
base: "audio",
messageId,
mimeType:
typeof message.audio?.mime_type === "string"
? message.audio.mime_type
: undefined,
});
return {
fileId: audioFileId,
fileName: withMessageIdPrefix(sanitizeFileName(audioNameRaw || fallback), messageId),
};
}
const videoFileId =
typeof message.video?.file_id === "string" ? message.video.file_id.trim() : "";
if (videoFileId) {
const videoNameRaw =
typeof message.video?.file_name === "string" ? message.video.file_name : "";
const fallback = buildIncomingFileName({
base: "video",
messageId,
mimeType:
typeof message.video?.mime_type === "string"
? message.video.mime_type
: undefined,
});
return {
fileId: videoFileId,
fileName: withMessageIdPrefix(sanitizeFileName(videoNameRaw || fallback), messageId),
};
}
const voiceFileId =
typeof message.voice?.file_id === "string" ? message.voice.file_id.trim() : "";
if (voiceFileId) {
return {
fileId: voiceFileId,
fileName: sanitizeFileName(
buildIncomingFileName({
base: "voice",
messageId,
mimeType:
typeof message.voice?.mime_type === "string"
? message.voice.mime_type
: undefined,
})
),
};
}
return null;
}
async function downloadTelegramFile(botToken: string, fileId: string): Promise<Buffer> {
const payload = await callTelegramApi(botToken, "getFile", {
file_id: fileId,
});
const result = payload.result ?? {};
const filePath = typeof result.file_path === "string" ? result.file_path : "";
if (!filePath) {
throw new Error("Telegram getFile returned empty file_path");
}
const fileUrl = `https://api.telegram.org/file/bot${botToken}/${filePath}`;
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to download Telegram file (${response.status})`);
}
const bytes = await response.arrayBuffer();
if (bytes.byteLength > TELEGRAM_FILE_MAX_BYTES) {
throw new Error(
`Telegram file is too large (${bytes.byteLength} bytes). Max supported size is ${TELEGRAM_FILE_MAX_BYTES} bytes.`
);
}
return Buffer.from(bytes);
}
function extractCommand(text: string): string | null {
const first = text.trim().split(/\s+/, 1)[0];
if (!first || !first.startsWith("/")) return null;
return first.split("@", 1)[0].toLowerCase();
}
function extractAccessCodeCandidate(text: string): string | null {
const value = text.trim();
if (!value) return null;
const fromCommand = value.match(
/^\/(?:code|start)(?:@[a-zA-Z0-9_]+)?\s+([A-Za-z0-9_-]{6,64})$/i
);
if (fromCommand?.[1]) {
return fromCommand[1];
}
if (/^[A-Za-z0-9_-]{6,64}$/.test(value)) {
return value;
}
return null;
}
function normalizeOutgoingText(text: string): string {
const value = text.trim();
if (!value) return "Пустой ответ от агента.";
if (value.length <= TELEGRAM_TEXT_LIMIT) return value;
return `${value.slice(0, TELEGRAM_TEXT_LIMIT - 1)}`;
}
async function sendTelegramMessage(
botToken: string,
chatId: number | string,
text: string,
replyToMessageId?: number
): Promise<void> {
const response = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
chat_id: chatId,
text: normalizeOutgoingText(text),
...(typeof replyToMessageId === "number" ? { reply_to_message_id: replyToMessageId } : {}),
}),
});
const payload = (await response.json().catch(() => null)) as
| { ok?: boolean; description?: string }
| null;
if (!response.ok || !payload?.ok) {
throw new Error(
`Telegram sendMessage failed (${response.status})${payload?.description ? `: ${payload.description}` : ""}`
);
}
}
function helpText(activeProject?: { id?: string; name?: string }): string {
const activeProjectLine = activeProject?.id
? `Active project: ${activeProject.name ? `${activeProject.name} (${activeProject.id})` : activeProject.id}`
: "Active project: not selected";
return [
"Telegram connection is active.",
activeProjectLine,
"",
"Commands:",
"/start - show this help",
"/help - show this help",
"/code <access_code> - activate access for your Telegram user",
"/new - start a new conversation (reset context)",
"",
"Text messages are sent to the agent.",
"File uploads are saved into chat files.",
"You can also ask the agent to send a local file back to Telegram.",
].join("\n");
}
export const maxDuration = 300;
export async function GET() {
return Response.json({
status: "ok",
integration: "telegram",
timestamp: new Date().toISOString(),
});
}
export async function POST(req: NextRequest) {
const runtime = await getTelegramIntegrationRuntimeConfig();
const botToken = runtime.botToken.trim();
const webhookSecret = runtime.webhookSecret.trim();
const defaultProjectId = runtime.defaultProjectId || undefined;
const allowedUserIds = new Set(runtime.allowedUserIds);
if (!botToken || !webhookSecret) {
return Response.json(
{
error:
"Telegram integration is not configured. Set TELEGRAM_BOT_TOKEN and TELEGRAM_WEBHOOK_SECRET.",
},
{ status: 503 }
);
}
const providedSecret = req.headers.get("x-telegram-bot-api-secret-token")?.trim();
if (!providedSecret || !safeTokenMatch(providedSecret, webhookSecret)) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
let botIdForRollback: string | null = null;
let updateIdForRollback: number | null = null;
try {
const body = (await req.json()) as TelegramUpdate;
const updateId =
typeof body.update_id === "number" && Number.isInteger(body.update_id)
? body.update_id
: null;
if (updateId === null) {
return Response.json({ error: "Invalid update_id" }, { status: 400 });
}
const botId = getBotId(botToken);
botIdForRollback = botId;
updateIdForRollback = updateId;
const isNewUpdate = await claimTelegramUpdate(botId, updateId);
if (!isNewUpdate) {
return Response.json({ ok: true, duplicate: true });
}
const message = body.message;
const chatId =
typeof message?.chat?.id === "number" || typeof message?.chat?.id === "string"
? message.chat.id
: null;
const chatType = typeof message?.chat?.type === "string" ? message.chat.type : "";
const messageId =
typeof message?.message_id === "number" ? message.message_id : undefined;
if (chatId === null || !chatType) {
return Response.json({ ok: true, ignored: true, reason: "unsupported_update" });
}
if (chatType !== "private") {
return Response.json({ ok: true, ignored: true, reason: "private_only" });
}
const text = typeof message?.text === "string" ? message.text.trim() : "";
const caption =
typeof message?.caption === "string" ? message.caption.trim() : "";
const incomingText = text || caption;
const fromUserId = normalizeTelegramUserId(message?.from?.id);
if (!fromUserId) {
return Response.json({
ok: true,
ignored: true,
reason: "missing_user_id",
});
}
if (!allowedUserIds.has(fromUserId)) {
const accessCode = extractAccessCodeCandidate(text);
const granted =
accessCode &&
(await consumeTelegramAccessCode({
code: accessCode,
userId: fromUserId,
}));
if (granted) {
await sendTelegramMessage(
botToken,
chatId,
"Доступ выдан. Теперь можно отправлять сообщения агенту.",
messageId
);
return Response.json({
ok: true,
accessGranted: true,
userId: fromUserId,
});
}
await sendTelegramMessage(
botToken,
chatId,
[
"Доступ запрещён: ваш user_id не в списке разрешённых.",
"Отправьте код активации командой /code <код> или /start <код>.",
`Ваш user_id: ${fromUserId}`,
].join("\n"),
messageId
);
return Response.json({
ok: true,
ignored: true,
reason: "user_not_allowed",
userId: fromUserId,
});
}
let sessionId = await getTelegramChatSessionId(botId, chatId);
if (!sessionId) {
sessionId = createDefaultTelegramSessionId(botId, chatId);
await setTelegramChatSessionId(botId, chatId, sessionId);
}
const command = extractCommand(text);
if (command === "/start" || command === "/help") {
const resolvedProject = await resolveTelegramProjectContext({
sessionId,
defaultProjectId,
});
await saveExternalSession({
...resolvedProject.session,
updatedAt: new Date().toISOString(),
});
await sendTelegramMessage(
botToken,
chatId,
helpText({
id: resolvedProject.resolvedProjectId,
name: resolvedProject.projectName,
}),
messageId
);
return Response.json({ ok: true, command });
}
if (command === "/new") {
const freshSessionId = createFreshTelegramSessionId(botId, chatId);
await setTelegramChatSessionId(botId, chatId, freshSessionId);
await sendTelegramMessage(
botToken,
chatId,
"Начал новый диалог. Контекст очищен для следующего сообщения.",
messageId
);
return Response.json({ ok: true, command });
}
let incomingSavedFile:
| {
name: string;
path: string;
size: number;
}
| null = null;
const incomingFile = message ? extractIncomingFile(message, messageId) : null;
let externalContext: TelegramExternalChatContext | null = null;
if (incomingFile) {
externalContext = await ensureTelegramExternalChatContext({
sessionId,
defaultProjectId,
});
const fileBuffer = await downloadTelegramFile(botToken, incomingFile.fileId);
const saved = await saveChatFile(
externalContext.chatId,
fileBuffer,
incomingFile.fileName
);
incomingSavedFile = {
name: saved.name,
path: saved.path,
size: saved.size,
};
}
if (!incomingText) {
if (incomingSavedFile) {
await sendTelegramMessage(
botToken,
chatId,
`File "${incomingSavedFile.name}" saved to chat files.`,
messageId
);
return Response.json({
ok: true,
fileSaved: true,
file: incomingSavedFile,
});
}
await sendTelegramMessage(
botToken,
chatId,
"Only text messages and file uploads are supported right now.",
messageId
);
return Response.json({ ok: true, ignored: true, reason: "non_text" });
}
try {
const result = await handleExternalMessage({
sessionId,
message: incomingSavedFile
? `${incomingText}\n\nAttached file: ${incomingSavedFile.name}`
: incomingText,
projectId: externalContext?.projectId ?? defaultProjectId,
chatId: externalContext?.chatId,
currentPath: normalizeTelegramCurrentPath(externalContext?.currentPath),
runtimeData: {
telegram: {
botToken,
chatId,
replyToMessageId: messageId ?? null,
},
},
});
await sendTelegramMessage(botToken, chatId, result.reply, messageId);
return Response.json({ ok: true });
} catch (error) {
if (error instanceof ExternalMessageError) {
const errorMessage =
typeof error.payload.error === "string"
? error.payload.error
: "Не удалось обработать сообщение.";
await sendTelegramMessage(botToken, chatId, `Ошибка: ${errorMessage}`, messageId);
return Response.json({ ok: true, handledError: true, status: error.status });
}
throw error;
}
} catch (error) {
if (
botIdForRollback &&
typeof updateIdForRollback === "number" &&
Number.isInteger(updateIdForRollback)
) {
try {
await releaseTelegramUpdate(botIdForRollback, updateIdForRollback);
} catch (releaseError) {
console.error("Telegram rollback error:", releaseError);
}
}
console.error("Telegram webhook error:", error);
return Response.json(
{
error: error instanceof Error ? error.message : "Internal server error",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,152 @@
import { NextRequest } from "next/server";
import {
buildTelegramWebhookUrl,
generateTelegramWebhookSecret,
getTelegramIntegrationPublicSettings,
getTelegramIntegrationRuntimeConfig,
getTelegramIntegrationStoredSettings,
saveTelegramIntegrationStoredSettings,
} from "@/lib/storage/telegram-integration-store";
interface TelegramApiResponse {
ok?: boolean;
description?: string;
}
function parseTelegramError(status: number, payload: TelegramApiResponse | null): string {
const description = payload?.description?.trim();
return description
? `Telegram API error (${status}): ${description}`
: `Telegram API error (${status})`;
}
async function setTelegramWebhook(params: {
botToken: string;
webhookUrl: string;
webhookSecret: string;
}): Promise<void> {
const response = await fetch(
`https://api.telegram.org/bot${params.botToken}/setWebhook`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
url: params.webhookUrl,
secret_token: params.webhookSecret,
drop_pending_updates: false,
}),
}
);
const payload = (await response.json().catch(() => null)) as
| TelegramApiResponse
| null;
if (!response.ok || !payload?.ok) {
throw new Error(parseTelegramError(response.status, payload));
}
}
function inferPublicBaseUrl(req: NextRequest): string {
const forwardedHost = req.headers
.get("x-forwarded-host")
?.split(",")[0]
?.trim();
const host = forwardedHost || req.headers.get("host")?.trim();
const forwardedProto = req.headers
.get("x-forwarded-proto")
?.split(",")[0]
?.trim();
if (host) {
const proto =
forwardedProto ||
(host.startsWith("localhost") || host.startsWith("127.0.0.1")
? "http"
: "https");
return `${proto}://${host}`;
}
const origin = req.nextUrl.origin?.trim();
if (origin && origin !== "null") {
return origin;
}
return "";
}
export async function POST(req: NextRequest) {
try {
const body = (await req.json().catch(() => ({}))) as {
botToken?: unknown;
};
const inputToken =
typeof body.botToken === "string" ? body.botToken.trim() : "";
const stored = await getTelegramIntegrationStoredSettings();
const runtime = await getTelegramIntegrationRuntimeConfig();
const storedToken = stored.botToken.trim();
const botToken = inputToken || storedToken || runtime.botToken.trim();
if (!botToken) {
return Response.json(
{ error: "Telegram bot token is required" },
{ status: 400 }
);
}
const webhookSecret =
stored.webhookSecret.trim() ||
runtime.webhookSecret.trim() ||
generateTelegramWebhookSecret();
const publicBaseUrl =
stored.publicBaseUrl.trim() ||
runtime.publicBaseUrl.trim() ||
inferPublicBaseUrl(req);
if (!publicBaseUrl) {
return Response.json(
{
error:
"Public base URL is required. Set APP_BASE_URL or access the app via public host.",
},
{ status: 400 }
);
}
const webhookUrl = buildTelegramWebhookUrl(publicBaseUrl);
await saveTelegramIntegrationStoredSettings({
botToken: inputToken ? botToken : storedToken || undefined,
webhookSecret,
publicBaseUrl,
defaultProjectId: stored.defaultProjectId,
});
await setTelegramWebhook({
botToken,
webhookUrl,
webhookSecret,
});
const settings = await getTelegramIntegrationPublicSettings();
return Response.json({
success: true,
message: "Telegram connected",
webhookUrl,
settings,
});
} catch (error) {
return Response.json(
{
error:
error instanceof Error
? error.message
: "Failed to configure Telegram integration",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,166 @@
import {
buildTelegramWebhookUrl,
getTelegramIntegrationRuntimeConfig,
} from "@/lib/storage/telegram-integration-store";
export const dynamic = "force-dynamic";
interface TelegramApiResponse {
ok?: boolean;
description?: string;
result?: Record<string, unknown>;
}
function parseTelegramError(status: number, payload: TelegramApiResponse | null): string {
const description = payload?.description?.trim();
return description
? `Telegram API error (${status}): ${description}`
: `Telegram API error (${status})`;
}
async function callTelegramApi(
botToken: string,
method: string,
body?: Record<string, unknown>
): Promise<TelegramApiResponse> {
const response = await fetch(`https://api.telegram.org/bot${botToken}/${method}`, {
method: body ? "POST" : "GET",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const payload = (await response.json().catch(() => null)) as
| TelegramApiResponse
| null;
if (!response.ok || !payload?.ok) {
throw new Error(parseTelegramError(response.status, payload));
}
return payload;
}
function ensureWebhookConfigured(config: {
botToken: string;
webhookSecret: string;
publicBaseUrl: string;
}): { botToken: string; webhookSecret: string; webhookUrl: string } {
const botToken = config.botToken.trim();
const webhookSecret = config.webhookSecret.trim();
if (!botToken) {
throw new Error("Telegram bot token is not configured");
}
if (!webhookSecret) {
throw new Error("Telegram webhook secret is not configured");
}
const webhookUrl = buildTelegramWebhookUrl(config.publicBaseUrl);
return { botToken, webhookSecret, webhookUrl };
}
export async function GET() {
try {
const config = await getTelegramIntegrationRuntimeConfig();
if (!config.botToken.trim()) {
return Response.json({
configured: false,
webhook: null,
message: "Telegram bot token is not configured",
});
}
const payload = await callTelegramApi(config.botToken, "getWebhookInfo");
const result = payload.result ?? {};
return Response.json({
configured: true,
webhook: {
url: typeof result.url === "string" ? result.url : "",
hasCustomCertificate: Boolean(result.has_custom_certificate),
pendingUpdateCount:
typeof result.pending_update_count === "number"
? result.pending_update_count
: 0,
ipAddress:
typeof result.ip_address === "string" ? result.ip_address : null,
lastErrorDate:
typeof result.last_error_date === "number" ? result.last_error_date : null,
lastErrorMessage:
typeof result.last_error_message === "string"
? result.last_error_message
: null,
maxConnections:
typeof result.max_connections === "number" ? result.max_connections : null,
},
});
} catch (error) {
return Response.json(
{
error:
error instanceof Error
? error.message
: "Failed to load Telegram webhook status",
},
{ status: 500 }
);
}
}
export async function POST() {
try {
const runtime = await getTelegramIntegrationRuntimeConfig();
const { botToken, webhookSecret, webhookUrl } = ensureWebhookConfigured(runtime);
await callTelegramApi(botToken, "setWebhook", {
url: webhookUrl,
secret_token: webhookSecret,
drop_pending_updates: false,
});
return Response.json({
success: true,
webhookUrl,
message: "Webhook has been configured",
});
} catch (error) {
return Response.json(
{
error:
error instanceof Error
? error.message
: "Failed to configure Telegram webhook",
},
{ status: 500 }
);
}
}
export async function DELETE() {
try {
const runtime = await getTelegramIntegrationRuntimeConfig();
const botToken = runtime.botToken.trim();
if (!botToken) {
return Response.json(
{ error: "Telegram bot token is not configured" },
{ status: 400 }
);
}
await callTelegramApi(botToken, "deleteWebhook", {
drop_pending_updates: false,
});
return Response.json({
success: true,
message: "Webhook has been removed",
});
} catch (error) {
return Response.json(
{
error:
error instanceof Error
? error.message
: "Failed to remove Telegram webhook",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,42 @@
import { NextRequest } from "next/server";
import path from "path";
import { importKnowledge } from "@/lib/memory/knowledge";
import { getSettings } from "@/lib/storage/settings-store";
const DATA_DIR = path.join(process.cwd(), "data");
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { directory, subdir } = body;
if (!directory) {
return Response.json(
{ error: "Directory path is required" },
{ status: 400 }
);
}
const settings = await getSettings();
const memorySubdir = subdir || "main";
// Resolve directory path
const knowledgeDir = path.isAbsolute(directory)
? directory
: path.join(DATA_DIR, "knowledge", directory);
const result = await importKnowledge(knowledgeDir, memorySubdir, settings);
return Response.json(result);
} catch (error) {
return Response.json(
{
error:
error instanceof Error
? error.message
: "Failed to import knowledge",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,75 @@
import { NextRequest } from "next/server";
import {
searchMemory,
insertMemory,
deleteMemoryById,
getAllMemories,
} from "@/lib/memory/memory";
import { getSettings } from "@/lib/storage/settings-store";
export async function GET(req: NextRequest) {
const query = req.nextUrl.searchParams.get("query");
const subdir = req.nextUrl.searchParams.get("subdir") || "main";
const limit = parseInt(req.nextUrl.searchParams.get("limit") || "20");
if (query) {
const settings = await getSettings();
const results = await searchMemory(
query,
limit,
settings.memory.similarityThreshold,
subdir,
settings
);
return Response.json(results);
}
// Return all memories for dashboard
const memories = await getAllMemories(subdir);
return Response.json(memories);
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { text, area, subdir } = body;
if (!text) {
return Response.json({ error: "Text is required" }, { status: 400 });
}
const settings = await getSettings();
const id = await insertMemory(
text,
area || "main",
subdir || "main",
settings
);
return Response.json({ id, success: true }, { status: 201 });
} catch (error) {
return Response.json(
{
error:
error instanceof Error ? error.message : "Failed to save memory",
},
{ status: 500 }
);
}
}
export async function DELETE(req: NextRequest) {
const id = req.nextUrl.searchParams.get("id");
const subdir = req.nextUrl.searchParams.get("subdir") || "main";
if (!id) {
return Response.json({ error: "Memory ID required" }, { status: 400 });
}
const deleted = await deleteMemoryById(id, subdir);
if (!deleted) {
return Response.json({ error: "Memory not found" }, { status: 404 });
}
return Response.json({ success: true });
}

161
src/app/api/models/route.ts Normal file
View File

@@ -0,0 +1,161 @@
import { NextRequest } from "next/server";
import { MODEL_PROVIDERS } from "@/lib/providers/model-config";
import { getSettings } from "@/lib/storage/settings-store";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const provider = searchParams.get("provider") || "";
let apiKey = searchParams.get("apiKey") || "";
const type = searchParams.get("type") || "chat"; // "chat" | "embedding"
// If apiKey is masked or missing, try to get it from server-side settings
if (!apiKey || apiKey.includes("****")) {
try {
const settings = await getSettings();
if (type === "chat" && settings.chatModel.provider === provider) {
apiKey = settings.chatModel.apiKey || "";
} else if (type === "embedding" && settings.embeddingsModel.provider === provider) {
apiKey = settings.embeddingsModel.apiKey || "";
} else if (provider === "openrouter" && process.env.OPENROUTER_API_KEY) {
// Special case for environment variables if not in settings explicitly
apiKey = process.env.OPENROUTER_API_KEY;
} else if (provider === "openai" && process.env.OPENAI_API_KEY) {
apiKey = process.env.OPENAI_API_KEY;
} else if (provider === "anthropic" && process.env.ANTHROPIC_API_KEY) {
apiKey = process.env.ANTHROPIC_API_KEY;
} else if (provider === "google" && process.env.GOOGLE_API_KEY) {
apiKey = process.env.GOOGLE_API_KEY;
}
} catch (e) {
console.error("Failed to load settings for API key lookup", e);
}
}
try {
let models: { id: string; name: string }[] = [];
switch (provider) {
case "openai": {
const res = await fetch("https://api.openai.com/v1/models", {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) throw new Error(`OpenAI API error: ${res.status}`);
const data = await res.json();
models = data.data
.filter((m: { id: string }) => {
if (type === "embedding") {
return m.id.includes("text-embedding") || m.id.includes("embedding");
}
return m.id.startsWith("gpt-") || m.id.startsWith("o1") || m.id.startsWith("o3") || m.id.startsWith("o4");
})
.map((m: { id: string }) => ({ id: m.id, name: m.id }))
.sort((a: { id: string }, b: { id: string }) => a.id.localeCompare(b.id));
break;
}
case "openrouter": {
let url = "https://openrouter.ai/api/v1/models";
if (type === "embedding") {
url = "https://openrouter.ai/api/v1/embeddings/models";
}
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) throw new Error(`OpenRouter API error: ${res.status}`);
const data = await res.json();
// OpenRouter embeddings endpoint might return array directly or { data: [] }
const rawModels = Array.isArray(data) ? data : (data.data || []);
models = rawModels
.map((m: { id: string; name?: string }) => ({
id: m.id,
name: m.name || m.id,
}))
.sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name));
break;
}
case "ollama": {
const rawBaseUrl = (searchParams.get("baseUrl") || "http://localhost:11434").trim();
const normalizedBaseUrl = rawBaseUrl
.replace(/\/+$/, "")
.replace(/\/v1$/, "");
const res = await fetch(`${normalizedBaseUrl}/api/tags`);
if (!res.ok) throw new Error(`Ollama API error: ${res.status}`);
const data = await res.json();
// Ollama returns all models. We can't reliably distinguish embedding vs chat without 'show' API
// For now, return all.
models = (data.models || []).map((m: { name: string; model?: string }) => ({
id: m.name,
name: m.name,
}));
break;
}
case "anthropic": {
if (type === "embedding") {
models = []; // Anthropic API doesn't list embedding models (they don't have public ones via this API usually)
break;
}
const res = await fetch("https://api.anthropic.com/v1/models?limit=1000", {
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
});
if (!res.ok) throw new Error(`Anthropic API error: ${res.status}`);
const data = await res.json();
models = (data.data || [])
.filter((m: { type: string; id: string }) => m.type === "model")
.map((m: { id: string; display_name?: string }) => ({
id: m.id,
name: m.display_name || m.id,
}))
.sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name));
break;
}
case "google": {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`
);
if (!res.ok) throw new Error(`Google API error: ${res.status}`);
const data = await res.json();
models = (data.models || [])
.map((m: { name: string; displayName?: string }) => ({
id: m.name.replace("models/", ""),
name: m.displayName || m.name.replace("models/", ""),
}))
.filter((m: { id: string }) => {
if (type === "embedding") {
return m.id.includes("embedding");
}
return m.id.includes("gemini");
})
.sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name));
break;
}
default: {
const providerConfig = MODEL_PROVIDERS[provider];
if (providerConfig) {
models = [...providerConfig.models];
}
break;
}
}
return Response.json({ models });
} catch (error) {
return Response.json(
{
error: error instanceof Error ? error.message : "Failed to fetch models",
models: [],
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,142 @@
import { NextRequest } from "next/server";
import { ensureCronSchedulerStarted } from "@/lib/cron/runtime";
import { getCronJob, removeCronJob, updateCronJob } from "@/lib/cron/service";
import type { CronJobPatch, CronSchedule } from "@/lib/cron/types";
function coerceSchedule(value: unknown): CronSchedule | null {
if (!value || typeof value !== "object") {
return null;
}
const raw = value as Record<string, unknown>;
if (raw.kind === "at" && typeof raw.at === "string") {
return { kind: "at", at: raw.at };
}
if (raw.kind === "every" && typeof raw.everyMs === "number") {
return {
kind: "every",
everyMs: raw.everyMs,
anchorMs: typeof raw.anchorMs === "number" ? raw.anchorMs : undefined,
};
}
if (raw.kind === "cron" && typeof raw.expr === "string") {
return {
kind: "cron",
expr: raw.expr,
tz: typeof raw.tz === "string" ? raw.tz : undefined,
};
}
return null;
}
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string; jobId: string }> }
) {
const { id, jobId } = await params;
await ensureCronSchedulerStarted();
try {
const job = await getCronJob(id, jobId);
if (!job) {
return Response.json({ error: "Cron job not found." }, { status: 404 });
}
return Response.json(job);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load cron job.";
const status = message.includes("not found") ? 404 : 400;
return Response.json({ error: message }, { status });
}
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string; jobId: string }> }
) {
const { id, jobId } = await params;
await ensureCronSchedulerStarted();
let body: Record<string, unknown>;
try {
body = (await req.json()) as Record<string, unknown>;
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const patch: CronJobPatch = {};
if ("name" in body) {
patch.name = typeof body.name === "string" ? body.name : "";
}
if ("description" in body) {
patch.description = typeof body.description === "string" ? body.description : "";
}
if ("enabled" in body) {
if (typeof body.enabled !== "boolean") {
return Response.json({ error: "enabled must be a boolean." }, { status: 400 });
}
patch.enabled = body.enabled;
}
if ("deleteAfterRun" in body) {
if (typeof body.deleteAfterRun !== "boolean") {
return Response.json({ error: "deleteAfterRun must be a boolean." }, { status: 400 });
}
patch.deleteAfterRun = body.deleteAfterRun;
}
if ("schedule" in body) {
const schedule = coerceSchedule(body.schedule);
if (!schedule) {
return Response.json({ error: "Invalid schedule patch." }, { status: 400 });
}
patch.schedule = schedule;
}
if ("payload" in body) {
if (!body.payload || typeof body.payload !== "object") {
return Response.json({ error: "Invalid payload patch." }, { status: 400 });
}
const payload = body.payload as Record<string, unknown>;
patch.payload = {
kind: "agentTurn",
message: typeof payload.message === "string" ? payload.message : undefined,
chatId: typeof payload.chatId === "string" ? payload.chatId : undefined,
telegramChatId:
typeof payload.telegramChatId === "string" ||
typeof payload.telegramChatId === "number"
? String(payload.telegramChatId)
: undefined,
currentPath: typeof payload.currentPath === "string" ? payload.currentPath : undefined,
timeoutSeconds:
typeof payload.timeoutSeconds === "number" ? payload.timeoutSeconds : undefined,
};
}
try {
const job = await updateCronJob(id, jobId, patch);
if (!job) {
return Response.json({ error: "Cron job not found." }, { status: 404 });
}
return Response.json(job);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to update cron job.";
const status = message.includes("not found") ? 404 : 400;
return Response.json({ error: message }, { status });
}
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string; jobId: string }> }
) {
const { id, jobId } = await params;
await ensureCronSchedulerStarted();
try {
const result = await removeCronJob(id, jobId);
if (!result.removed) {
return Response.json({ error: "Cron job not found." }, { status: 404 });
}
return Response.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to remove cron job.";
const status = message.includes("not found") ? 404 : 400;
return Response.json({ error: message }, { status });
}
}

View File

@@ -0,0 +1,30 @@
import { ensureCronSchedulerStarted } from "@/lib/cron/runtime";
import { runCronJobNow } from "@/lib/cron/service";
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string; jobId: string }> }
) {
const { id, jobId } = await params;
await ensureCronSchedulerStarted();
try {
const result = await runCronJobNow(id, jobId);
if (!result.ran) {
if (result.reason === "not-found") {
return Response.json({ error: "Cron job not found." }, { status: 404 });
}
if (result.reason === "already-running") {
return Response.json(
{ error: "Cron job is already running." },
{ status: 409 }
);
}
}
return Response.json({ success: true, ran: result.ran });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to run cron job.";
const status = message.includes("not found") ? 404 : 400;
return Response.json({ error: message }, { status });
}
}

View File

@@ -0,0 +1,22 @@
import { listCronRuns } from "@/lib/cron/service";
import { ensureCronSchedulerStarted } from "@/lib/cron/runtime";
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string; jobId: string }> }
) {
const { id, jobId } = await params;
await ensureCronSchedulerStarted();
const url = new URL(req.url);
const limitRaw = url.searchParams.get("limit");
const limit = limitRaw ? Number(limitRaw) : undefined;
try {
const entries = await listCronRuns(id, jobId, Number.isFinite(limit) ? limit : undefined);
return Response.json({ entries });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to read cron run logs.";
const status = message.includes("not found") ? 404 : 400;
return Response.json({ error: message }, { status });
}
}

View File

@@ -0,0 +1,106 @@
import { NextRequest } from "next/server";
import { ensureCronSchedulerStarted } from "@/lib/cron/runtime";
import { addCronJob, listCronJobs } from "@/lib/cron/service";
import type { CronJobCreate, CronSchedule } from "@/lib/cron/types";
function badRequest(message: string) {
return Response.json({ error: message }, { status: 400 });
}
function coerceSchedule(value: unknown): CronSchedule | null {
if (!value || typeof value !== "object") {
return null;
}
const raw = value as Record<string, unknown>;
if (raw.kind === "at" && typeof raw.at === "string") {
return { kind: "at", at: raw.at };
}
if (raw.kind === "every" && typeof raw.everyMs === "number") {
return {
kind: "every",
everyMs: raw.everyMs,
anchorMs: typeof raw.anchorMs === "number" ? raw.anchorMs : undefined,
};
}
if (raw.kind === "cron" && typeof raw.expr === "string") {
return {
kind: "cron",
expr: raw.expr,
tz: typeof raw.tz === "string" ? raw.tz : undefined,
};
}
return null;
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
await ensureCronSchedulerStarted();
const includeDisabled = req.nextUrl.searchParams.get("includeDisabled") === "true";
try {
const jobs = await listCronJobs(id, { includeDisabled });
return Response.json({ jobs });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load cron jobs.";
const status = message.includes("not found") ? 404 : 400;
return Response.json({ error: message }, { status });
}
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
await ensureCronSchedulerStarted();
let body: Record<string, unknown>;
try {
body = (await req.json()) as Record<string, unknown>;
} catch {
return badRequest("Invalid JSON body.");
}
const schedule = coerceSchedule(body.schedule);
if (!schedule) {
return badRequest("Invalid schedule payload.");
}
if (!body.payload || typeof body.payload !== "object") {
return badRequest("payload is required.");
}
const payload = body.payload as Record<string, unknown>;
if (payload.kind !== "agentTurn" || typeof payload.message !== "string") {
return badRequest('payload.kind must be "agentTurn" and payload.message is required.');
}
const input: CronJobCreate = {
name: typeof body.name === "string" ? body.name : "",
description: typeof body.description === "string" ? body.description : undefined,
enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
deleteAfterRun: typeof body.deleteAfterRun === "boolean" ? body.deleteAfterRun : undefined,
schedule,
payload: {
kind: "agentTurn",
message: payload.message,
chatId: typeof payload.chatId === "string" ? payload.chatId : undefined,
telegramChatId:
typeof payload.telegramChatId === "string" || typeof payload.telegramChatId === "number"
? String(payload.telegramChatId)
: undefined,
currentPath: typeof payload.currentPath === "string" ? payload.currentPath : undefined,
timeoutSeconds:
typeof payload.timeoutSeconds === "number" ? payload.timeoutSeconds : undefined,
},
};
try {
const job = await addCronJob(id, input);
return Response.json(job, { status: 201 });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create cron job.";
const status = message.includes("not found") ? 404 : 400;
return Response.json({ error: message }, { status });
}
}

View File

@@ -0,0 +1,18 @@
import { ensureCronSchedulerStarted } from "@/lib/cron/runtime";
import { getCronProjectStatus } from "@/lib/cron/service";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
await ensureCronSchedulerStarted();
try {
const status = await getCronProjectStatus(id);
return Response.json(status);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load cron status.";
const status = message.includes("not found") ? 404 : 400;
return Response.json({ error: message }, { status });
}
}

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { getChunksByFilename } from "@/lib/memory/memory";
import { getProject } from "@/lib/storage/project-store";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const filename = req.nextUrl.searchParams.get("filename");
if (!filename) {
return NextResponse.json(
{ error: "Query parameter 'filename' is required" },
{ status: 400 }
);
}
const project = await getProject(id);
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
try {
const chunks = await getChunksByFilename(id, filename);
return NextResponse.json({ filename, chunks });
} catch (error) {
console.error("Error loading chunks:", error);
return NextResponse.json(
{ error: "Failed to load chunks" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,156 @@
import { NextRequest, NextResponse } from "next/server";
import path from "path";
import fs from "fs/promises";
import { importKnowledgeFile } from "@/lib/memory/knowledge";
import { deleteMemoryByMetadata, getChunkCountsByFilename } from "@/lib/memory/memory";
import { getProject } from "@/lib/storage/project-store";
import { getSettings } from "@/lib/storage/settings-store";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const projectDir = path.join(process.cwd(), "data", "projects", id);
const knowledgeDir = path.join(projectDir, ".meta", "knowledge");
try {
await fs.access(knowledgeDir);
} catch {
return NextResponse.json([]);
}
try {
const files = await fs.readdir(knowledgeDir);
const chunkCounts = await getChunkCountsByFilename(id);
const fileDetails = await Promise.all(
files.map(async (file) => {
const stats = await fs.stat(path.join(knowledgeDir, file));
return {
name: file,
size: stats.size,
createdAt: stats.birthtime,
chunkCount: chunkCounts[file] ?? 0,
};
})
);
return NextResponse.json(fileDetails);
} catch (error) {
return NextResponse.json(
{ error: "Failed to list knowledge files" },
{ status: 500 }
);
}
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
// Verify project exists
const project = await getProject(id);
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const projectDir = path.join(process.cwd(), "data", "projects", id);
const knowledgeDir = path.join(projectDir, ".meta", "knowledge");
// Ensure knowledge directory exists
await fs.mkdir(knowledgeDir, { recursive: true });
const buffer = Buffer.from(await file.arrayBuffer());
const filePath = path.join(knowledgeDir, file.name);
try {
// Save file
await fs.writeFile(filePath, buffer);
// Ingest only the uploaded file (removes its old chunks first, so no duplicates)
const settings = await getSettings();
const result = await importKnowledgeFile(knowledgeDir, id, settings, file.name);
if (result.errors.length > 0) {
console.error("Ingestion errors:", result.errors);
return NextResponse.json(
{
message: "File saved but ingestion had errors",
details: result
},
{ status: 207 } // Multi-Status
);
}
return NextResponse.json({
message: "File uploaded and ingested successfully",
filename: file.name
});
} catch (error) {
console.error("Upload error:", error);
return NextResponse.json(
{ error: "Failed to process file" },
{ status: 500 }
);
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
// Verify project exists
const project = await getProject(id);
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
try {
const { filename } = await req.json();
if (!filename) {
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
}
const projectDir = path.join(process.cwd(), "data", "projects", id);
const knowledgeDir = path.join(projectDir, ".meta", "knowledge");
const filePath = path.join(knowledgeDir, filename);
// Delete file from disk
try {
await fs.unlink(filePath);
} catch (error: any) {
if (error.code !== "ENOENT") {
throw error;
}
// If file doesn't exist, we still try to delete vectors
}
// Delete vectors
const deletedVectors = await deleteMemoryByMetadata("filename", filename, id);
return NextResponse.json({
message: "File and vectors deleted successfully",
deletedVectors
});
} catch (error) {
console.error("Delete error:", error);
return NextResponse.json(
{ error: "Failed to delete file" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,43 @@
import fs from "fs/promises";
import { NextRequest, NextResponse } from "next/server";
import {
getProject,
getProjectMcpServersPath,
loadProjectMcpServers,
} from "@/lib/storage/project-store";
function isNotFoundError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
return "code" in error && (error as { code?: string }).code === "ENOENT";
}
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const project = await getProject(id);
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
try {
const filePath = getProjectMcpServersPath(id);
const [content, normalized] = await Promise.all([
fs.readFile(filePath, "utf-8"),
loadProjectMcpServers(id),
]);
return NextResponse.json({
content,
servers: normalized?.servers ?? [],
});
} catch (error) {
if (isNotFoundError(error)) {
return NextResponse.json({ content: null, servers: [] });
}
return NextResponse.json(
{ error: "Failed to load MCP servers configuration" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest } from "next/server";
import {
getProject,
updateProject,
deleteProject,
} from "@/lib/storage/project-store";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const project = await getProject(id);
if (!project) {
return Response.json({ error: "Project not found" }, { status: 404 });
}
return Response.json(project);
}
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await req.json();
const updated = await updateProject(id, body);
if (!updated) {
return Response.json({ error: "Project not found" }, { status: 404 });
}
return Response.json(updated);
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const deleted = await deleteProject(id);
if (!deleted) {
return Response.json({ error: "Project not found" }, { status: 404 });
}
return Response.json({ success: true });
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { getProject, loadProjectSkills } from "@/lib/storage/project-store";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const project = await getProject(id);
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
try {
const skills = await loadProjectSkills(id);
return NextResponse.json(
skills.map((skill) => ({
name: skill.name,
description: skill.description,
content: skill.body,
license: skill.license,
compatibility: skill.compatibility,
}))
);
} catch {
return NextResponse.json(
{ error: "Failed to load project skills" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,46 @@
import { NextRequest } from "next/server";
import { getAllProjects, createProject } from "@/lib/storage/project-store";
export async function GET() {
const projects = await getAllProjects();
return Response.json(projects);
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { name, description, instructions, memoryMode } = body;
if (!name || typeof name !== "string") {
return Response.json(
{ error: "Project name is required" },
{ status: 400 }
);
}
// Generate URL-safe ID from name
const id = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
|| crypto.randomUUID().slice(0, 8);
const project = await createProject({
id,
name,
description: description || "",
instructions: instructions || "",
memoryMode: memoryMode || "global",
});
return Response.json(project, { status: 201 });
} catch (error) {
return Response.json(
{
error:
error instanceof Error ? error.message : "Failed to create project",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,92 @@
import { NextRequest } from "next/server";
import { getSettings, saveSettings } from "@/lib/storage/settings-store";
import type { AppSettings } from "@/lib/types";
export async function GET() {
const settings = await getSettings();
return Response.json(maskSettingsKeys(settings));
}
export async function PUT(req: NextRequest) {
try {
const body = (await req.json()) as Partial<AppSettings>;
const current = await getSettings();
const sanitized = restoreMaskedKeys(body, current);
const updated = await saveSettings(sanitized);
return Response.json(maskSettingsKeys(updated));
} catch (error) {
return Response.json(
{
error:
error instanceof Error
? error.message
: "Failed to save settings",
},
{ status: 500 }
);
}
}
function maskSettingsKeys(settings: AppSettings): AppSettings {
const masked: AppSettings = structuredClone(settings);
if (masked.chatModel.apiKey) {
masked.chatModel.apiKey = maskKey(masked.chatModel.apiKey);
}
if (masked.embeddingsModel.apiKey) {
masked.embeddingsModel.apiKey = maskKey(masked.embeddingsModel.apiKey);
}
if (masked.search.apiKey) {
masked.search.apiKey = maskKey(masked.search.apiKey);
}
if (masked.auth.passwordHash) {
masked.auth.passwordHash = maskKey(masked.auth.passwordHash);
}
return masked;
}
function restoreMaskedKeys(
incoming: Partial<AppSettings>,
current: AppSettings
): Partial<AppSettings> {
const next: Partial<AppSettings> = structuredClone(incoming);
if (isMaskedKey(next.chatModel?.apiKey)) {
next.chatModel = {
...(next.chatModel || {}),
apiKey: current.chatModel.apiKey,
};
}
if (isMaskedKey(next.embeddingsModel?.apiKey)) {
next.embeddingsModel = {
...(next.embeddingsModel || {}),
apiKey: current.embeddingsModel.apiKey,
};
}
if (isMaskedKey(next.search?.apiKey)) {
next.search = {
...(next.search || {}),
apiKey: current.search.apiKey,
};
}
if (isMaskedKey(next.auth?.passwordHash)) {
next.auth = {
...(next.auth || {}),
passwordHash: current.auth.passwordHash,
};
}
return next;
}
function isMaskedKey(value: unknown): value is string {
return typeof value === "string" && value.includes("****");
}
function maskKey(key: string): string {
if (key.length <= 8) return "****";
return key.slice(0, 4) + "****" + key.slice(-4);
}

View File

@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { loadProjectSkillsMetadata } from "@/lib/storage/project-store";
import {
installBundledSkill,
listBundledSkills,
} from "@/lib/storage/bundled-skills-store";
export async function GET(req: NextRequest) {
const projectId = req.nextUrl.searchParams.get("projectId");
const bundledSkills = await listBundledSkills();
if (!projectId) {
return NextResponse.json(
bundledSkills.map((skill) => ({ ...skill, installed: false }))
);
}
try {
const installedSkills = await loadProjectSkillsMetadata(projectId);
const installedNames = new Set(
installedSkills.map((skill) => skill.name.toLowerCase())
);
return NextResponse.json(
bundledSkills.map((skill) => ({
...skill,
installed: installedNames.has(skill.name.toLowerCase()),
}))
);
} catch {
return NextResponse.json(
{ error: "Failed to load installed project skills" },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const projectId =
typeof body === "object" &&
body !== null &&
"projectId" in body &&
typeof body.projectId === "string"
? body.projectId
: "";
const skillName =
typeof body === "object" &&
body !== null &&
"skillName" in body &&
typeof body.skillName === "string"
? body.skillName
: "";
if (!projectId.trim() || !skillName.trim()) {
return NextResponse.json(
{ error: "projectId and skillName are required" },
{ status: 400 }
);
}
const result = await installBundledSkill(projectId, skillName);
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: result.code });
}
return NextResponse.json(
{
success: true,
installedSkill: skillName.trim().toLowerCase(),
targetDir: result.targetDir,
},
{ status: 201 }
);
}

View File

@@ -0,0 +1,194 @@
import { AppSidebar } from "@/components/app-sidebar";
import { ExternalApiTokenManager } from "@/components/external-api-token-manager";
import { SiteHeader } from "@/components/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
function CodeBlock({ code }: { code: string }) {
return (
<pre className="rounded-lg border bg-muted/40 p-3 text-xs overflow-x-auto whitespace-pre-wrap">
<code>{code}</code>
</pre>
);
}
export default function ApiPage() {
return (
<div className="[--header-height:calc(--spacing(14))]">
<SidebarProvider className="flex flex-col">
<SiteHeader title="API" />
<div className="flex flex-1">
<AppSidebar />
<SidebarInset>
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6 max-w-5xl mx-auto w-full">
<div className="space-y-2">
<h2 className="text-2xl font-semibold">External Message API</h2>
<p className="text-sm text-muted-foreground">
Endpoint for sending messages from external integrations (Telegram, bots, webhooks)
with persistent project/chat context.
</p>
</div>
<section className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-center gap-2">
<span className="rounded border bg-muted px-2 py-0.5 text-xs font-medium">
POST
</span>
<span className="font-mono text-sm">/api/external/message</span>
</div>
<p className="text-sm text-muted-foreground">
Required fields: <span className="font-mono">sessionId</span>,{" "}
<span className="font-mono">message</span>.
</p>
<p className="text-sm text-muted-foreground">
Auth: header <span className="font-mono">Authorization: Bearer &lt;token&gt;</span> (from Token Management or <span className="font-mono">EXTERNAL_API_TOKEN</span>).
</p>
<CodeBlock
code={`{
"sessionId": "user-42",
"message": "hello",
"projectId": "my-project-id",
"chatId": "optional-chat-id",
"currentPath": "optional/relative/path"
}`}
/>
</section>
<section className="rounded-lg border bg-card p-4 space-y-3">
<h3 className="text-lg font-medium">Telegram Webhook</h3>
<p className="text-sm text-muted-foreground">
Telegram endpoint: <span className="font-mono">POST /api/integrations/telegram</span>.
It reuses the same external session context engine as{" "}
<span className="font-mono">/api/external/message</span>.
</p>
<p className="text-sm text-muted-foreground">
Configure credentials in <span className="font-mono">Dashboard -&gt; Messengers</span>
(bot token is enough; webhook secret/url are configured automatically).
</p>
<CodeBlock
code={`curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \\
-H "Content-Type: application/json" \\
-d '{
"url": "https://YOUR_PUBLIC_BASE_URL/api/integrations/telegram",
"secret_token": "'$TELEGRAM_WEBHOOK_SECRET'"
}'`}
/>
<p className="text-sm text-muted-foreground">
Supported commands: <span className="font-mono">/start</span>,{" "}
<span className="font-mono">/help</span>,{" "}
<span className="font-mono">/code &lt;access_code&gt;</span>,{" "}
<span className="font-mono">/new</span>.
</p>
</section>
<section className="rounded-lg border bg-card p-4 space-y-3">
<h3 className="text-lg font-medium">API Token Management</h3>
<ExternalApiTokenManager />
</section>
<section className="rounded-lg border bg-card p-4 space-y-3">
<h3 className="text-lg font-medium">How Project Context Is Resolved</h3>
<ol className="list-decimal pl-5 space-y-1 text-sm text-muted-foreground">
<li>If request includes <span className="font-mono">projectId</span>, it is used and saved as active for this session.</li>
<li>Otherwise API uses session&apos;s current active project.</li>
<li>If active project is missing and only one project exists, it is selected automatically.</li>
<li>If multiple projects exist and message is not about project navigation, API returns <span className="font-mono">409</span> with available projects.</li>
</ol>
</section>
<section className="rounded-lg border bg-card p-4 space-y-3">
<h3 className="text-lg font-medium">Project Navigation via Natural Language</h3>
<p className="text-sm text-muted-foreground">
The agent can answer project questions and switch projects using tools:
<span className="font-mono"> list_projects</span>,{" "}
<span className="font-mono"> get_current_project</span>,{" "}
<span className="font-mono"> switch_project</span>,{" "}
<span className="font-mono"> create_project</span>.
When switch/create succeeds, session context is updated automatically for next requests.
</p>
</section>
<section className="rounded-lg border bg-card p-4 space-y-3">
<h3 className="text-lg font-medium">Examples</h3>
<div className="space-y-3">
<div>
<p className="text-sm font-medium mb-1">1. Ask for projects</p>
<CodeBlock
code={`curl -X POST http://localhost:3000/api/external/message \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer $EXTERNAL_API_TOKEN" \\
-d '{
"sessionId": "user-42",
"message": "what projects are available?"
}'`}
/>
</div>
<div>
<p className="text-sm font-medium mb-1">2. Switch by name</p>
<CodeBlock
code={`curl -X POST http://localhost:3000/api/external/message \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer $EXTERNAL_API_TOKEN" \\
-d '{
"sessionId": "user-42",
"message": "switch to the backend project"
}'`}
/>
</div>
<div>
<p className="text-sm font-medium mb-1">3. Send normal message after switch</p>
<CodeBlock
code={`curl -X POST http://localhost:3000/api/external/message \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer $EXTERNAL_API_TOKEN" \\
-d '{
"sessionId": "user-42",
"message": "hello"
}'`}
/>
</div>
<div>
<p className="text-sm font-medium mb-1">4. Create a new project from chat</p>
<CodeBlock
code={`curl -X POST http://localhost:3000/api/external/message \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer $EXTERNAL_API_TOKEN" \\
-d '{
"sessionId": "user-42",
"message": "create a new project named crm-support"
}'`}
/>
</div>
</div>
</section>
<section className="rounded-lg border bg-card p-4 space-y-3">
<h3 className="text-lg font-medium">Successful Response Shape</h3>
<CodeBlock
code={`{
"success": true,
"sessionId": "user-42",
"reply": "assistant response",
"context": {
"activeProjectId": "backend",
"activeProjectName": "Backend",
"activeChatId": "b86f...",
"currentPath": ""
},
"switchedProject": {
"toProjectId": "backend",
"toProjectName": "Backend"
},
"createdProject": null
}`}
/>
</section>
</div>
</SidebarInset>
</div>
</SidebarProvider>
</div>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { useEffect, useState } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { useAppStore } from "@/store/app-store";
import { CronSection } from "@/components/cron-section";
export default function CronPage() {
const { projects, setProjects, activeProjectId } = useAppStore();
const [selectedProjectId, setSelectedProjectId] = useState("");
const [projectsLoading, setProjectsLoading] = useState(false);
useEffect(() => {
loadProjects();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (projects.length === 0) {
setSelectedProjectId("");
return;
}
const hasCurrent = projects.some((project) => project.id === selectedProjectId);
if (hasCurrent) return;
const activeFromSidebar = activeProjectId
? projects.find((project) => project.id === activeProjectId)
: null;
if (activeFromSidebar) {
setSelectedProjectId(activeFromSidebar.id);
return;
}
setSelectedProjectId(projects[0].id);
}, [projects, selectedProjectId, activeProjectId]);
async function loadProjects() {
try {
setProjectsLoading(true);
const res = await fetch("/api/projects");
const data = await res.json();
if (Array.isArray(data)) setProjects(data);
} catch {
setProjects([]);
} finally {
setProjectsLoading(false);
}
}
return (
<div className="[--header-height:calc(--spacing(14))]">
<SidebarProvider className="flex flex-col">
<SiteHeader title="Cron Jobs" />
<div className="flex flex-1">
<AppSidebar />
<SidebarInset>
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
<div className="space-y-1">
<h2 className="text-2xl font-semibold flex items-center gap-2">
Cron Jobs
</h2>
<p className="text-sm text-muted-foreground">
Manage scheduled jobs per project and switch between projects.
</p>
</div>
<div className="flex flex-col md:flex-row gap-3">
<select
value={selectedProjectId}
onChange={(e) => setSelectedProjectId(e.target.value)}
className="rounded-md border bg-background px-3 py-2 text-sm md:w-96"
disabled={projectsLoading || projects.length === 0}
>
{projectsLoading && (
<option value="">Loading projects...</option>
)}
{!projectsLoading && projects.length === 0 && (
<option value="">No projects available</option>
)}
{!projectsLoading &&
projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name} ({project.id})
</option>
))}
</select>
</div>
{!selectedProjectId ? (
<div className="rounded-lg border bg-card p-4 text-sm text-muted-foreground">
Select a project to manage cron jobs.
</div>
) : (
<CronSection key={selectedProjectId} projectId={selectedProjectId} />
)}
</div>
</SidebarInset>
</div>
</SidebarProvider>
</div>
);
}

View File

@@ -0,0 +1,7 @@
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,343 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { Input } from "@/components/ui/input";
import { Globe, Loader2, Terminal, Wrench } from "lucide-react";
import { useAppStore } from "@/store/app-store";
interface McpServerItem {
id: string;
transport: "stdio" | "http";
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
headers?: Record<string, string>;
}
function normalizeServers(input: unknown): McpServerItem[] {
if (!Array.isArray(input)) return [];
const servers: McpServerItem[] = [];
for (const item of input) {
if (!item || typeof item !== "object") continue;
const raw = item as Record<string, unknown>;
const id = typeof raw.id === "string" ? raw.id : "";
const transport = raw.transport;
if (!id || (transport !== "stdio" && transport !== "http")) continue;
if (transport === "stdio") {
servers.push({
id,
transport,
command: typeof raw.command === "string" ? raw.command : undefined,
args: Array.isArray(raw.args)
? raw.args.filter((arg): arg is string => typeof arg === "string")
: undefined,
env:
raw.env && typeof raw.env === "object" && !Array.isArray(raw.env)
? Object.fromEntries(
Object.entries(raw.env).filter(
([key, value]) =>
typeof key === "string" && typeof value === "string"
)
)
: undefined,
cwd: typeof raw.cwd === "string" ? raw.cwd : undefined,
});
} else {
servers.push({
id,
transport,
url: typeof raw.url === "string" ? raw.url : undefined,
headers:
raw.headers &&
typeof raw.headers === "object" &&
!Array.isArray(raw.headers)
? Object.fromEntries(
Object.entries(raw.headers).filter(
([key, value]) =>
typeof key === "string" && typeof value === "string"
)
)
: undefined,
});
}
}
return servers;
}
export default function McpPage() {
const { projects, setProjects, activeProjectId } = useAppStore();
const [selectedProjectId, setSelectedProjectId] = useState("");
const [servers, setServers] = useState<McpServerItem[]>([]);
const [rawContent, setRawContent] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [projectsLoading, setProjectsLoading] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [search, setSearch] = useState("");
useEffect(() => {
loadProjects();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (projects.length === 0) {
setSelectedProjectId("");
return;
}
const hasCurrent = projects.some((project) => project.id === selectedProjectId);
if (hasCurrent) return;
const activeFromSidebar = activeProjectId
? projects.find((project) => project.id === activeProjectId)
: null;
if (activeFromSidebar) {
setSelectedProjectId(activeFromSidebar.id);
return;
}
setSelectedProjectId(projects[0].id);
}, [projects, selectedProjectId, activeProjectId]);
useEffect(() => {
loadProjectMcp(selectedProjectId);
}, [selectedProjectId]);
async function loadProjects() {
try {
setProjectsLoading(true);
const res = await fetch("/api/projects");
const data = await res.json();
if (Array.isArray(data)) setProjects(data);
} catch {
setProjects([]);
} finally {
setProjectsLoading(false);
}
}
async function loadProjectMcp(projectId: string) {
if (!projectId) {
setServers([]);
setRawContent(null);
setStatusMessage(null);
setLoading(false);
return;
}
try {
setLoading(true);
setStatusMessage(null);
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/mcp`);
const payload = await res.json();
if (!res.ok) {
const message =
typeof payload?.error === "string"
? payload.error
: "Failed to load MCP servers";
setStatusMessage(message);
setServers([]);
setRawContent(null);
return;
}
setRawContent(typeof payload?.content === "string" ? payload.content : null);
setServers(normalizeServers(payload?.servers));
} catch {
setStatusMessage("Failed to load MCP servers");
setServers([]);
setRawContent(null);
} finally {
setLoading(false);
}
}
const filteredServers = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return servers;
return servers.filter((server) => {
const parts = [server.id, server.transport, server.command, server.url]
.filter((value): value is string => typeof value === "string")
.join("\n")
.toLowerCase();
return parts.includes(query);
});
}, [servers, search]);
return (
<div className="[--header-height:calc(--spacing(14))]">
<SidebarProvider className="flex flex-col">
<SiteHeader title="MCP" />
<div className="flex flex-1">
<AppSidebar />
<SidebarInset>
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
<div className="space-y-1">
<h2 className="text-2xl font-semibold">MCP Servers</h2>
<p className="text-sm text-muted-foreground">
View MCP servers configured for each project from
<span className="font-mono"> .meta/mcp/servers.json </span>
and switch between projects.
</p>
</div>
<div className="flex flex-col md:flex-row gap-3">
<select
value={selectedProjectId}
onChange={(e) => setSelectedProjectId(e.target.value)}
className="rounded-md border bg-background px-3 py-2 text-sm md:w-96"
disabled={projectsLoading || projects.length === 0}
>
{projectsLoading && (
<option value="">Loading projects...</option>
)}
{!projectsLoading && projects.length === 0 && (
<option value="">No projects available</option>
)}
{!projectsLoading &&
projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name} ({project.id})
</option>
))}
</select>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search MCP servers..."
className="md:max-w-sm"
/>
</div>
{statusMessage && (
<div className="rounded-md border bg-muted/40 px-3 py-2 text-sm">
{statusMessage}
</div>
)}
<div className="rounded-lg border bg-card">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<Wrench className="size-4 text-primary" />
<h3 className="text-sm font-medium">Servers In Project</h3>
</div>
{!loading && selectedProjectId && (
<span className="text-xs text-muted-foreground">
{servers.length} total
</span>
)}
</div>
{loading ? (
<div className="py-12 text-center text-muted-foreground flex items-center justify-center gap-2">
<Loader2 className="size-4 animate-spin" />
Loading MCP servers...
</div>
) : !selectedProjectId ? (
<div className="p-4 text-sm text-muted-foreground">
Select a project to view MCP servers.
</div>
) : filteredServers.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">
No MCP servers found for this project.
</div>
) : (
<div className="divide-y">
{filteredServers.map((server) => (
<div key={server.id} className="p-4 space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
{server.transport === "http" ? (
<Globe className="size-4 text-primary shrink-0" />
) : (
<Terminal className="size-4 text-primary shrink-0" />
)}
<p className="font-medium truncate">{server.id}</p>
</div>
<span className="rounded border px-2 py-0.5 text-xs text-muted-foreground shrink-0">
{server.transport}
</span>
</div>
{server.transport === "stdio" ? (
<div className="space-y-1 text-sm text-muted-foreground">
<p>
Command: <span className="font-mono">{server.command || "-"}</span>
</p>
{server.args && server.args.length > 0 ? (
<p>
Args: <span className="font-mono">{server.args.join(" ")}</span>
</p>
) : null}
{server.cwd ? (
<p>
CWD: <span className="font-mono">{server.cwd}</span>
</p>
) : null}
{server.env && Object.keys(server.env).length > 0 ? (
<details className="pt-1">
<summary className="cursor-pointer text-xs">
Environment ({Object.keys(server.env).length})
</summary>
<pre className="mt-2 rounded border bg-muted/30 p-2 text-xs font-mono whitespace-pre-wrap break-words">
{JSON.stringify(server.env, null, 2)}
</pre>
</details>
) : null}
</div>
) : (
<div className="space-y-1 text-sm text-muted-foreground">
<p>
URL: <span className="font-mono">{server.url || "-"}</span>
</p>
{server.headers && Object.keys(server.headers).length > 0 ? (
<details className="pt-1">
<summary className="cursor-pointer text-xs">
Headers ({Object.keys(server.headers).length})
</summary>
<pre className="mt-2 rounded border bg-muted/30 p-2 text-xs font-mono whitespace-pre-wrap break-words">
{JSON.stringify(server.headers, null, 2)}
</pre>
</details>
) : null}
</div>
)}
</div>
))}
</div>
)}
</div>
{rawContent ? (
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium">Raw servers.json</h3>
</div>
<div className="p-4">
<pre className="max-h-[360px] overflow-auto rounded-lg border bg-muted/30 p-3 text-xs font-mono whitespace-pre-wrap break-words">
{rawContent}
</pre>
</div>
</div>
) : null}
</div>
</SidebarInset>
</div>
</SidebarProvider>
</div>
);
}

View File

@@ -0,0 +1,220 @@
"use client";
import { useEffect, useState } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header";
import {
SidebarInset,
SidebarProvider,
} from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Brain, Search, Trash2 } from "lucide-react";
import { KnowledgeSection } from "@/components/knowledge-section";
interface MemoryItem {
id: string;
text: string;
metadata: Record<string, unknown>;
score?: number;
}
interface ProjectOption {
id: string;
name: string;
}
export default function MemoryPage() {
const [memories, setMemories] = useState<MemoryItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [subdir, setSubdir] = useState("main");
const [projects, setProjects] = useState<ProjectOption[]>([]);
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
useEffect(() => {
loadMemories();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subdir]);
useEffect(() => {
loadProjects();
}, []);
async function loadProjects() {
try {
setIsLoadingProjects(true);
const res = await fetch("/api/projects");
const data = await res.json();
if (Array.isArray(data)) {
const mapped: ProjectOption[] = data.map((p) => ({
id: p.id,
name: typeof p.name === "string" && p.name.trim() ? p.name : p.id,
}));
setProjects(mapped);
}
} catch {
setProjects([]);
} finally {
setIsLoadingProjects(false);
}
}
async function loadMemories() {
try {
const res = await fetch(`/api/memory?subdir=${subdir}`);
const data = await res.json();
if (Array.isArray(data)) setMemories(data);
} catch {
setMemories([]);
}
}
async function handleSearch() {
if (!searchQuery.trim()) {
loadMemories();
return;
}
setIsSearching(true);
try {
const res = await fetch(
`/api/memory?query=${encodeURIComponent(searchQuery)}&subdir=${subdir}&limit=20`
);
const data = await res.json();
if (Array.isArray(data)) setMemories(data);
} catch {
// ignore
}
setIsSearching(false);
}
async function handleDelete(id: string) {
await fetch(`/api/memory?id=${id}&subdir=${subdir}`, {
method: "DELETE",
});
loadMemories();
}
return (
<div className="[--header-height:calc(--spacing(14))]">
<SidebarProvider className="flex flex-col">
<SiteHeader title="Memory Dashboard" />
<div className="flex flex-1">
<AppSidebar />
<SidebarInset>
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6 max-w-4xl mx-auto w-full">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-2xl font-semibold">Memory Dashboard</h2>
<p className="text-sm text-muted-foreground">
Browse and search the agent&apos;s persistent vector memory.
</p>
</div>
<select
value={subdir}
onChange={(e) => setSubdir(e.target.value)}
className="rounded-md border bg-background px-3 py-2 text-sm max-w-xs"
>
{isLoadingProjects && (
<option disabled>Loading projects...</option>
)}
{!isLoadingProjects &&
projects.map((project) => (
<option key={project.id} value={project.id}>
Project: {project.name} ({project.id})
</option>
))}
</select>
</div>
{/* Controls */}
<div className="flex items-center gap-3">
{subdir === "main" || subdir === "projects" ? (
<div className="flex-1 flex gap-2">
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search memories..."
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
/>
<Button
onClick={handleSearch}
disabled={isSearching}
variant="secondary"
className="gap-2"
>
<Search className="size-4" />
Search
</Button>
</div>
) : (
<div className="flex-1" />
)}
</div>
{/* Memory content */}
{subdir === "main" || subdir === "projects" ? (
<div className="space-y-2">
{memories.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<Brain className="size-12 mx-auto mb-4 opacity-50" />
<p>No memories found.</p>
<p className="text-xs mt-1">
The agent will save memories as it learns from conversations.
</p>
</div>
)}
{memories.map((mem) => (
<div
key={mem.id}
className="border rounded-lg p-3 bg-card group"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm whitespace-pre-wrap">
{mem.text}
</p>
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
{mem.metadata?.area ? (
<span className="bg-muted px-1.5 py-0.5 rounded">
{String(mem.metadata.area)}
</span>
) : null}
{mem.score !== undefined && (
<span>
Score: {(mem.score * 100).toFixed(1)}%
</span>
)}
{mem.metadata?.createdAt ? (
<span>
{new Date(
String(mem.metadata.createdAt)
).toLocaleDateString()}
</span>
) : null}
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(mem.id)}
className="opacity-0 group-hover:opacity-100 shrink-0 text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))}
</div>
) : (
<KnowledgeSection projectId={subdir} />
)}
</div>
</SidebarInset>
</div>
</SidebarProvider>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header";
import { TelegramIntegrationManager } from "@/components/telegram-integration-manager";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
export default function MessengersPage() {
return (
<div className="[--header-height:calc(--spacing(14))]">
<SidebarProvider className="flex flex-col">
<SiteHeader title="Messengers" />
<div className="flex flex-1">
<AppSidebar />
<SidebarInset>
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6 max-w-5xl mx-auto w-full">
<div className="space-y-2">
<h2 className="text-2xl font-semibold">Messenger Integrations</h2>
<p className="text-sm text-muted-foreground">
Connect external messengers to the agent. Telegram is available now.
</p>
</div>
<section className="rounded-lg border bg-card p-4 space-y-2">
<h3 className="text-lg font-medium">Telegram Commands</h3>
<p className="text-sm text-muted-foreground">
Available commands in Telegram private chat:
</p>
<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">
<li>
<span className="font-mono">/start</span> - show help and connection status
</li>
<li>
<span className="font-mono">/help</span> - show help
</li>
<li>
<span className="font-mono">/code &lt;access_code&gt;</span> - activate access for your Telegram user_id
</li>
<li>
<span className="font-mono">/new</span> - start a new conversation and reset context
</li>
</ul>
<p className="text-xs text-muted-foreground">
Notes: only private chats are supported. Uploaded files are saved into chat files,
and you can ask the agent to send a local file back to Telegram.
</p>
</section>
<TelegramIntegrationManager />
</div>
</SidebarInset>
</div>
</SidebarProvider>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { AppSidebar } from "@/components/app-sidebar"
import { SiteHeader } from "@/components/site-header"
import { ChatPanel } from "@/components/chat/chat-panel"
import {
SidebarInset,
SidebarProvider,
} from "@/components/ui/sidebar"
import { redirect } from "next/navigation"
import {
getAllProjects,
} from "@/lib/storage/project-store"
export const dynamic = "force-dynamic"
export default async function DashboardPage() {
const projects = await getAllProjects()
if (projects.length === 0) {
redirect("/dashboard/projects")
}
return (
<div className="[--header-height:calc(--spacing(14))]">
<SidebarProvider className="flex flex-col">
<SiteHeader title="Chat" />
<div className="flex flex-1">
<AppSidebar />
<SidebarInset>
<div className="flex flex-1 flex-col h-[calc(100svh-var(--header-height))]">
<ChatPanel />
</div>
</SidebarInset>
</div>
</SidebarProvider>
</div>
)
}

View File

@@ -0,0 +1,116 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Loader2 } from "lucide-react";
import { KnowledgeSection } from "@/components/knowledge-section";
import { ProjectContextSection } from "@/components/project-context-section";
import { CronSection } from "@/components/cron-section";
import type { Project } from "@/lib/types";
export default function ProjectDetailsPage() {
const params = useParams();
const router = useRouter();
const id = params.id as string;
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/projects/${id}`)
.then((res) => {
if (!res.ok) throw new Error("Project not found");
return res.json();
})
.then((data: Project) => {
setProject(data);
setLoading(false);
})
.catch(() => {
setProject(null); // Explicitly set null on error
setLoading(false);
});
}, [id]);
if (loading) {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="size-8 animate-spin text-primary" />
</div>
);
}
if (!project) {
return (
<div className="flex h-screen flex-col items-center justify-center gap-4">
<h1 className="text-2xl font-bold">Project Not Found</h1>
<Button onClick={() => router.push("/dashboard/projects")}>
Back to Projects
</Button>
</div>
);
}
return (
<div className="[--header-height:calc(--spacing(14))]">
<SidebarProvider className="flex flex-col">
<SiteHeader title={project.name} />
<div className="flex flex-1">
<AppSidebar />
<SidebarInset>
<div className="flex flex-1 flex-col gap-6 p-4 md:p-8 max-w-5xl mx-auto w-full">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-2">
<Button
variant="ghost"
size="icon"
className="-ml-2 h-8 w-8"
onClick={() => router.push("/dashboard/projects")}
>
<ArrowLeft className="size-4" />
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{project.name}</h1>
</div>
<p className="text-muted-foreground">
{project.description || "No description provided."}
</p>
</div>
{/* Could handle project settings here */}
{/* <Button variant="outline" size="sm" className="gap-2">
<Settings className="size-4" />
Settings
</Button> */}
</div>
{/* Instructions */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Instructions
</h3>
<div className="bg-muted/50 p-4 rounded-lg text-sm font-mono whitespace-pre-wrap">
{project.instructions || "No custom instructions defined."}
</div>
</div>
{/* MCP + Skills */}
<ProjectContextSection projectId={project.id} />
{/* Cron Jobs */}
<CronSection projectId={project.id} />
{/* Knowledge Base */}
<KnowledgeSection projectId={project.id} />
</div>
</SidebarInset>
</div>
</SidebarProvider>
</div>
);
}

View File

@@ -0,0 +1,950 @@
"use client";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TelegramIntegrationManager } from "@/components/telegram-integration-manager";
import { ChatModelWizard, EmbeddingsModelWizard } from "@/components/settings/model-wizards";
import type { AppSettings } from "@/lib/types";
import { updateSettingsByPath } from "@/lib/settings/update-settings-path";
import {
AlertTriangle,
Check,
FolderOpen,
Loader2,
Plus,
Puzzle,
Trash2,
X,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAppStore } from "@/store/app-store";
type OnboardingStep = -1 | 0 | 1 | 2 | 3 | 4;
interface BundledSkillItem {
name: string;
description: string;
installed: boolean;
license?: string;
compatibility?: string;
}
interface AuthStatusResponse {
authenticated: boolean;
username: string | null;
mustChangeCredentials: boolean;
}
function OnboardingStepIndicator({
step,
currentStep,
label,
}: {
step: 0 | 1 | 2 | 3 | 4;
currentStep: OnboardingStep;
label: string;
}) {
const completed = currentStep > step;
const active = currentStep === step;
return (
<div className="flex items-center gap-2">
<div
className={`flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold ${
completed
? "bg-emerald-500 text-white"
: active
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{completed ? <Check className="size-3.5" /> : step}
</div>
<span
className={`text-xs ${
active ? "text-foreground font-medium" : "text-muted-foreground"
}`}
>
{label}
</span>
</div>
);
}
function ProjectsPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
const { projects, setProjects, setActiveProjectId } = useAppStore();
const isOnboardingQuery = searchParams.get("onboarding") === "1";
const shouldOpenCreate = searchParams.get("create") === "1" || isOnboardingQuery;
const [projectsLoading, setProjectsLoading] = useState(true);
const [authStatusLoading, setAuthStatusLoading] = useState(true);
const [mustChangeCredentials, setMustChangeCredentials] = useState(false);
const [credentialUsername, setCredentialUsername] = useState("");
const [credentialPassword, setCredentialPassword] = useState("");
const [credentialPasswordConfirm, setCredentialPasswordConfirm] = useState("");
const [credentialsSaving, setCredentialsSaving] = useState(false);
const [credentialsError, setCredentialsError] = useState<string | null>(null);
const [credentialsStatus, setCredentialsStatus] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [creatingProject, setCreatingProject] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [newName, setNewName] = useState("");
const [newDescription, setNewDescription] = useState("");
const [newInstructions, setNewInstructions] = useState("");
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep>(-1);
const [onboardingProjectId, setOnboardingProjectId] = useState("");
const [settingsDraft, setSettingsDraft] = useState<AppSettings | null>(null);
const [settingsLoading, setSettingsLoading] = useState(false);
const [settingsSaving, setSettingsSaving] = useState(false);
const [settingsError, setSettingsError] = useState<string | null>(null);
const [bundledSkills, setBundledSkills] = useState<BundledSkillItem[]>([]);
const [bundledSkillsLoading, setBundledSkillsLoading] = useState(false);
const [installingSkill, setInstallingSkill] = useState<string | null>(null);
const [skillsStatus, setSkillsStatus] = useState<string | null>(null);
const forceCreateVisible = projects.length === 0 && onboardingStep !== 0;
const isCreateOpen = forceCreateVisible || showCreate;
const onboardingTargetProjectId = useMemo(
() => onboardingProjectId || projects[0]?.id || "",
[onboardingProjectId, projects]
);
const loadProjects = useCallback(async () => {
try {
setProjectsLoading(true);
const res = await fetch("/api/projects");
const data = await res.json();
if (Array.isArray(data)) {
setProjects(data);
} else {
setProjects([]);
}
} catch {
setProjects([]);
} finally {
setProjectsLoading(false);
}
}, [setProjects]);
const loadAuthStatus = useCallback(async () => {
try {
setAuthStatusLoading(true);
const res = await fetch("/api/auth/status", { cache: "no-store" });
const data = (await res.json()) as Partial<AuthStatusResponse>;
if (!res.ok) {
throw new Error("Failed to load auth status");
}
const currentUsername =
typeof data.username === "string" ? data.username : "";
if (currentUsername) {
setCredentialUsername(currentUsername);
}
setMustChangeCredentials(Boolean(data.mustChangeCredentials));
} catch {
setMustChangeCredentials(false);
} finally {
setAuthStatusLoading(false);
}
}, []);
const loadOnboardingSettings = useCallback(async () => {
try {
setSettingsLoading(true);
setSettingsError(null);
const res = await fetch("/api/settings", { cache: "no-store" });
const data = (await res.json()) as AppSettings;
if (!res.ok) {
throw new Error("Failed to load settings");
}
setSettingsDraft(data);
} catch (error) {
setSettingsError(
error instanceof Error ? error.message : "Failed to load settings"
);
} finally {
setSettingsLoading(false);
}
}, []);
const loadBundledSkills = useCallback(async (projectId: string) => {
if (!projectId) {
setBundledSkills([]);
return;
}
try {
setBundledSkillsLoading(true);
setSkillsStatus(null);
const res = await fetch(
`/api/skills?projectId=${encodeURIComponent(projectId)}`
);
const data = (await res.json()) as unknown;
if (!res.ok || !Array.isArray(data)) {
throw new Error("Failed to load skills");
}
setBundledSkills(
data.map((item) => ({
name: typeof item.name === "string" ? item.name : "unknown",
description:
typeof item.description === "string" ? item.description : "",
installed: Boolean(item.installed),
license: typeof item.license === "string" ? item.license : undefined,
compatibility:
typeof item.compatibility === "string"
? item.compatibility
: undefined,
}))
);
} catch {
setBundledSkills([]);
setSkillsStatus("Failed to load bundled skills.");
} finally {
setBundledSkillsLoading(false);
}
}, []);
useEffect(() => {
void loadProjects();
}, [loadProjects]);
useEffect(() => {
void loadAuthStatus();
}, [loadAuthStatus]);
useEffect(() => {
if (forceCreateVisible) {
setShowCreate(true);
return;
}
if (shouldOpenCreate && onboardingStep === 1) {
setShowCreate(true);
}
}, [forceCreateVisible, shouldOpenCreate, onboardingStep]);
useEffect(() => {
if (onboardingStep === 0) {
setShowCreate(false);
}
}, [onboardingStep]);
useEffect(() => {
if (!forceCreateVisible && onboardingStep >= 2) {
setShowCreate(false);
}
}, [forceCreateVisible, onboardingStep]);
useEffect(() => {
if (authStatusLoading || projectsLoading) return;
if (mustChangeCredentials) {
if (onboardingStep !== 0) {
setOnboardingStep(0);
}
return;
}
if (projects.length === 0) {
if (onboardingStep === -1 || onboardingStep === 0) {
setOnboardingStep(1);
}
setOnboardingProjectId("");
return;
}
if (onboardingStep === 1) {
setOnboardingStep(2);
return;
}
if (onboardingStep === -1 && isOnboardingQuery) {
setOnboardingStep(2);
}
}, [
authStatusLoading,
projectsLoading,
mustChangeCredentials,
projects.length,
onboardingStep,
isOnboardingQuery,
]);
useEffect(() => {
if (onboardingStep !== 2 || settingsDraft) return;
void loadOnboardingSettings();
}, [onboardingStep, settingsDraft, loadOnboardingSettings]);
useEffect(() => {
if (onboardingStep !== 4 || !onboardingTargetProjectId) return;
void loadBundledSkills(onboardingTargetProjectId);
}, [onboardingStep, onboardingTargetProjectId, loadBundledSkills]);
async function handleCreate() {
const trimmedName = newName.trim();
if (!trimmedName) return;
const hadNoProjects = projects.length === 0;
try {
setCreatingProject(true);
setCreateError(null);
const res = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: trimmedName,
description: newDescription.trim(),
instructions: newInstructions.trim(),
memoryMode: "isolated",
}),
});
const payload = (await res.json()) as { id?: string; error?: string };
if (!res.ok || !payload?.id) {
throw new Error(payload?.error || "Failed to create project");
}
setNewName("");
setNewDescription("");
setNewInstructions("");
setActiveProjectId(payload.id);
setOnboardingProjectId(payload.id);
if (hadNoProjects) {
setOnboardingStep(2);
setSettingsDraft(null);
setShowCreate(false);
} else {
setShowCreate(false);
}
await loadProjects();
} catch (error) {
setCreateError(
error instanceof Error ? error.message : "Failed to create project"
);
} finally {
setCreatingProject(false);
}
}
async function handleUpdateCredentials() {
const username = credentialUsername.trim();
const password = credentialPassword.trim();
const passwordConfirm = credentialPasswordConfirm.trim();
if (!username) {
setCredentialsError("Username is required.");
return;
}
if (password.length < 8) {
setCredentialsError("Password must be at least 8 characters.");
return;
}
if (password !== passwordConfirm) {
setCredentialsError("Password confirmation does not match.");
return;
}
try {
setCredentialsSaving(true);
setCredentialsError(null);
setCredentialsStatus(null);
const res = await fetch("/api/auth/credentials", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const payload = (await res.json().catch(() => null)) as
| { error?: string; success?: boolean }
| null;
if (!res.ok) {
throw new Error(payload?.error || "Failed to update credentials");
}
setMustChangeCredentials(false);
setCredentialsStatus("Credentials updated.");
setCredentialPassword("");
setCredentialPasswordConfirm("");
if (projects.length === 0) {
setOnboardingStep(1);
} else {
setOnboardingStep(2);
}
const params = new URLSearchParams(searchParams.toString());
params.delete("credentials");
const nextQuery = params.toString();
router.replace(
nextQuery ? `/dashboard/projects?${nextQuery}` : "/dashboard/projects"
);
router.refresh();
} catch (error) {
setCredentialsError(
error instanceof Error ? error.message : "Failed to update credentials"
);
} finally {
setCredentialsSaving(false);
}
}
async function handleDelete(id: string) {
if (!confirm("Delete this project? This cannot be undone.")) return;
await fetch(`/api/projects/${id}`, { method: "DELETE" });
await loadProjects();
}
async function handleSaveSettingsStep() {
if (!settingsDraft) return;
try {
setSettingsSaving(true);
setSettingsError(null);
const res = await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settingsDraft),
});
const data = (await res.json()) as AppSettings | { error?: string };
if (!res.ok) {
throw new Error(
"error" in data && typeof data.error === "string"
? data.error
: "Failed to save settings"
);
}
setSettingsDraft(data as AppSettings);
setOnboardingStep(3);
} catch (error) {
setSettingsError(
error instanceof Error ? error.message : "Failed to save settings"
);
} finally {
setSettingsSaving(false);
}
}
async function handleInstallSkill(skillName: string) {
if (!onboardingTargetProjectId) return;
try {
setInstallingSkill(skillName);
setSkillsStatus(null);
const res = await fetch("/api/skills", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId: onboardingTargetProjectId,
skillName,
}),
});
const payload = (await res.json()) as { error?: string };
if (!res.ok) {
throw new Error(payload.error || "Failed to install skill");
}
await loadBundledSkills(onboardingTargetProjectId);
setSkillsStatus(`Installed "${skillName}"`);
} catch (error) {
setSkillsStatus(
error instanceof Error ? error.message : "Failed to install skill"
);
} finally {
setInstallingSkill(null);
}
}
function finishOnboarding() {
setOnboardingStep(-1);
router.push("/dashboard");
}
function updateOnboardingSettings(path: string, value: unknown) {
setSettingsDraft((prev) => {
if (!prev) return null;
return updateSettingsByPath(prev, path, value);
});
}
return (
<div className="[--header-height:calc(--spacing(14))]">
<SidebarProvider className="flex flex-col">
<SiteHeader title="Projects" />
<div className="flex flex-1">
<AppSidebar />
<SidebarInset>
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold">Projects</h2>
<p className="text-sm text-muted-foreground">
Manage project workspaces and run onboarding for first setup.
</p>
</div>
<Button
onClick={() => {
if (forceCreateVisible || onboardingStep === 0) return;
setShowCreate(!showCreate);
}}
className="gap-2"
disabled={forceCreateVisible || onboardingStep === 0}
>
{showCreate ? (
<>
<X className="size-4" />
Cancel
</>
) : (
<>
<Plus className="size-4" />
New Project
</>
)}
</Button>
</div>
{onboardingStep >= 0 && (
<section className="rounded-lg border bg-card p-4 space-y-4">
<div className="space-y-2">
<h3 className="font-medium">Project Onboarding</h3>
<div className="flex flex-wrap gap-4">
<OnboardingStepIndicator
step={0}
currentStep={onboardingStep}
label="Credentials"
/>
<OnboardingStepIndicator
step={1}
currentStep={onboardingStep}
label="Create project"
/>
<OnboardingStepIndicator
step={2}
currentStep={onboardingStep}
label="Model API keys"
/>
<OnboardingStepIndicator
step={3}
currentStep={onboardingStep}
label="Telegram"
/>
<OnboardingStepIndicator
step={4}
currentStep={onboardingStep}
label="Skills"
/>
</div>
</div>
{onboardingStep === 0 && (
<div className="rounded-lg border p-4 space-y-4">
<div className="space-y-1">
<h4 className="font-medium">Step 0: Replace default login</h4>
<p className="text-sm text-muted-foreground">
Set a new username and password before continuing.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="credential-username">Username</Label>
<Input
id="credential-username"
value={credentialUsername}
onChange={(event) => setCredentialUsername(event.target.value)}
placeholder="admin"
autoComplete="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="credential-password">New password</Label>
<Input
id="credential-password"
type="password"
value={credentialPassword}
onChange={(event) => setCredentialPassword(event.target.value)}
placeholder="At least 8 characters"
autoComplete="new-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="credential-password-confirm">
Confirm password
</Label>
<Input
id="credential-password-confirm"
type="password"
value={credentialPasswordConfirm}
onChange={(event) =>
setCredentialPasswordConfirm(event.target.value)
}
placeholder="Repeat password"
autoComplete="new-password"
/>
</div>
{credentialsError && (
<p className="text-sm text-destructive">{credentialsError}</p>
)}
{credentialsStatus && (
<p className="text-sm text-emerald-600">{credentialsStatus}</p>
)}
<div className="flex items-center gap-2">
<Button
onClick={handleUpdateCredentials}
disabled={credentialsSaving || authStatusLoading}
className="gap-2"
>
{credentialsSaving ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving...
</>
) : (
"Save and Continue"
)}
</Button>
</div>
</div>
)}
{onboardingStep === 1 && (
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">
Step 1: Create your first project to continue onboarding.
</p>
</div>
)}
{onboardingStep === 2 && (
<div className="rounded-lg border p-4 space-y-4">
<h4 className="font-medium">Step 2: Model and Vector API Settings</h4>
<p className="text-sm text-muted-foreground">
Same model setup UI as in Settings, including model loading by API key.
</p>
{settingsLoading || !settingsDraft ? (
<div className="py-8 flex items-center justify-center text-muted-foreground">
<Loader2 className="size-4 animate-spin mr-2" />
Loading settings...
</div>
) : (
<>
<ChatModelWizard
settings={settingsDraft}
updateSettings={updateOnboardingSettings}
/>
<EmbeddingsModelWizard
settings={settingsDraft}
updateSettings={updateOnboardingSettings}
/>
{settingsError && (
<p className="text-sm text-destructive">{settingsError}</p>
)}
<div className="flex items-center gap-2">
<Button
onClick={handleSaveSettingsStep}
disabled={settingsSaving}
className="gap-2"
>
{settingsSaving ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving...
</>
) : (
"Save and Continue"
)}
</Button>
<Button
variant="ghost"
onClick={() => setOnboardingStep(3)}
>
Skip
</Button>
</div>
</>
)}
</div>
)}
{onboardingStep === 3 && (
<div className="rounded-lg border p-4 space-y-4">
<div className="space-y-1">
<h4 className="font-medium">Step 3: Connect Telegram</h4>
<p className="text-sm text-muted-foreground">
Configure bot token and webhook to receive messages in Telegram.
</p>
</div>
<TelegramIntegrationManager />
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setOnboardingStep(2)}>
Back
</Button>
<Button onClick={() => setOnboardingStep(4)}>
Continue to Skills
</Button>
</div>
</div>
)}
{onboardingStep === 4 && (
<div className="rounded-lg border p-4 space-y-4">
<div className="space-y-1">
<h4 className="font-medium">Step 4: Add Skills to Project</h4>
<p className="text-sm text-muted-foreground">
Install bundled skills into the project to extend agent capabilities.
</p>
<p className="text-xs text-muted-foreground">
Target project:{" "}
<span className="font-mono">
{onboardingTargetProjectId || "not selected"}
</span>
</p>
</div>
{skillsStatus && (
<div className="rounded-md border bg-muted/40 px-3 py-2 text-sm">
{skillsStatus}
</div>
)}
{bundledSkillsLoading ? (
<div className="py-8 flex items-center justify-center text-muted-foreground">
<Loader2 className="size-4 animate-spin mr-2" />
Loading skills...
</div>
) : bundledSkills.length === 0 ? (
<p className="text-sm text-muted-foreground">
No bundled skills available.
</p>
) : (
<div className="grid gap-3">
{bundledSkills.map((skill) => (
<div
key={skill.name}
className="rounded-lg border p-3 bg-card flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<Puzzle className="size-4 text-primary shrink-0" />
<p className="font-medium truncate">{skill.name}</p>
</div>
<p className="text-sm text-muted-foreground mt-1">
{skill.description || "No description"}
</p>
</div>
<Button
onClick={() => handleInstallSkill(skill.name)}
disabled={
!onboardingTargetProjectId ||
skill.installed ||
installingSkill === skill.name
}
variant={skill.installed ? "secondary" : "default"}
className="shrink-0"
>
{installingSkill === skill.name ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Installing
</>
) : skill.installed ? (
"Installed"
) : (
"Install"
)}
</Button>
</div>
))}
</div>
)}
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setOnboardingStep(3)}>
Back
</Button>
<Button onClick={finishOnboarding}>Finish Onboarding</Button>
</div>
</div>
)}
</section>
)}
{isCreateOpen && (
<div className="border rounded-lg p-4 bg-card space-y-4">
<div className="space-y-1">
<h3 className="font-medium">
{forceCreateVisible
? "Step 1: Create your first project"
: "Create Project"}
</h3>
{forceCreateVisible && (
<p className="text-sm text-muted-foreground">
This window stays open until a project is created.
</p>
)}
</div>
{forceCreateVisible && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-300">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<p>
Warning: This app can execute scripts and shell commands via AI
agents. Some actions may be irreversible (for example, deleting or
overwriting files).
</p>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="name">Project Name</Label>
<Input
id="name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="My Project"
/>
</div>
<div className="space-y-2">
<Label htmlFor="desc">Description</Label>
<Input
id="desc"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder="Brief description of the project"
/>
</div>
<div className="space-y-2">
<Label htmlFor="instructions">Instructions for AI Agent</Label>
<textarea
id="instructions"
value={newInstructions}
onChange={(e) => setNewInstructions(e.target.value)}
placeholder="Special instructions for the AI when working on this project..."
className="w-full rounded-md border bg-background px-3 py-2 text-sm min-h-[80px] resize-y focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{createError && (
<p className="text-sm text-destructive">{createError}</p>
)}
<div className="flex items-center gap-2">
<Button
onClick={handleCreate}
disabled={!newName.trim() || creatingProject}
className="gap-2"
>
{creatingProject ? (
<>
<Loader2 className="size-4 animate-spin" />
Creating...
</>
) : (
"Create Project"
)}
</Button>
{!forceCreateVisible && (
<Button variant="ghost" onClick={() => setShowCreate(false)}>
Close
</Button>
)}
</div>
</div>
)}
<div className="space-y-3">
{!projectsLoading && projects.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<FolderOpen className="size-12 mx-auto mb-4 opacity-50" />
<p>No projects yet. Create one to get started.</p>
</div>
)}
{projects.map((project) => (
<div
key={project.id}
className="border rounded-lg p-4 bg-card hover:shadow-sm transition-shadow cursor-pointer"
onClick={() => router.push(`/dashboard/projects/${project.id}`)}
>
<div className="flex items-start justify-between">
<div className="space-y-1 flex-1">
<div className="flex items-center gap-2">
<FolderOpen className="size-5 text-primary" />
<h3 className="font-semibold">{project.name}</h3>
</div>
{project.description && (
<p className="text-sm text-muted-foreground">
{project.description}
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground mt-2">
<span>
Memory:{" "}
{project.memoryMode === "isolated" ? "Isolated" : "Global"}
</span>
<span>
Created:{" "}
{new Date(project.createdAt).toLocaleDateString()}
</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
void handleDelete(project.id);
}}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))}
</div>
</div>
</SidebarInset>
</div>
</SidebarProvider>
</div>
);
}
export default function ProjectsPage() {
return (
<Suspense fallback={<div className="p-4 text-sm text-muted-foreground">Loading...</div>}>
<ProjectsPageClient />
</Suspense>
);
}

View File

@@ -0,0 +1,391 @@
"use client";
import { useEffect, useState } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Check, Loader2, Save, ShieldCheck } from "lucide-react";
import { ChatModelWizard, EmbeddingsModelWizard } from "@/components/settings/model-wizards";
import { updateSettingsByPath } from "@/lib/settings/update-settings-path";
import type { AppSettings } from "@/lib/types";
export default function SettingsPage() {
const [settings, setSettings] = useState<AppSettings | null>(null);
const [saved, setSaved] = useState(false);
const [loading, setLoading] = useState(true);
const [authUsername, setAuthUsername] = useState("");
const [authPassword, setAuthPassword] = useState("");
const [authPasswordConfirm, setAuthPasswordConfirm] = useState("");
const [authSaving, setAuthSaving] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const [authSaved, setAuthSaved] = useState(false);
useEffect(() => {
fetch("/api/settings")
.then((response) => response.json())
.then((data) => {
setSettings(data);
if (data?.auth?.username && typeof data.auth.username === "string") {
setAuthUsername(data.auth.username);
}
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
async function handleSave() {
if (!settings) return;
await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
function updateSettings(path: string, value: unknown) {
setSettings((prev) => {
if (!prev) return null;
return updateSettingsByPath(prev, path, value);
});
}
async function handleUpdateAuth() {
const username = authUsername.trim();
const password = authPassword.trim();
const passwordConfirm = authPasswordConfirm.trim();
if (!username) {
setAuthError("Username is required.");
return;
}
if (password.length < 8) {
setAuthError("Password must be at least 8 characters.");
return;
}
if (password !== passwordConfirm) {
setAuthError("Password confirmation does not match.");
return;
}
try {
setAuthSaving(true);
setAuthError(null);
setAuthSaved(false);
const response = await fetch("/api/auth/credentials", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string; username?: string }
| null;
if (!response.ok) {
throw new Error(payload?.error || "Failed to update credentials.");
}
const normalizedUsername = payload?.username || username;
setAuthUsername(normalizedUsername);
setAuthPassword("");
setAuthPasswordConfirm("");
setAuthSaved(true);
setTimeout(() => setAuthSaved(false), 2000);
setSettings((prev) => {
if (!prev) return prev;
return {
...prev,
auth: {
...prev.auth,
username: normalizedUsername,
mustChangeCredentials: false,
},
};
});
} catch (error) {
setAuthError(
error instanceof Error ? error.message : "Failed to update credentials."
);
} finally {
setAuthSaving(false);
}
}
if (loading) {
return (
<div className="[--header-height:calc(--spacing(14))]">
<SidebarProvider className="flex flex-col">
<SiteHeader title="Settings" />
<div className="flex flex-1">
<AppSidebar />
<SidebarInset>
<div className="flex flex-1 items-center justify-center">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
</SidebarInset>
</div>
</SidebarProvider>
</div>
);
}
if (!settings) return null;
return (
<div className="[--header-height:calc(--spacing(14))]">
<SidebarProvider className="flex flex-col">
<SiteHeader title="Settings" />
<div className="flex flex-1">
<AppSidebar />
<SidebarInset>
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6 max-w-3xl mx-auto w-full overflow-y-auto">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold">Settings</h2>
<p className="text-sm text-muted-foreground">
Configure AI models, tools, and preferences.
</p>
</div>
<Button onClick={handleSave} className="gap-2">
{saved ? (
<>
<Check className="size-4" />
Saved
</>
) : (
<>
<Save className="size-4" />
Save Settings
</>
)}
</Button>
</div>
<ChatModelWizard settings={settings} updateSettings={updateSettings} />
<EmbeddingsModelWizard settings={settings} updateSettings={updateSettings} />
<section className="border rounded-xl p-5 bg-card space-y-4">
<div className="flex items-center gap-2">
<ShieldCheck className="size-5 text-primary" />
<h3 className="font-semibold text-lg">Authentication</h3>
</div>
<p className="text-sm text-muted-foreground">
Change dashboard login username and password.
</p>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="auth-username">Username</Label>
<Input
id="auth-username"
value={authUsername}
onChange={(e) => setAuthUsername(e.target.value)}
autoComplete="username"
placeholder="admin"
/>
</div>
<div className="space-y-2">
<Label htmlFor="auth-password">New Password</Label>
<Input
id="auth-password"
type="password"
value={authPassword}
onChange={(e) => setAuthPassword(e.target.value)}
autoComplete="new-password"
placeholder="At least 8 characters"
/>
</div>
<div className="space-y-2">
<Label htmlFor="auth-password-confirm">Confirm Password</Label>
<Input
id="auth-password-confirm"
type="password"
value={authPasswordConfirm}
onChange={(e) => setAuthPasswordConfirm(e.target.value)}
autoComplete="new-password"
placeholder="Repeat password"
/>
</div>
</div>
{authError && <p className="text-sm text-destructive">{authError}</p>}
<div className="flex items-center gap-2">
<Button
onClick={handleUpdateAuth}
disabled={authSaving}
className="gap-2"
>
{authSaving ? (
<>
<Loader2 className="size-4 animate-spin" />
Updating...
</>
) : authSaved ? (
<>
<Check className="size-4" />
Updated
</>
) : (
"Update Credentials"
)}
</Button>
</div>
</section>
<section className="border rounded-xl p-5 bg-card space-y-4">
<h3 className="font-semibold text-lg">Code Execution</h3>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="code-enabled"
checked={settings.codeExecution.enabled}
onChange={(e) =>
updateSettings("codeExecution.enabled", e.target.checked)
}
className="rounded"
/>
<Label htmlFor="code-enabled">
Enable code execution (Python, Node.js, Shell)
</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Timeout (seconds)</Label>
<Input
type="number"
value={settings.codeExecution.timeout}
onChange={(e) =>
updateSettings(
"codeExecution.timeout",
parseInt(e.target.value, 10)
)
}
/>
</div>
<div className="space-y-2">
<Label>Max Output Length</Label>
<Input
type="number"
value={settings.codeExecution.maxOutputLength}
onChange={(e) =>
updateSettings(
"codeExecution.maxOutputLength",
parseInt(e.target.value, 10)
)
}
/>
</div>
</div>
</section>
<section className="border rounded-xl p-5 bg-card space-y-4">
<h3 className="font-semibold text-lg">Memory</h3>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="memory-enabled"
checked={settings.memory.enabled}
onChange={(e) => updateSettings("memory.enabled", e.target.checked)}
className="rounded"
/>
<Label htmlFor="memory-enabled">
Enable persistent vector memory
</Label>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label>Similarity Threshold</Label>
<Input
type="number"
step="0.05"
min="0"
max="1"
value={settings.memory.similarityThreshold}
onChange={(e) =>
updateSettings(
"memory.similarityThreshold",
parseFloat(e.target.value)
)
}
/>
</div>
<div className="space-y-2">
<Label>Max Results</Label>
<Input
type="number"
value={settings.memory.maxResults}
onChange={(e) =>
updateSettings("memory.maxResults", parseInt(e.target.value, 10))
}
/>
</div>
<div className="space-y-2">
<Label>Knowledge Chunk Size</Label>
<Input
type="number"
min="100"
max="4000"
step="50"
value={settings.memory.chunkSize}
onChange={(e) =>
updateSettings("memory.chunkSize", parseInt(e.target.value, 10))
}
/>
</div>
</div>
</section>
<section className="border rounded-xl p-5 bg-card space-y-4">
<h3 className="font-semibold text-lg">Web Search</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Provider</Label>
<select
value={settings.search.provider}
onChange={(e) => {
updateSettings("search.provider", e.target.value);
updateSettings("search.enabled", e.target.value !== "none");
}}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="none">Disabled</option>
<option value="searxng">SearXNG (self-hosted)</option>
<option value="tavily">Tavily API</option>
</select>
</div>
{settings.search.provider === "tavily" && (
<div className="space-y-2">
<Label>Tavily API Key</Label>
<Input
type="password"
value={settings.search.apiKey || ""}
onChange={(e) => updateSettings("search.apiKey", e.target.value)}
placeholder="tvly-..."
/>
</div>
)}
{settings.search.provider === "searxng" && (
<div className="space-y-2">
<Label>SearXNG URL</Label>
<Input
value={settings.search.baseUrl || ""}
onChange={(e) => updateSettings("search.baseUrl", e.target.value)}
placeholder="http://localhost:8080"
/>
</div>
)}
</div>
</section>
</div>
</SidebarInset>
</div>
</SidebarProvider>
</div>
);
}

View File

@@ -0,0 +1,444 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Loader2, PackagePlus, Puzzle, BookText } from "lucide-react";
import { useAppStore } from "@/store/app-store";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
interface BundledSkillItem {
name: string;
description: string;
license?: string;
compatibility?: string;
installed: boolean;
}
interface InstalledSkillItem {
name: string;
description: string;
content: string;
license?: string;
compatibility?: string;
}
export default function SkillsPage() {
const { projects, setProjects, activeProjectId } = useAppStore();
const [selectedProjectId, setSelectedProjectId] = useState("");
const [bundledSkills, setBundledSkills] = useState<BundledSkillItem[]>([]);
const [installedSkills, setInstalledSkills] = useState<InstalledSkillItem[]>([]);
const [bundledSkillsLoading, setBundledSkillsLoading] = useState(true);
const [installedSkillsLoading, setInstalledSkillsLoading] = useState(true);
const [projectsLoading, setProjectsLoading] = useState(false);
const [installingSkill, setInstallingSkill] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [selectedSkill, setSelectedSkill] = useState<InstalledSkillItem | null>(
null
);
const [isSkillSheetOpen, setIsSkillSheetOpen] = useState(false);
useEffect(() => {
loadProjects();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (projects.length === 0) {
setSelectedProjectId("");
return;
}
const hasCurrent = projects.some((project) => project.id === selectedProjectId);
if (hasCurrent) return;
const activeFromSidebar = activeProjectId
? projects.find((project) => project.id === activeProjectId)
: null;
if (activeFromSidebar) {
setSelectedProjectId(activeFromSidebar.id);
return;
}
setSelectedProjectId(projects[0].id);
}, [projects, selectedProjectId, activeProjectId]);
useEffect(() => {
loadBundledSkills(selectedProjectId);
if (!selectedProjectId) {
setInstalledSkills([]);
setInstalledSkillsLoading(false);
return;
}
loadInstalledSkills(selectedProjectId);
}, [selectedProjectId]);
async function loadProjects() {
try {
setProjectsLoading(true);
const res = await fetch("/api/projects");
const data = await res.json();
if (Array.isArray(data)) setProjects(data);
} catch {
setProjects([]);
} finally {
setProjectsLoading(false);
}
}
async function loadBundledSkills(projectId: string) {
try {
setBundledSkillsLoading(true);
const query = projectId
? `?projectId=${encodeURIComponent(projectId)}`
: "";
const res = await fetch(`/api/skills${query}`);
if (!res.ok) throw new Error("Failed to load skills");
const data = await res.json();
if (Array.isArray(data)) {
setBundledSkills(
data.map((item) => ({
name: typeof item.name === "string" ? item.name : "unknown",
description:
typeof item.description === "string"
? item.description
: "",
license:
typeof item.license === "string"
? item.license
: undefined,
compatibility:
typeof item.compatibility === "string"
? item.compatibility
: undefined,
installed: Boolean(item.installed),
}))
);
} else {
setBundledSkills([]);
}
} catch {
setBundledSkills([]);
} finally {
setBundledSkillsLoading(false);
}
}
async function loadInstalledSkills(projectId: string) {
try {
setInstalledSkillsLoading(true);
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/skills`);
if (!res.ok) throw new Error("Failed to load project skills");
const data = await res.json();
if (Array.isArray(data)) {
setInstalledSkills(
data.map((item) => ({
name: typeof item.name === "string" ? item.name : "unknown",
description:
typeof item.description === "string" ? item.description : "",
content: typeof item.content === "string" ? item.content : "",
license:
typeof item.license === "string" ? item.license : undefined,
compatibility:
typeof item.compatibility === "string"
? item.compatibility
: undefined,
}))
);
} else {
setInstalledSkills([]);
}
} catch {
setInstalledSkills([]);
} finally {
setInstalledSkillsLoading(false);
}
}
async function handleInstall(skillName: string) {
if (!selectedProjectId) return;
setStatusMessage(null);
setInstallingSkill(skillName);
try {
const res = await fetch("/api/skills", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId: selectedProjectId,
skillName,
}),
});
const payload = await res.json();
if (!res.ok) {
const errorText =
typeof payload?.error === "string"
? payload.error
: "Failed to install skill";
setStatusMessage(errorText);
return;
}
await Promise.all([
loadBundledSkills(selectedProjectId),
loadInstalledSkills(selectedProjectId),
]);
const projectName =
projects.find((project) => project.id === selectedProjectId)?.name ??
selectedProjectId;
setStatusMessage(`Installed "${skillName}" into project "${projectName}".`);
} catch {
setStatusMessage("Failed to install skill");
} finally {
setInstallingSkill(null);
}
}
const filteredBundledSkills = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return bundledSkills;
return bundledSkills.filter((skill) => {
const haystack = `${skill.name}\n${skill.description}`.toLowerCase();
return haystack.includes(query);
});
}, [bundledSkills, search]);
const filteredInstalledSkills = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return installedSkills;
return installedSkills.filter((skill) => {
const haystack = `${skill.name}\n${skill.description}`.toLowerCase();
return haystack.includes(query);
});
}, [installedSkills, search]);
function handleOpenSkill(skill: InstalledSkillItem) {
setSelectedSkill(skill);
setIsSkillSheetOpen(true);
}
return (
<div className="[--header-height:calc(--spacing(14))]">
<SidebarProvider className="flex flex-col">
<SiteHeader title="Skills" />
<div className="flex flex-1">
<AppSidebar />
<SidebarInset>
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
<div className="space-y-1">
<h2 className="text-2xl font-semibold">Skills</h2>
<p className="text-sm text-muted-foreground">
Browse installed skills of the selected project and install bundled skills.
Installed skills live in
<span className="font-mono"> .meta/skills </span>
and bundled skills are copied there on install.
</p>
</div>
<div className="flex flex-col md:flex-row gap-3">
<select
value={selectedProjectId}
onChange={(e) => setSelectedProjectId(e.target.value)}
className="rounded-md border bg-background px-3 py-2 text-sm md:w-96"
disabled={projectsLoading || projects.length === 0}
>
{projectsLoading && (
<option value="">Loading projects...</option>
)}
{!projectsLoading && projects.length === 0 && (
<option value="">No projects available</option>
)}
{!projectsLoading &&
projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name} ({project.id})
</option>
))}
</select>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search skills..."
className="md:max-w-sm"
/>
</div>
{statusMessage && (
<div className="rounded-md border bg-muted/40 px-3 py-2 text-sm">
{statusMessage}
</div>
)}
<div className="rounded-lg border bg-card">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<BookText className="size-4 text-primary" />
<h3 className="text-sm font-medium">Installed In Project</h3>
</div>
{!installedSkillsLoading && selectedProjectId && (
<span className="text-xs text-muted-foreground">
{installedSkills.length} total
</span>
)}
</div>
{installedSkillsLoading ? (
<div className="py-10 text-center text-muted-foreground flex items-center justify-center gap-2">
<Loader2 className="size-4 animate-spin" />
Loading installed skills...
</div>
) : !selectedProjectId ? (
<div className="p-4 text-sm text-muted-foreground">
Select a project to view installed skills.
</div>
) : filteredInstalledSkills.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">
No installed skills found for this project.
</div>
) : (
<div className="divide-y">
{filteredInstalledSkills.map((skill) => (
<button
key={skill.name}
type="button"
className="w-full p-3 flex items-start gap-3 hover:bg-muted/40 transition-colors text-left"
onClick={() => handleOpenSkill(skill)}
>
<div className="bg-primary/10 p-2 rounded shrink-0 mt-0.5">
<BookText className="size-4 text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{skill.name}</p>
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">
{skill.description || "No description"}
</p>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
{skill.license ? (
<span className="rounded border px-2 py-0.5">
License: {skill.license}
</span>
) : null}
{skill.compatibility ? (
<span className="rounded border px-2 py-0.5">
Compatibility: {skill.compatibility}
</span>
) : null}
</div>
</div>
</button>
))}
</div>
)}
</div>
<div className="space-y-1">
<h3 className="text-lg font-medium">Bundled Skills Catalog</h3>
<p className="text-sm text-muted-foreground">
Install prebuilt skills into the selected project. Skills are copied to
<span className="font-mono"> .meta/skills </span>
of that project.
</p>
</div>
{bundledSkillsLoading ? (
<div className="py-14 text-center text-muted-foreground flex items-center justify-center gap-2">
<Loader2 className="size-4 animate-spin" />
Loading bundled skills...
</div>
) : filteredBundledSkills.length === 0 ? (
<div className="py-14 text-center text-muted-foreground">
No bundled skills found.
</div>
) : (
<div className="grid gap-3">
{filteredBundledSkills.map((skill) => (
<div
key={skill.name}
className="rounded-lg border bg-card p-4 flex items-start justify-between gap-4"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<Puzzle className="size-4 text-primary" />
<h3 className="font-medium truncate">{skill.name}</h3>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{skill.description || "No description"}
</p>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
{skill.license ? (
<span className="rounded border px-2 py-0.5">
License: {skill.license}
</span>
) : null}
{skill.compatibility ? (
<span className="rounded border px-2 py-0.5">
Compatibility: {skill.compatibility}
</span>
) : null}
</div>
</div>
<Button
onClick={() => handleInstall(skill.name)}
disabled={
!selectedProjectId ||
skill.installed ||
installingSkill === skill.name
}
variant={skill.installed ? "secondary" : "default"}
className="shrink-0 gap-2"
>
{installingSkill === skill.name ? (
<>
<Loader2 className="size-4 animate-spin" />
Installing
</>
) : skill.installed ? (
"Installed"
) : (
<>
<PackagePlus className="size-4" />
Install
</>
)}
</Button>
</div>
))}
</div>
)}
</div>
</SidebarInset>
</div>
</SidebarProvider>
<Sheet open={isSkillSheetOpen} onOpenChange={setIsSkillSheetOpen}>
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col">
<SheetHeader>
<SheetTitle className="truncate pr-8">
Skill: {selectedSkill?.name ?? ""}
</SheetTitle>
<SheetDescription>
{selectedSkill?.description || "Skill instructions"}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 pb-4">
<pre className="rounded-lg border bg-muted/30 p-3 text-sm font-mono whitespace-pre-wrap break-words">
{selectedSkill?.content || "No skill content."}
</pre>
</div>
</SheetContent>
</Sheet>
</div>
);
}

122
src/app/globals.css Normal file
View File

@@ -0,0 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

34
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Eggent",
description: "AI Agent Terminal - Execute code, manage memory, search the web",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

131
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,131 @@
"use client";
import { FormEvent, Suspense, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Loader2, LockKeyhole } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function normalizeNextPath(value: string | null): string {
if (!value) return "/dashboard";
if (!value.startsWith("/") || value.startsWith("//")) return "/dashboard";
if (value.startsWith("/login")) return "/dashboard";
return value;
}
function LoginPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("admin");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const nextPath = useMemo(
() => normalizeNextPath(searchParams.get("next")),
[searchParams]
);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: username.trim(),
password: password.trim(),
}),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string; mustChangeCredentials?: boolean }
| null;
if (!response.ok) {
throw new Error(payload?.error || "Login failed");
}
if (payload?.mustChangeCredentials) {
router.replace("/dashboard/projects?onboarding=1&credentials=1");
router.refresh();
return;
}
router.replace(nextPath);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setSubmitting(false);
}
}
return (
<main className="min-h-screen bg-muted/20 px-4 py-8">
<div className="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-md items-center">
<section className="w-full rounded-xl border bg-card p-6 shadow-sm">
<div className="mb-6 flex items-center gap-2">
<LockKeyhole className="size-5 text-primary" />
<h1 className="text-xl font-semibold">Eggent Login</h1>
</div>
<p className="mb-6 text-sm text-muted-foreground">
Default credentials: <span className="font-mono">admin / admin</span>
</p>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="admin"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="••••••••"
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full gap-2" disabled={submitting}>
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Signing in...
</>
) : (
"Sign In"
)}
</Button>
</form>
</section>
</div>
</main>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<main className="p-4 text-sm text-muted-foreground">Loading...</main>}>
<LoginPageClient />
</Suspense>
);
}

5
src/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/dashboard");
}

View File

@@ -0,0 +1,358 @@
"use client";
import * as React from "react";
import { useEffect } from "react";
import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import {
CalendarClock,
Cable,
Bot,
Brain,
FolderOpen,
LifeBuoy,
LogOut,
MessageSquarePlus,
MessagesSquare,
Puzzle,
Settings2,
Trash2,
Wrench,
} from "lucide-react";
import { useAppStore } from "@/store/app-store";
import { FileTree } from "@/components/file-tree";
import { useBackgroundSync } from "@/hooks/use-background-sync";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const router = useRouter();
const pathname = usePathname();
const {
chats,
setChats,
activeChatId,
setActiveChatId,
removeChat,
projects,
setProjects,
activeProjectId,
setActiveProjectId,
} = useAppStore();
const projectsTick = useBackgroundSync({
topics: ["projects", "global"],
});
const chatsTick = useBackgroundSync({
topics: ["chat", "projects", "global"],
projectId: activeProjectId ?? null,
});
const isOnChatPage = pathname === "/dashboard";
// Navigate to chat page when not already there (e.g. from settings/projects/memory)
const goToChatIfNeeded = React.useCallback(() => {
if (!isOnChatPage) router.push("/dashboard");
}, [isOnChatPage, router]);
// Keep projects list in sync with background updates.
useEffect(() => {
fetch("/api/projects")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) setProjects(data);
})
.catch(() => {});
}, [setProjects, projectsTick]);
// Keep active project aligned with available projects.
useEffect(() => {
if (projects.length === 0) {
if (activeProjectId !== null) setActiveProjectId(null);
return;
}
const activeExists = activeProjectId
? projects.some((project) => project.id === activeProjectId)
: false;
if (!activeExists) {
setActiveProjectId(projects[0].id);
}
}, [projects, activeProjectId, setActiveProjectId]);
// Keep chat list synced for the active project.
useEffect(() => {
const params = new URLSearchParams();
if (activeProjectId) {
params.set("projectId", activeProjectId);
} else {
params.set("projectId", "none");
}
fetch(`/api/chat/history?${params}`)
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) setChats(data);
})
.catch(() => {});
}, [activeProjectId, setChats, chatsTick]);
const handleNewChat = () => {
setActiveChatId(null);
goToChatIfNeeded();
};
const handleChatClick = (chatId: string) => {
setActiveChatId(chatId);
goToChatIfNeeded();
};
const handleProjectClick = (projectId: string) => {
const params = new URLSearchParams({ projectId });
fetch(`/api/chat/history?${params}`)
.then((r) => r.json())
.then((data) => {
const list = Array.isArray(data) ? data : [];
setChats(list);
setActiveProjectId(projectId);
setActiveChatId(list[0]?.id ?? null);
goToChatIfNeeded();
})
.catch(() => {
setActiveProjectId(projectId);
setActiveChatId(null);
goToChatIfNeeded();
});
};
const handleDeleteChat = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
await fetch(`/api/chat/history?id=${id}`, { method: "DELETE" });
removeChat(id);
};
const handleLogout = async () => {
try {
await fetch("/api/auth/logout", { method: "POST" });
} catch {
// ignore logout request errors and continue redirect
} finally {
router.push("/login");
router.refresh();
}
};
return (
<Sidebar
className="top-(--header-height) h-[calc(100svh-var(--header-height))]!"
{...props}
>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link href="/dashboard">
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<Bot className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">Eggent</span>
<span className="truncate text-xs">Agent Terminal</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{/* New Chat button */}
<div className="px-3 pt-2">
<Button
variant="outline"
className="w-full justify-start gap-2"
onClick={handleNewChat}
>
<MessageSquarePlus className="size-4" />
New Chat
</Button>
</div>
</SidebarHeader>
<SidebarContent>
{/* Project selector */}
<SidebarGroup>
<SidebarGroupLabel>Project</SidebarGroupLabel>
<SidebarMenu>
{projects.length === 0 && (
<SidebarMenuItem>
<SidebarMenuButton disabled>
<span className="text-muted-foreground text-xs">
No projects yet
</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
{projects.map((project) => (
<SidebarMenuItem key={project.id}>
<SidebarMenuButton
isActive={activeProjectId === project.id}
onClick={() => handleProjectClick(project.id)}
>
<FolderOpen className="size-4" />
<span className="truncate">{project.name}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
{/* File tree */}
<SidebarGroup>
<SidebarGroupLabel>
<FolderOpen className="size-3.5 mr-1" />
Files
</SidebarGroupLabel>
<div className="px-2">
<FileTree projectId={activeProjectId ?? "none"} />
</div>
</SidebarGroup>
{/* Chat history */}
<SidebarGroup>
<SidebarGroupLabel>
<MessagesSquare className="size-3.5 mr-1" />
Chats
</SidebarGroupLabel>
<SidebarMenu>
{chats.length === 0 && (
<SidebarMenuItem>
<SidebarMenuButton disabled>
<span className="text-muted-foreground text-xs">
No chats yet
</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
{chats.map((chat) => (
<SidebarMenuItem key={chat.id}>
<SidebarMenuButton
isActive={activeChatId === chat.id}
onClick={() => handleChatClick(chat.id)}
>
<span className="truncate">{chat.title}</span>
</SidebarMenuButton>
<SidebarMenuAction
onClick={(e) => handleDeleteChat(chat.id, e)}
className="opacity-0 group-hover/menu-item:opacity-100"
>
<Trash2 className="size-3.5" />
</SidebarMenuAction>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="/dashboard/projects">
<FolderOpen className="size-4" />
<span>Projects</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="/dashboard/memory">
<Brain className="size-4" />
<span>Memory</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="/dashboard/skills">
<Puzzle className="size-4" />
<span>Skills</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="/dashboard/mcp">
<Wrench className="size-4" />
<span>MCP</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="/dashboard/cron">
<CalendarClock className="size-4" />
<span>Cron Jobs</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="/dashboard/settings">
<Settings2 className="size-4" />
<span>Settings</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="/dashboard/api">
<Cable className="size-4" />
<span>API</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="/dashboard/messengers">
<MessagesSquare className="size-4" />
<span>Messengers</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<a
href="https://github.com/eggent-ai/eggent"
target="_blank"
rel="noopener noreferrer"
>
<LifeBuoy className="size-4" />
<span>Documentation</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton onClick={handleLogout}>
<LogOut className="size-4" />
<span>Logout</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -0,0 +1,291 @@
"use client";
import { useRef, useCallback, useState, useEffect } from "react";
import { Send, Square, Paperclip, X, FileIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ChatFile } from "@/lib/types";
interface ChatInputProps {
input: string;
setInput: (input: string) => void;
onSubmit: () => void;
onStop?: () => void;
isLoading: boolean;
disabled?: boolean;
chatId?: string;
onFilesUploaded?: (files: ChatFile[]) => void;
}
export function ChatInput({
input,
setInput,
onSubmit,
onStop,
isLoading,
disabled,
chatId,
onFilesUploaded,
}: ChatInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [uploadingFiles, setUploadingFiles] = useState<string[]>([]);
const [uploadedFiles, setUploadedFiles] = useState<ChatFile[]>([]);
// Load chat files when chatId changes
useEffect(() => {
if (!chatId) {
setUploadedFiles([]);
return;
}
let cancelled = false;
fetch(`/api/chat/files?chatId=${encodeURIComponent(chatId)}`)
.then((res) => {
if (!res.ok) throw new Error("Failed to load files");
return res.json();
})
.then((data: { files?: ChatFile[] }) => {
if (cancelled) return;
setUploadedFiles(data.files || []);
})
.catch(() => {
if (!cancelled) {
setUploadedFiles([]);
}
});
return () => {
cancelled = true;
};
}, [chatId]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!isLoading && input.trim()) {
onSubmit();
}
}
},
[input, isLoading, onSubmit]
);
const handleInput = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
// Auto-resize
const textarea = e.target;
textarea.style.height = "auto";
textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px";
},
[setInput]
);
const uploadFile = useCallback(
async (file: File) => {
if (!chatId) return;
setUploadingFiles((prev) => [...prev, file.name]);
try {
const formData = new FormData();
formData.append("chatId", chatId);
formData.append("file", file);
const response = await fetch("/api/chat/files", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Upload failed");
}
const data = await response.json();
const uploadedFile = data.file as ChatFile;
setUploadedFiles((prev) => [...prev, uploadedFile]);
onFilesUploaded?.([uploadedFile]);
} catch (error) {
console.error("Failed to upload file:", error);
} finally {
setUploadingFiles((prev) => prev.filter((name) => name !== file.name));
}
},
[chatId, onFilesUploaded]
);
const handleFileSelect = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
for (const file of Array.from(files)) {
await uploadFile(file);
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
},
[uploadFile]
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = e.dataTransfer.files;
if (!files || files.length === 0) return;
for (const file of Array.from(files)) {
await uploadFile(file);
}
},
[uploadFile]
);
const removeUploadedFile = useCallback(
async (filename: string) => {
if (!chatId) return;
try {
await fetch(
`/api/chat/files?chatId=${encodeURIComponent(chatId)}&filename=${encodeURIComponent(filename)}`,
{ method: "DELETE" }
);
setUploadedFiles((prev) => prev.filter((f) => f.name !== filename));
} catch (error) {
console.error("Failed to delete file:", error);
}
},
[chatId]
);
return (
<div
className={`border-t bg-background p-4 transition-colors ${isDragging ? "bg-primary/5 border-primary" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="mx-auto max-w-3xl">
{/* Uploaded files preview */}
{(uploadedFiles.length > 0 || uploadingFiles.length > 0) && (
<div className="mb-2 flex flex-wrap gap-2">
{uploadedFiles.map((file) => (
<div
key={file.name}
className="flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs"
>
<FileIcon className="size-3" />
<span className="max-w-[100px] truncate">{file.name}</span>
<button
type="button"
onClick={() => removeUploadedFile(file.name)}
className="hover:text-destructive"
>
<X className="size-3" />
</button>
</div>
))}
{uploadingFiles.map((name) => (
<div
key={name}
className="flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs opacity-50"
>
<FileIcon className="size-3 animate-pulse" />
<span className="max-w-[100px] truncate">{name}</span>
</div>
))}
</div>
)}
{/* Drag drop overlay hint */}
{isDragging && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-primary bg-primary/10">
<p className="text-primary font-medium">Drop files here</p>
</div>
)}
<div className="relative">
{/* File upload button */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
/>
<div className="flex items-center gap-2 rounded-2xl border border-border/80 bg-background px-2 py-1.5 shadow-sm transition-colors focus-within:border-primary/40 focus-within:ring-4 focus-within:ring-primary/10">
<Button
variant="ghost"
size="icon"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || !chatId}
className="h-10 w-10 shrink-0 rounded-xl text-muted-foreground hover:text-foreground"
title={chatId ? "Attach files" : "Send a message first to attach files"}
>
<Paperclip className="size-4" />
</Button>
<div className="relative flex-1">
<textarea
ref={textareaRef}
value={input}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={isDragging ? "Drop files here..." : "Send a message..."}
disabled={disabled}
rows={1}
className="min-h-[30px] max-h-[200px] w-full translate-y-px resize-none border-0 bg-transparent px-1 pt-2.5 pb-1.5 text-sm leading-5 placeholder:text-muted-foreground focus:outline-none disabled:opacity-50"
/>
</div>
{isLoading ? (
<Button
variant="destructive"
size="icon"
onClick={onStop}
className="h-10 w-10 shrink-0 rounded-xl"
>
<Square className="size-4" />
</Button>
) : (
<Button
size="icon"
onClick={onSubmit}
disabled={!input.trim() || disabled}
className="h-10 w-10 shrink-0 rounded-xl"
>
<Send className="size-4" />
</Button>
)}
</div>
</div>
<p className="mt-2 text-center text-xs text-muted-foreground">
AI agent with code execution, memory, and web search capabilities
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { useEffect, useRef } from "react";
import { MessageBubble } from "./message-bubble";
import { Loader2 } from "lucide-react";
import type { UIMessage } from "ai";
interface ChatMessagesProps {
messages: UIMessage[];
isLoading: boolean;
}
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
const endRef = useRef<HTMLDivElement>(null);
// Auto-scroll on new messages
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isLoading]);
if (messages.length === 0 && !isLoading) {
return (
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center space-y-3 max-w-md">
<div className="flex justify-center">
<div className="size-16 rounded-2xl bg-primary/10 flex items-center justify-center">
<svg
className="size-8 text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"
/>
</svg>
</div>
</div>
<h3 className="text-lg font-semibold">Start a conversation</h3>
<p className="text-sm text-muted-foreground">
Send a message to begin chatting with the AI agent. It can execute
code, search the web, manage memory, and more.
</p>
</div>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto px-4 md:px-6">
<div className="max-w-3xl mx-auto py-4 space-y-1">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{isLoading && messages.length > 0 && (
<div className="flex gap-3 py-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Loader2 className="size-4 animate-spin" />
</div>
<div className="flex items-center">
<span className="text-sm text-muted-foreground">
Thinking...
</span>
</div>
</div>
)}
<div ref={endRef} />
</div>
</div>
);
}

View File

@@ -0,0 +1,487 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import type { UIMessage } from "ai";
import { ChatMessages } from "./chat-messages";
import { ChatInput } from "./chat-input";
import { useAppStore } from "@/store/app-store";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import type { ChatMessage } from "@/lib/types";
import { useBackgroundSync } from "@/hooks/use-background-sync";
import { generateClientId } from "@/lib/utils";
/** Convert stored ChatMessage to UIMessage (parts format for useChat) */
function chatMessagesToUIMessages(chatMessages: ChatMessage[]): UIMessage[] {
const result: UIMessage[] = [];
// Build a map of toolCallId -> tool result for pairing
const toolResultMap = new Map<string, ChatMessage>();
for (const m of chatMessages) {
if (m.role === "tool" && m.toolCallId) {
toolResultMap.set(m.toolCallId, m);
}
}
for (const m of chatMessages) {
if (m.role === "user") {
result.push({
id: m.id,
role: "user",
parts: [{ type: "text" as const, text: m.content }],
});
} else if (m.role === "assistant") {
const parts: UIMessage["parts"] = [];
// Add tool call parts with their results
if (m.toolCalls && m.toolCalls.length > 0) {
for (const tc of m.toolCalls) {
const toolResult = toolResultMap.get(tc.toolCallId);
parts.push({
type: `tool-${tc.toolName}` as `tool-${string}`,
toolCallId: tc.toolCallId,
toolName: tc.toolName,
state: "output-available" as const,
input: tc.args,
output: toolResult?.toolResult ?? toolResult?.content ?? "",
} as unknown as UIMessage["parts"][number]);
}
}
// Add text content
if (m.content) {
parts.push({ type: "text" as const, text: m.content });
}
// Only add message if it has content
if (parts.length > 0) {
result.push({
id: m.id,
role: "assistant",
parts,
});
}
}
// Skip "tool" role messages - they are paired with assistant toolCalls above
}
return result;
}
interface SwitchProjectResult {
success?: boolean;
action?: string;
projectId?: string;
currentPath?: string;
}
interface CreateProjectResult {
success?: boolean;
action?: string;
projectId?: string;
}
function tryParseSwitchProjectResult(output: unknown): SwitchProjectResult | null {
if (output == null) return null;
let parsed: unknown = output;
if (typeof output === "string") {
const trimmed = output.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
try {
parsed = JSON.parse(trimmed);
} catch {
return null;
}
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return null;
}
const record = parsed as Record<string, unknown>;
if (record.action !== "switch_project" || record.success !== true) {
return null;
}
const projectId = typeof record.projectId === "string" ? record.projectId : undefined;
if (!projectId?.trim()) {
return null;
}
return {
success: true,
action: "switch_project",
projectId,
currentPath:
typeof record.currentPath === "string" ? record.currentPath : undefined,
};
}
function tryParseCreateProjectResult(output: unknown): CreateProjectResult | null {
if (output == null) return null;
let parsed: unknown = output;
if (typeof output === "string") {
const trimmed = output.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
try {
parsed = JSON.parse(trimmed);
} catch {
return null;
}
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return null;
}
const record = parsed as Record<string, unknown>;
if (record.action !== "create_project" || record.success !== true) {
return null;
}
const projectId = typeof record.projectId === "string" ? record.projectId : undefined;
if (!projectId?.trim()) {
return null;
}
return {
success: true,
action: "create_project",
projectId,
};
}
function extractToolPartInfo(
part: UIMessage["parts"][number],
toolName: string
): { key: string; output: unknown } | null {
if (part.type === "dynamic-tool") {
const dynamicPart = part as {
type: "dynamic-tool";
toolName: string;
toolCallId: string;
state: string;
output?: unknown;
};
if (
dynamicPart.toolName !== toolName ||
dynamicPart.state !== "output-available"
) {
return null;
}
return {
key: dynamicPart.toolCallId ? `${toolName}:${dynamicPart.toolCallId}` : "",
output: dynamicPart.output,
};
}
if (part.type === `tool-${toolName}`) {
const toolPart = part as {
type: string;
toolCallId: string;
state: string;
output?: unknown;
};
if (toolPart.state !== "output-available") {
return null;
}
return {
key: toolPart.toolCallId ? `${toolName}:${toolPart.toolCallId}` : "",
output: toolPart.output,
};
}
return null;
}
function areUIMessagesEquivalentById(
left: UIMessage[],
right: UIMessage[]
): boolean {
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
if (left[i].id !== right[i].id) return false;
if (left[i].role !== right[i].role) return false;
}
return true;
}
export function ChatPanel() {
const {
activeChatId,
setActiveChatId,
activeProjectId,
currentPath,
setCurrentPath,
setActiveProjectId,
setProjects,
addChat,
} = useAppStore();
const [input, setInput] = useState("");
// Internal chatId that stays stable during a message send.
// Pre-generate a UUID so useChat always has a consistent id.
const [internalChatId, setInternalChatId] = useState(
() => activeChatId || generateClientId()
);
const syncTick = useBackgroundSync({
topics: ["chat", "global"],
projectId: activeProjectId ?? null,
chatId: activeChatId ?? undefined,
});
const internalChatIdRef = useRef(internalChatId);
internalChatIdRef.current = internalChatId;
const activeProjectIdRef = useRef(activeProjectId);
activeProjectIdRef.current = activeProjectId;
const currentPathRef = useRef(currentPath);
currentPathRef.current = currentPath;
// Track the last activeChatId we've seen to detect external navigation
const prevActiveChatId = useRef(activeChatId);
// Sync internalChatId when user navigates to a different chat via sidebar
useEffect(() => {
if (activeChatId !== prevActiveChatId.current) {
prevActiveChatId.current = activeChatId;
if (activeChatId !== null) {
setInternalChatId(activeChatId);
} else {
// "New chat" clicked — generate fresh id
setInternalChatId(generateClientId());
}
}
}, [activeChatId]);
// Stable transport — body is a function so it always reads current refs
const transport = useMemo(
() =>
new DefaultChatTransport({
api: "/api/chat",
body: () => ({
chatId: internalChatIdRef.current,
projectId: activeProjectIdRef.current,
currentPath: currentPathRef.current,
}),
}),
[]
);
const { messages, sendMessage, status, stop, setMessages } = useChat({
id: internalChatId,
transport,
onError: (error) => {
console.error("Chat error:", error);
},
});
// Don't overwrite messages while a request is in flight (avoids "blink" on new chat)
const statusRef = useRef(status);
statusRef.current = status;
const messagesRef = useRef(messages);
messagesRef.current = messages;
const pendingProjectSwitchRef = useRef(false);
const submissionStartCountRef = useRef<number | null>(null);
const handledSwitchToolCallsRef = useRef<Set<string>>(new Set());
const queuedSwitchResultRef = useRef<SwitchProjectResult | null>(null);
const shouldRefreshProjectsRef = useRef(false);
const switchInFlightRef = useRef(false);
// Reset local messages when switching to "new chat" mode.
useEffect(() => {
if (activeChatId === null) {
setMessages([]);
}
}, [activeChatId, setMessages]);
// Keep active chat history synced with background updates.
useEffect(() => {
if (activeChatId === null) return;
if (status === "submitted" || status === "streaming") return;
let cancelled = false;
fetch(`/api/chat/history?id=${encodeURIComponent(activeChatId)}`)
.then((r) => {
if (r.status === 404) {
return null;
}
if (!r.ok) throw new Error("Failed to load chat");
return r.json() as Promise<{ messages?: ChatMessage[] }>;
})
.then((chat) => {
if (cancelled) return;
// Don't overwrite while user is sending or stream is in progress
if (statusRef.current === "submitted" || statusRef.current === "streaming") {
return;
}
if (!chat?.messages) {
setMessages([]);
return;
}
const nextMessages = chatMessagesToUIMessages(chat.messages);
if (areUIMessagesEquivalentById(messagesRef.current, nextMessages)) {
return;
}
setMessages(nextMessages);
})
.catch(() => {
// Keep last known messages on transient polling/network errors.
});
return () => {
cancelled = true;
};
}, [activeChatId, setMessages, status, syncTick]);
const refreshProjects = useCallback(async () => {
try {
const response = await fetch("/api/projects");
const data = await response.json();
if (Array.isArray(data)) {
setProjects(data);
}
} catch {
// ignore project list refresh failures
}
}, [setProjects]);
const applySwitchResult = useCallback(
(result: SwitchProjectResult) => {
if (switchInFlightRef.current) return;
const nextProjectId = result.projectId?.trim();
if (!nextProjectId) return;
switchInFlightRef.current = true;
try {
if (activeProjectIdRef.current === nextProjectId) {
setCurrentPath(result.currentPath ?? "");
return;
}
setActiveProjectId(nextProjectId);
setCurrentPath(result.currentPath ?? "");
} finally {
switchInFlightRef.current = false;
}
},
[setActiveProjectId, setCurrentPath]
);
useEffect(() => {
if (!pendingProjectSwitchRef.current) return;
if (status === "submitted") return;
const startIndex = submissionStartCountRef.current ?? messages.length;
const recentMessages = messages.slice(startIndex);
const latestAssistant = [...recentMessages]
.reverse()
.find((m) => m.role === "assistant");
if (latestAssistant) {
for (let idx = 0; idx < latestAssistant.parts.length; idx++) {
const part = latestAssistant.parts[idx];
const switchInfo = extractToolPartInfo(part, "switch_project");
if (switchInfo) {
const key = switchInfo.key || `${latestAssistant.id}-${idx}-switch`;
if (!handledSwitchToolCallsRef.current.has(key)) {
handledSwitchToolCallsRef.current.add(key);
const parsedSwitch = tryParseSwitchProjectResult(switchInfo.output);
if (parsedSwitch) {
queuedSwitchResultRef.current = parsedSwitch;
shouldRefreshProjectsRef.current = true;
}
}
}
const createInfo = extractToolPartInfo(part, "create_project");
if (createInfo) {
const key = createInfo.key || `${latestAssistant.id}-${idx}-create`;
if (!handledSwitchToolCallsRef.current.has(key)) {
handledSwitchToolCallsRef.current.add(key);
const parsedCreate = tryParseCreateProjectResult(createInfo.output);
if (parsedCreate) {
shouldRefreshProjectsRef.current = true;
}
}
}
}
}
if (status === "ready" || status === "error") {
const queued = queuedSwitchResultRef.current;
const shouldRefresh = shouldRefreshProjectsRef.current || Boolean(queued);
pendingProjectSwitchRef.current = false;
submissionStartCountRef.current = null;
handledSwitchToolCallsRef.current.clear();
queuedSwitchResultRef.current = null;
shouldRefreshProjectsRef.current = false;
void (async () => {
if (shouldRefresh) {
await refreshProjects();
}
if (queued) {
applySwitchResult(queued);
}
})();
}
}, [messages, status, applySwitchResult, refreshProjects]);
const isLoading = status === "submitted" || status === "streaming";
const onSubmit = useCallback(() => {
if (!input.trim() || isLoading) return;
pendingProjectSwitchRef.current = true;
submissionStartCountRef.current = messagesRef.current.length;
handledSwitchToolCallsRef.current.clear();
queuedSwitchResultRef.current = null;
shouldRefreshProjectsRef.current = false;
// If no active chat, register in the store.
// Update prevActiveChatId ref BEFORE setActiveChatId so the
// useEffect above won't treat this as external navigation.
if (!activeChatId) {
prevActiveChatId.current = internalChatId;
setActiveChatId(internalChatId);
addChat({
id: internalChatId,
title: input.slice(0, 60) + (input.length > 60 ? "..." : ""),
projectId: activeProjectId || undefined,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
messageCount: 1,
});
}
sendMessage({ text: input });
setInput("");
}, [
input,
isLoading,
activeChatId,
internalChatId,
setActiveChatId,
addChat,
activeProjectId,
sendMessage,
]);
return (
<div className="flex flex-col h-full">
<ChatMessages messages={messages} isLoading={isLoading} />
<ChatInput
input={input}
setInput={setInput}
onSubmit={onSubmit}
onStop={stop}
isLoading={isLoading}
chatId={activeChatId || internalChatId}
/>
</div>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { copyTextToClipboard } from "@/lib/utils";
interface CodeBlockProps {
code: string;
language?: string;
}
export function CodeBlock({ code, language }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
const copiedOk = await copyTextToClipboard(code);
if (!copiedOk) return;
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative group rounded-lg border bg-muted/50 overflow-hidden my-2">
<div className="flex items-center justify-between px-3 py-1.5 border-b bg-muted/80">
<span className="text-xs text-muted-foreground font-mono">
{language || "code"}
</span>
<Button
variant="ghost"
size="xs"
onClick={handleCopy}
className="h-6 gap-1 text-xs"
>
{copied ? (
<>
<Check className="size-3" />
Copied
</>
) : (
<>
<Copy className="size-3" />
Copy
</>
)}
</Button>
</div>
<pre className="p-3 overflow-x-auto text-sm">
<code className={language ? `language-${language}` : ""}>
{code}
</code>
</pre>
</div>
);
}

View File

@@ -0,0 +1,178 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Bot, User } from "lucide-react";
import { CodeBlock } from "./code-block";
import { ToolOutput } from "./tool-output";
import type { UIMessage } from "ai";
interface MessageBubbleProps {
message: UIMessage;
}
export function MessageBubble({ message }: MessageBubbleProps) {
const isUser = message.role === "user";
// Extract text content from parts
const textContent = message.parts
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("");
// Extract tool parts
const toolParts = message.parts.filter(
(p) => p.type.startsWith("tool-") || p.type === "dynamic-tool"
);
return (
<div className="space-y-1">
{/* Tool invocations */}
{toolParts.map((part, idx) => {
if (part.type === "dynamic-tool") {
const dp = part as {
type: "dynamic-tool";
toolName: string;
toolCallId: string;
state: string;
input?: unknown;
output?: unknown;
};
return (
<ToolOutput
key={`tool-${dp.toolCallId}-${idx}`}
toolName={dp.toolName}
args={
typeof dp.input === "object" && dp.input !== null
? (dp.input as Record<string, unknown>)
: {}
}
result={
dp.state === "output-available"
? typeof dp.output === "string"
? dp.output
: JSON.stringify(dp.output)
: dp.state === "output-error"
? "Error occurred"
: "Running..."
}
/>
);
}
// Handle typed tool parts (tool-{name})
if (part.type.startsWith("tool-")) {
const tp = part as {
type: string;
toolCallId?: string;
state?: string;
input?: unknown;
output?: unknown;
};
const toolName = part.type.replace("tool-", "");
return (
<ToolOutput
key={`tool-${tp.toolCallId || idx}-${idx}`}
toolName={toolName}
args={
typeof tp.input === "object" && tp.input !== null
? (tp.input as Record<string, unknown>)
: {}
}
result={
tp.state === "output-available"
? typeof tp.output === "string"
? tp.output
: JSON.stringify(tp.output)
: tp.state === "output-error"
? "Error occurred"
: "Running..."
}
/>
);
}
return null;
})}
{/* Text content: same font size for user and AI, first line aligned with icon center */}
{textContent && (
<div className="flex gap-3 py-2 items-start">
<div
className={`flex size-8 shrink-0 items-center justify-center rounded-full ${
isUser
? "bg-secondary text-secondary-foreground"
: "bg-foreground text-background"
}`}
>
{isUser ? (
<User className="size-4" />
) : (
<Bot className="size-4" />
)}
</div>
<div className="flex-1 min-w-0 text-sm leading-7 pt-0.5">
{isUser ? (
<p className="whitespace-pre-wrap">{textContent}</p>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none text-inherit [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<MarkdownContent content={textContent} />
</div>
)}
</div>
</div>
)}
</div>
);
}
function MarkdownContent({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match;
if (isInline) {
return (
<code
className="bg-muted px-1.5 py-0.5 rounded text-sm"
{...props}
>
{children}
</code>
);
}
return (
<CodeBlock
code={String(children).replace(/\n$/, "")}
language={match[1]}
/>
);
},
ul({ children, ...props }) {
return (
<ul className="my-2 list-disc pl-6 space-y-1" {...props}>
{children}
</ul>
);
},
ol({ children, ...props }) {
return (
<ol className="my-2 list-decimal pl-6 space-y-1" {...props}>
{children}
</ol>
);
},
li({ children, ...props }) {
return (
<li className="marker:text-muted-foreground" {...props}>
{children}
</li>
);
},
}}
>
{content}
</ReactMarkdown>
);
}

View File

@@ -0,0 +1,135 @@
"use client";
import { useState } from "react";
import {
ChevronDown,
ChevronRight,
Terminal,
Brain,
Search,
FileText,
Bot,
Puzzle,
CalendarClock,
FolderOpen,
} from "lucide-react";
import { CodeBlock } from "./code-block";
interface ToolOutputProps {
toolName: string;
args: Record<string, unknown>;
result: string;
}
const TOOL_ICONS: Record<string, React.ElementType> = {
code_execution: Terminal,
memory_save: Brain,
memory_load: Brain,
memory_delete: Brain,
search_web: Search,
knowledge_query: FileText,
call_subordinate: Bot,
load_skill: Puzzle,
load_skill_resource: Puzzle,
create_skill: Puzzle,
update_skill: Puzzle,
delete_skill: Puzzle,
write_skill_file: Puzzle,
upsert_mcp_server: Puzzle,
delete_mcp_server: Puzzle,
cron: CalendarClock,
list_projects: FolderOpen,
get_current_project: FolderOpen,
switch_project: FolderOpen,
create_project: FolderOpen,
};
const TOOL_LABELS: Record<string, string> = {
code_execution: "Code Execution",
memory_save: "Memory Save",
memory_load: "Memory Load",
memory_delete: "Memory Delete",
search_web: "Web Search",
knowledge_query: "Knowledge Query",
call_subordinate: "Subordinate Agent",
load_skill: "Load Skill",
load_skill_resource: "Load Skill Resource",
create_skill: "Create Skill",
update_skill: "Update Skill",
delete_skill: "Delete Skill",
write_skill_file: "Write Skill File",
upsert_mcp_server: "Upsert MCP Server",
delete_mcp_server: "Delete MCP Server",
cron: "Cron",
list_projects: "List Projects",
get_current_project: "Current Project",
switch_project: "Switch Project",
create_project: "Create Project",
response: "Response",
};
export function ToolOutput({ toolName, args, result }: ToolOutputProps) {
const [expanded, setExpanded] = useState(false);
const Icon = TOOL_ICONS[toolName] || Terminal;
const label = TOOL_LABELS[toolName] || toolName;
// Don't render the response tool visually
if (toolName === "response") return null;
return (
<div className="border rounded-lg my-2 overflow-hidden bg-card">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left hover:bg-muted/50 transition-colors"
>
{expanded ? (
<ChevronDown className="size-4 shrink-0" />
) : (
<ChevronRight className="size-4 shrink-0" />
)}
<Icon className="size-4 shrink-0 text-primary" />
<span className="font-medium">{label}</span>
{toolName === "code_execution" && args.runtime ? (
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{String(args.runtime)}
</span>
) : null}
{toolName === "search_web" && args.query ? (
<span className="text-xs text-muted-foreground truncate">
&quot;{String(args.query)}&quot;
</span>
) : null}
</button>
{expanded && (
<div className="border-t px-3 py-2 space-y-2">
{/* Tool arguments */}
{toolName === "code_execution" && args.code ? (
<CodeBlock
code={String(args.code)}
language={
args.runtime === "python"
? "python"
: args.runtime === "nodejs"
? "javascript"
: "bash"
}
/>
) : null}
{/* Tool result */}
{result ? (
<div className="text-sm">
<p className="text-xs text-muted-foreground mb-1 font-medium">
Output:
</p>
<pre className="text-xs bg-muted/50 rounded p-2 overflow-x-auto whitespace-pre-wrap max-h-64 overflow-y-auto">
{result}
</pre>
</div>
) : null}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,712 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
CalendarClock,
Clock3,
History,
Loader2,
Play,
RefreshCw,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type CronSchedule =
| { kind: "at"; at: string }
| { kind: "every"; everyMs: number; anchorMs?: number }
| { kind: "cron"; expr: string; tz?: string };
type CronRunStatus = "ok" | "error" | "skipped";
type CronJob = {
id: string;
name: string;
description?: string;
enabled: boolean;
deleteAfterRun?: boolean;
schedule: CronSchedule;
payload: {
kind: "agentTurn";
message: string;
telegramChatId?: string;
timeoutSeconds?: number;
};
state: {
nextRunAtMs?: number;
runningAtMs?: number;
lastRunAtMs?: number;
lastStatus?: CronRunStatus;
lastError?: string;
lastDurationMs?: number;
};
};
type CronStatus = {
projectId: string;
jobs: number;
nextWakeAtMs: number | null;
};
type CronRunLogEntry = {
ts: number;
status: CronRunStatus;
error?: string;
summary?: string;
runAtMs?: number;
durationMs?: number;
nextRunAtMs?: number;
};
type CronFormState = {
name: string;
description: string;
enabled: boolean;
deleteAfterRun: boolean;
scheduleKind: "every" | "at" | "cron";
scheduleAt: string;
everyAmount: string;
everyUnit: "minutes" | "hours" | "days";
cronExpr: string;
cronTz: string;
message: string;
telegramChatId: string;
timeoutSeconds: string;
};
const DEFAULT_FORM: CronFormState = {
name: "",
description: "",
enabled: true,
deleteAfterRun: true,
scheduleKind: "every",
scheduleAt: "",
everyAmount: "30",
everyUnit: "minutes",
cronExpr: "0 9 * * 1-5",
cronTz: "",
message: "",
telegramChatId: "",
timeoutSeconds: "",
};
function formatDateTime(ms?: number | null): string {
if (typeof ms !== "number" || !Number.isFinite(ms)) {
return "n/a";
}
return new Date(ms).toLocaleString();
}
function formatDuration(ms?: number): string {
if (typeof ms !== "number" || !Number.isFinite(ms)) {
return "n/a";
}
if (ms < 1_000) {
return `${ms}ms`;
}
return `${(ms / 1_000).toFixed(1)}s`;
}
function scheduleSummary(schedule: CronSchedule): string {
if (schedule.kind === "at") {
return `At ${schedule.at}`;
}
if (schedule.kind === "every") {
return `Every ${schedule.everyMs}ms`;
}
return schedule.tz ? `Cron ${schedule.expr} (${schedule.tz})` : `Cron ${schedule.expr}`;
}
async function readErrorMessage(res: Response): Promise<string> {
try {
const body = (await res.json()) as { error?: unknown };
if (typeof body.error === "string" && body.error.trim()) {
return body.error;
}
} catch {
// no-op
}
return `Request failed (${res.status})`;
}
interface CronSectionProps {
projectId: string;
}
export function CronSection({ projectId }: CronSectionProps) {
const [form, setForm] = useState<CronFormState>(DEFAULT_FORM);
const [status, setStatus] = useState<CronStatus | null>(null);
const [jobs, setJobs] = useState<CronJob[]>([]);
const [runs, setRuns] = useState<CronRunLogEntry[]>([]);
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [runsLoading, setRunsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedJob = useMemo(
() => (selectedJobId ? jobs.find((job) => job.id === selectedJobId) ?? null : null),
[jobs, selectedJobId]
);
const loadStatusAndJobs = useCallback(async () => {
setError(null);
setLoading(true);
try {
const [statusRes, jobsRes] = await Promise.all([
fetch(`/api/projects/${projectId}/cron/status`),
fetch(`/api/projects/${projectId}/cron?includeDisabled=true`),
]);
if (!statusRes.ok) {
throw new Error(await readErrorMessage(statusRes));
}
if (!jobsRes.ok) {
throw new Error(await readErrorMessage(jobsRes));
}
const statusData = (await statusRes.json()) as CronStatus;
const jobsData = (await jobsRes.json()) as { jobs?: CronJob[] };
const nextJobs = Array.isArray(jobsData.jobs) ? jobsData.jobs : [];
setStatus(statusData);
setJobs(nextJobs);
// Keep selectedJobId/runs even when a one-shot job disappears from active jobs
// after successful run (deleteAfterRun=true). Run history is persisted separately.
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [projectId]);
async function loadRuns(jobId: string) {
setError(null);
setRunsLoading(true);
try {
const res = await fetch(`/api/projects/${projectId}/cron/${jobId}/runs?limit=100`);
if (!res.ok) {
throw new Error(await readErrorMessage(res));
}
const data = (await res.json()) as { entries?: CronRunLogEntry[] };
const entries = Array.isArray(data.entries) ? data.entries : [];
setSelectedJobId(jobId);
setRuns([...entries].sort((a, b) => b.ts - a.ts));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setRunsLoading(false);
}
}
useEffect(() => {
void loadStatusAndJobs();
}, [loadStatusAndJobs]);
async function createJob() {
setError(null);
setBusy(true);
try {
const name = form.name.trim();
if (!name) {
throw new Error("Job name is required.");
}
const message = form.message.trim();
if (!message) {
throw new Error("Agent message is required.");
}
let schedule: CronSchedule;
if (form.scheduleKind === "at") {
const atMs = Date.parse(form.scheduleAt);
if (!Number.isFinite(atMs)) {
throw new Error("Invalid date/time for 'At' schedule.");
}
schedule = { kind: "at", at: new Date(atMs).toISOString() };
} else if (form.scheduleKind === "every") {
const amount = Number(form.everyAmount);
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error("Interval amount must be a positive number.");
}
const multiplier =
form.everyUnit === "minutes"
? 60_000
: form.everyUnit === "hours"
? 3_600_000
: 86_400_000;
schedule = { kind: "every", everyMs: Math.floor(amount * multiplier) };
} else {
const expr = form.cronExpr.trim();
if (!expr) {
throw new Error("Cron expression is required.");
}
schedule = {
kind: "cron",
expr,
tz: form.cronTz.trim() || undefined,
};
}
const timeoutValue = Number(form.timeoutSeconds);
const timeoutSeconds =
Number.isFinite(timeoutValue) && timeoutValue > 0 ? Math.floor(timeoutValue) : undefined;
const res = await fetch(`/api/projects/${projectId}/cron`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
description: form.description.trim() || undefined,
enabled: form.enabled,
deleteAfterRun: form.deleteAfterRun,
schedule,
payload: {
kind: "agentTurn",
message,
telegramChatId: form.telegramChatId.trim() || undefined,
timeoutSeconds,
},
}),
});
if (!res.ok) {
throw new Error(await readErrorMessage(res));
}
setForm((prev) => ({
...prev,
name: "",
description: "",
message: "",
telegramChatId: "",
timeoutSeconds: "",
}));
await loadStatusAndJobs();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function toggleJob(job: CronJob, enabled: boolean) {
setError(null);
setBusy(true);
try {
const res = await fetch(`/api/projects/${projectId}/cron/${job.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (!res.ok) {
throw new Error(await readErrorMessage(res));
}
await loadStatusAndJobs();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function runNow(job: CronJob) {
setError(null);
setBusy(true);
try {
const res = await fetch(`/api/projects/${projectId}/cron/${job.id}/run`, {
method: "POST",
});
if (!res.ok) {
throw new Error(await readErrorMessage(res));
}
await Promise.all([loadStatusAndJobs(), loadRuns(job.id)]);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function removeJob(job: CronJob) {
const confirmed = confirm(`Delete cron job "${job.name}"?`);
if (!confirmed) {
return;
}
setError(null);
setBusy(true);
try {
const res = await fetch(`/api/projects/${projectId}/cron/${job.id}`, {
method: "DELETE",
});
if (!res.ok) {
throw new Error(await readErrorMessage(res));
}
if (selectedJobId === job.id) {
setSelectedJobId(null);
setRuns([]);
}
await loadStatusAndJobs();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium flex items-center gap-2">
<CalendarClock className="size-5 text-primary" />
Cron Jobs
</h3>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() => void loadStatusAndJobs()}
disabled={loading || busy}
>
{loading ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
Refresh
</Button>
</div>
{error && (
<div className="text-sm border border-destructive/30 bg-destructive/10 text-destructive rounded-md px-3 py-2">
{error}
</div>
)}
<div className="grid gap-4 lg:grid-cols-3">
<div className="border rounded-lg bg-card p-4 space-y-3">
<p className="text-sm font-medium">Scheduler Status</p>
<div className="text-sm text-muted-foreground">
<p>Project: {status?.projectId ?? projectId}</p>
<p>Jobs: {status?.jobs ?? (loading ? "…" : 0)}</p>
<p>Next wake: {formatDateTime(status?.nextWakeAtMs ?? null)}</p>
</div>
</div>
<div className="border rounded-lg bg-card p-4 space-y-3 lg:col-span-2">
<p className="text-sm font-medium">Create Job</p>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="cron-name">Name</Label>
<Input
id="cron-name"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="Daily summary"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cron-description">Description</Label>
<Input
id="cron-description"
value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
placeholder="Optional"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cron-schedule-kind">Schedule</Label>
<select
id="cron-schedule-kind"
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
value={form.scheduleKind}
onChange={(e) =>
setForm((prev) => ({
...prev,
scheduleKind: e.target.value as CronFormState["scheduleKind"],
}))
}
>
<option value="every">Every</option>
<option value="at">At</option>
<option value="cron">Cron</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="cron-timeout">Timeout (seconds)</Label>
<Input
id="cron-timeout"
value={form.timeoutSeconds}
onChange={(e) => setForm((prev) => ({ ...prev, timeoutSeconds: e.target.value }))}
placeholder="Optional"
/>
</div>
</div>
{form.scheduleKind === "at" && (
<div className="space-y-2">
<Label htmlFor="cron-at">Run At</Label>
<Input
id="cron-at"
type="datetime-local"
value={form.scheduleAt}
onChange={(e) => setForm((prev) => ({ ...prev, scheduleAt: e.target.value }))}
/>
</div>
)}
{form.scheduleKind === "every" && (
<div className="grid gap-3 md:grid-cols-[1fr_180px]">
<div className="space-y-2">
<Label htmlFor="cron-every-amount">Every</Label>
<Input
id="cron-every-amount"
value={form.everyAmount}
onChange={(e) => setForm((prev) => ({ ...prev, everyAmount: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cron-every-unit">Unit</Label>
<select
id="cron-every-unit"
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
value={form.everyUnit}
onChange={(e) =>
setForm((prev) => ({
...prev,
everyUnit: e.target.value as CronFormState["everyUnit"],
}))
}
>
<option value="minutes">Minutes</option>
<option value="hours">Hours</option>
<option value="days">Days</option>
</select>
</div>
</div>
)}
{form.scheduleKind === "cron" && (
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="cron-expr">Cron Expression</Label>
<Input
id="cron-expr"
value={form.cronExpr}
onChange={(e) => setForm((prev) => ({ ...prev, cronExpr: e.target.value }))}
placeholder="0 9 * * 1-5"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cron-tz">Time Zone</Label>
<Input
id="cron-tz"
value={form.cronTz}
onChange={(e) => setForm((prev) => ({ ...prev, cronTz: e.target.value }))}
placeholder="Optional, e.g. UTC"
/>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="cron-message">Agent Message</Label>
<textarea
id="cron-message"
value={form.message}
onChange={(e) => setForm((prev) => ({ ...prev, message: e.target.value }))}
placeholder="What should the agent do on each run?"
className="w-full rounded-md border bg-background px-3 py-2 text-sm min-h-[88px] resize-y"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cron-telegram-chat-id">Telegram Chat ID (optional)</Label>
<Input
id="cron-telegram-chat-id"
value={form.telegramChatId}
onChange={(e) => setForm((prev) => ({ ...prev, telegramChatId: e.target.value }))}
placeholder="e.g. 123456789"
/>
</div>
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm((prev) => ({ ...prev, enabled: e.target.checked }))}
/>
Enabled
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.deleteAfterRun}
onChange={(e) =>
setForm((prev) => ({ ...prev, deleteAfterRun: e.target.checked }))
}
/>
Delete after one-shot run
</label>
</div>
<Button onClick={() => void createJob()} disabled={busy} className="gap-2">
{busy ? <Loader2 className="size-4 animate-spin" /> : <CalendarClock className="size-4" />}
Create Job
</Button>
</div>
</div>
<div className="border rounded-lg bg-card">
<div className="px-4 py-3 border-b">
<p className="text-sm font-medium">Jobs</p>
</div>
{loading ? (
<div className="p-8 text-muted-foreground text-sm flex items-center justify-center gap-2">
<Loader2 className="size-4 animate-spin" />
Loading jobs...
</div>
) : jobs.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">
No cron jobs yet.
</div>
) : (
<div className="divide-y">
{jobs.map((job) => (
<div key={job.id} className="p-4 space-y-3">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<p className="font-medium text-sm">{job.name}</p>
{job.description && (
<p className="text-sm text-muted-foreground">{job.description}</p>
)}
<div className="text-xs text-muted-foreground space-y-1">
<p>Schedule: {scheduleSummary(job.schedule)}</p>
<p>Next run: {formatDateTime(job.state.nextRunAtMs)}</p>
<p>Last run: {formatDateTime(job.state.lastRunAtMs)}</p>
<p>Last duration: {formatDuration(job.state.lastDurationMs)}</p>
{job.state.lastStatus && <p>Last status: {job.state.lastStatus}</p>}
{job.state.lastError && (
<p className="text-destructive">Error: {job.state.lastError}</p>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void toggleJob(job, !job.enabled)}
disabled={busy}
>
{job.enabled ? "Disable" : "Enable"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void runNow(job)}
disabled={busy}
className="gap-1"
>
<Play className="size-3.5" />
Run now
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void loadRuns(job.id)}
disabled={busy}
className="gap-1"
>
<History className="size-3.5" />
Runs
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => void removeJob(job)}
disabled={busy}
className="text-muted-foreground hover:text-destructive"
title="Delete job"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="border rounded-lg bg-card">
<div className="px-4 py-3 border-b">
<p className="text-sm font-medium flex items-center gap-2">
<Clock3 className="size-4 text-primary" />
Run History{" "}
{selectedJob
? `for "${selectedJob.name}"`
: selectedJobId
? `(job ${selectedJobId})`
: ""}
</p>
</div>
{selectedJobId === null ? (
<div className="p-4 text-sm text-muted-foreground">
Select a job and click &quot;Runs&quot; to inspect history.
</div>
) : !selectedJob ? (
runsLoading ? (
<div className="p-8 text-muted-foreground text-sm flex items-center justify-center gap-2">
<Loader2 className="size-4 animate-spin" />
Loading run history...
</div>
) : runs.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">
Job is no longer in active list (likely one-shot auto-delete). No runs found yet.
</div>
) : (
<div className="divide-y">
{runs.map((entry, idx) => (
<div key={`${entry.ts}-${idx}`} className="p-4 text-sm space-y-1">
<p>
<span className="font-medium">{entry.status.toUpperCase()}</span>{" "}
at {formatDateTime(entry.runAtMs ?? entry.ts)}
</p>
<p className="text-muted-foreground">
Duration: {formatDuration(entry.durationMs)} | Next run:{" "}
{formatDateTime(entry.nextRunAtMs)}
</p>
{entry.summary && <p>{entry.summary}</p>}
{entry.error && <p className="text-destructive">{entry.error}</p>}
</div>
))}
</div>
)
) : runsLoading ? (
<div className="p-8 text-muted-foreground text-sm flex items-center justify-center gap-2">
<Loader2 className="size-4 animate-spin" />
Loading run history...
</div>
) : runs.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No run history yet.</div>
) : (
<div className="divide-y">
{runs.map((entry, idx) => (
<div key={`${entry.ts}-${idx}`} className="p-4 text-sm space-y-1">
<p>
<span className="font-medium">{entry.status.toUpperCase()}</span>{" "}
at {formatDateTime(entry.runAtMs ?? entry.ts)}
</p>
<p className="text-muted-foreground">
Duration: {formatDuration(entry.durationMs)} | Next run:{" "}
{formatDateTime(entry.nextRunAtMs)}
</p>
{entry.summary && <p>{entry.summary}</p>}
{entry.error && <p className="text-destructive">{entry.error}</p>}
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,175 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Check, Copy, Loader2, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { copyTextToClipboard } from "@/lib/utils";
type TokenSource = "env" | "stored" | "none";
interface TokenStatusResponse {
configured: boolean;
source: TokenSource;
maskedToken: string | null;
updatedAt: string | null;
error?: string;
}
interface TokenRotateResponse {
success: boolean;
token: string;
maskedToken: string;
source: "stored";
error?: string;
}
export function ExternalApiTokenManager() {
const [status, setStatus] = useState<TokenStatusResponse | null>(null);
const [loading, setLoading] = useState(true);
const [rotating, setRotating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [freshToken, setFreshToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const loadStatus = useCallback(async () => {
setError(null);
setLoading(true);
try {
const res = await fetch("/api/external/token", { cache: "no-store" });
const data = (await res.json()) as TokenStatusResponse;
if (!res.ok) {
throw new Error(data.error || "Failed to load token status");
}
setStatus(data);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load token status");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadStatus();
}, [loadStatus]);
const rotateToken = useCallback(async () => {
setError(null);
setRotating(true);
setCopied(false);
try {
const res = await fetch("/api/external/token", { method: "POST" });
const data = (await res.json()) as TokenRotateResponse;
if (!res.ok) {
throw new Error(data.error || "Failed to rotate token");
}
setFreshToken(data.token);
await loadStatus();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to rotate token");
} finally {
setRotating(false);
}
}, [loadStatus]);
const copyToken = useCallback(async () => {
if (!freshToken) return;
setError(null);
try {
const copiedOk = await copyTextToClipboard(freshToken);
if (!copiedOk) {
throw new Error("copy-failed");
}
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
setError("Failed to copy token");
}
}, [freshToken]);
const updatedLabel = useMemo(() => {
if (!status?.updatedAt) return null;
const date = new Date(status.updatedAt);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString();
}, [status?.updatedAt]);
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Create a token for <span className="font-mono">Authorization: Bearer ...</span> and
rotate it when needed.
</p>
{status?.source === "env" && (
<p className="text-xs text-amber-600">
Env token detected. Generate to create and use an app-managed token.
</p>
)}
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading token status...
</div>
) : (
<div className="space-y-1 text-sm">
<div>
Status:{" "}
<span className="font-medium">
{status?.configured ? "configured" : "not configured"}
</span>
</div>
{status?.maskedToken && (
<div>
Current token:{" "}
<span className="font-mono text-xs">{status.maskedToken}</span>
</div>
)}
{updatedLabel && (
<div className="text-muted-foreground">Updated: {updatedLabel}</div>
)}
</div>
)}
<Button onClick={rotateToken} disabled={rotating || loading}>
{rotating ? (
<>
<Loader2 className="size-4 animate-spin" />
Processing...
</>
) : (
<>
<RefreshCw className="size-4" />
{status?.configured ? "Regenerate Token" : "Generate Token"}
</>
)}
</Button>
{freshToken && (
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">
New token (shown once):
</p>
<code className="block break-all rounded bg-background p-2 text-xs">
{freshToken}
</code>
<Button variant="outline" size="sm" onClick={copyToken}>
{copied ? (
<>
<Check className="size-4" />
Copied
</>
) : (
<>
<Copy className="size-4" />
Copy token
</>
)}
</Button>
</div>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,288 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import {
ChevronRight,
ChevronDown,
Folder,
FolderOpen,
FileText,
FileCode,
File,
Download,
} from "lucide-react";
import { useAppStore } from "@/store/app-store";
import { cn } from "@/lib/utils";
import { useBackgroundSync } from "@/hooks/use-background-sync";
interface FileEntry {
name: string;
type: "file" | "directory";
size: number;
}
function getFileIcon(name: string) {
const ext = name.split(".").pop()?.toLowerCase();
switch (ext) {
case "ts":
case "tsx":
case "js":
case "jsx":
case "py":
case "sh":
case "json":
case "yaml":
case "yml":
return FileCode;
case "md":
case "txt":
case "csv":
return FileText;
default:
return File;
}
}
interface TreeNodeProps {
projectId: string;
name: string;
relativePath: string; // full relative path from project root
type: "file" | "directory";
depth: number;
refreshToken: number;
}
function TreeNode({
projectId,
name,
relativePath,
type,
depth,
refreshToken,
}: TreeNodeProps) {
const { currentPath, setCurrentPath } = useAppStore();
const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState<FileEntry[] | null>(null);
const [loading, setLoading] = useState(false);
const downloadHref = useMemo(() => {
if (type !== "file") return "";
const params = new URLSearchParams({
project: projectId,
path: relativePath,
});
return `/api/files/download?${params.toString()}`;
}, [projectId, relativePath, type]);
const isActive = type === "directory" && currentPath === relativePath;
// Auto-expand if this folder is a parent of currentPath
useEffect(() => {
if (
type === "directory" &&
currentPath.startsWith(relativePath + "/") &&
!expanded
) {
setExpanded(true);
}
}, [currentPath, relativePath, type, expanded]);
const loadChildren = useCallback(async (force = false, showLoader = true) => {
if (!force && children !== null) return; // already loaded
if (showLoader) {
setLoading(true);
}
try {
const params = new URLSearchParams({
project: projectId,
path: relativePath,
});
const res = await fetch(`/api/files?${params}`);
const data = await res.json();
if (Array.isArray(data)) {
setChildren(data);
}
} catch {
setChildren((prev) => (prev === null ? [] : prev));
}
if (showLoader) {
setLoading(false);
}
}, [projectId, relativePath, children]);
useEffect(() => {
if (type !== "directory" || !expanded || children !== null) return;
void loadChildren(false, true);
}, [type, expanded, children, loadChildren]);
useEffect(() => {
if (type !== "directory" || !expanded) return;
void loadChildren(true, false);
}, [refreshToken, type, expanded, loadChildren]);
const handleClick = () => {
if (type === "directory") {
const willExpand = !expanded;
setExpanded(willExpand);
if (willExpand) {
void loadChildren(true, true);
}
setCurrentPath(relativePath);
}
};
const Icon = type === "directory"
? (expanded ? FolderOpen : Folder)
: getFileIcon(name);
return (
<div className="group/tree-node relative">
<button
onClick={handleClick}
className={cn(
"flex items-center gap-1 w-full text-left text-xs py-1 px-1 rounded-sm hover:bg-accent/50 transition-colors",
type === "file" && "pr-7",
isActive && "bg-accent text-accent-foreground font-medium"
)}
style={{ paddingLeft: `${depth * 12 + 4}px` }}
>
{type === "directory" ? (
expanded ? (
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-3 shrink-0 text-muted-foreground" />
)
) : (
<span className="size-3 shrink-0" />
)}
<Icon
className={cn(
"size-3.5 shrink-0",
type === "directory"
? "text-blue-500"
: "text-muted-foreground"
)}
/>
<span className="truncate">{name}</span>
</button>
{type === "file" && (
<a
href={downloadHref}
download={name}
className="absolute right-1 top-1/2 inline-flex size-5 -translate-y-1/2 items-center justify-center rounded-sm text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover/tree-node:opacity-100"
title={`Download ${name}`}
aria-label={`Download ${name}`}
>
<Download className="size-3.5" />
</a>
)}
{type === "directory" && expanded && (
<div>
{loading && (
<span
className="text-[10px] text-muted-foreground block"
style={{ paddingLeft: `${(depth + 1) * 12 + 4}px` }}
>
Loading...
</span>
)}
{children?.map((child) => (
<TreeNode
key={child.name}
projectId={projectId}
name={child.name}
relativePath={
relativePath ? `${relativePath}/${child.name}` : child.name
}
type={child.type}
depth={depth + 1}
refreshToken={refreshToken}
/>
))}
{children?.length === 0 && !loading && (
<span
className="text-[10px] text-muted-foreground block py-0.5"
style={{ paddingLeft: `${(depth + 1) * 12 + 4}px` }}
>
Empty
</span>
)}
</div>
)}
</div>
);
}
interface FileTreeProps {
projectId: string;
}
export function FileTree({ projectId }: FileTreeProps) {
const { currentPath, setCurrentPath } = useAppStore();
const [rootEntries, setRootEntries] = useState<FileEntry[] | null>(null);
const refreshToken = useBackgroundSync({
topics: ["files", "projects", "global"],
projectId: projectId === "none" ? null : projectId,
});
useEffect(() => {
setRootEntries(null);
}, [projectId]);
useEffect(() => {
let cancelled = false;
const params = new URLSearchParams({ project: projectId, path: "" });
fetch(`/api/files?${params}`)
.then((r) => r.json())
.then((data) => {
if (cancelled) return;
if (Array.isArray(data)) setRootEntries(data);
})
.catch(() => {
if (!cancelled) {
setRootEntries((prev) => (prev === null ? [] : prev));
}
});
return () => {
cancelled = true;
};
}, [projectId, refreshToken]);
return (
<div className="text-xs">
{/* Project root button */}
<button
onClick={() => setCurrentPath("")}
className={cn(
"flex items-center gap-1 w-full text-left text-xs py-1 px-1 rounded-sm hover:bg-accent/50 transition-colors",
currentPath === "" && "bg-accent text-accent-foreground font-medium"
)}
>
<FolderOpen className="size-3.5 shrink-0 text-blue-500" />
<span className="truncate font-medium">/</span>
</button>
{rootEntries === null ? (
<span className="text-[10px] text-muted-foreground block pl-4 py-1">
Loading...
</span>
) : rootEntries.length === 0 ? (
<span className="text-[10px] text-muted-foreground block pl-4 py-1">
No files
</span>
) : (
rootEntries.map((entry) => (
<TreeNode
key={entry.name}
projectId={projectId}
name={entry.name}
relativePath={entry.name}
type={entry.type}
depth={1}
refreshToken={refreshToken}
/>
))
)}
</div>
);
}

View File

@@ -0,0 +1,449 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { FileText, Upload, File, Image as ImageIcon, Loader2, Trash2, MessageCircle, FolderOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
interface KnowledgeChunk {
id: string;
text: string;
index: number;
}
interface KnowledgeFile {
name: string;
size: number;
createdAt: string;
chunkCount: number;
}
interface MemoryEntry {
id: string;
text: string;
createdAt?: string;
area?: string;
}
interface KnowledgeSectionProps {
projectId: string;
}
export function KnowledgeSection({ projectId }: KnowledgeSectionProps) {
const [files, setFiles] = useState<KnowledgeFile[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [memories, setMemories] = useState<MemoryEntry[]>([]);
const [memoriesLoading, setMemoriesLoading] = useState(false);
const [deletingMemoryId, setDeletingMemoryId] = useState<string | null>(null);
const [chunksOpen, setChunksOpen] = useState(false);
const [chunksFile, setChunksFile] = useState<string | null>(null);
const [chunks, setChunks] = useState<KnowledgeChunk[]>([]);
const [chunksLoading, setChunksLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
loadFiles();
loadMemories();
}, [projectId]);
async function loadFiles() {
try {
setLoading(true);
const res = await fetch(`/api/projects/${projectId}/knowledge`);
const data = await res.json();
if (Array.isArray(data)) {
setFiles(data);
}
} catch (error) {
console.error("Failed to load files:", error);
} finally {
setLoading(false);
}
}
async function loadMemories() {
try {
setMemoriesLoading(true);
const res = await fetch(`/api/memory?subdir=${encodeURIComponent(projectId)}`);
const data = await res.json();
if (Array.isArray(data)) {
const entries: MemoryEntry[] = data
.filter((m) => m && m.metadata?.area !== "knowledge")
.map((m) => ({
id: m.id,
text: m.text,
createdAt: typeof m.metadata?.createdAt === "string" ? m.metadata.createdAt : undefined,
area: typeof m.metadata?.area === "string" ? m.metadata.area : undefined,
}));
setMemories(entries);
} else {
setMemories([]);
}
} catch (error) {
console.error("Failed to load memories:", error);
} finally {
setMemoriesLoading(false);
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
// Reset input value so same file can be selected again
e.target.value = "";
try {
setUploading(true);
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`/api/projects/${projectId}/knowledge`, {
method: "POST",
body: formData,
});
if (!res.ok) {
throw new Error("Upload failed");
}
await loadFiles();
} catch (error) {
console.error("Upload error:", error);
alert("Failed to upload file");
} finally {
setUploading(false);
}
}
async function handleDelete(filename: string) {
if (!confirm(`Are you sure you want to delete "${filename}"?`)) return;
try {
setDeleting(filename);
const res = await fetch(`/api/projects/${projectId}/knowledge`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
if (!res.ok) {
throw new Error("Delete failed");
}
await loadFiles();
} catch (error) {
console.error("Delete error:", error);
alert("Failed to delete file");
} finally {
setDeleting(null);
}
}
async function handleDeleteMemory(id: string) {
if (!confirm("Are you sure you want to delete this memory?")) return;
try {
setDeletingMemoryId(id);
const res = await fetch(
`/api/memory?id=${encodeURIComponent(id)}&subdir=${encodeURIComponent(projectId)}`,
{
method: "DELETE",
}
);
if (!res.ok) {
throw new Error("Delete memory failed");
}
await loadMemories();
} catch (error) {
console.error("Delete memory error:", error);
alert("Failed to delete memory");
} finally {
setDeletingMemoryId(null);
}
}
function formatSize(bytes: number) {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
function getFileIcon(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase();
if (["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext || "")) return ImageIcon;
if (["pdf", "docx", "xlsx", "xls"].includes(ext || "")) return FileText;
return File;
}
async function handleViewChunks(filename: string) {
setChunksFile(filename);
setChunksOpen(true);
setChunksLoading(true);
setChunks([]);
try {
const res = await fetch(
`/api/projects/${projectId}/knowledge/chunks?filename=${encodeURIComponent(filename)}`
);
const data = await res.json();
if (data.chunks) setChunks(data.chunks);
} catch (e) {
console.error("Failed to load chunks", e);
} finally {
setChunksLoading(false);
}
}
function formatDate(value?: string) {
if (!value) return "";
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
function getMemoryTitle(memory: MemoryEntry, index: number) {
const created = formatDate(memory.createdAt);
const prefix = memory.area ? memory.area.charAt(0).toUpperCase() + memory.area.slice(1) : "Memory";
if (created) {
return `${prefix} #${index + 1}${created}`;
}
return `${prefix} #${index + 1}`;
}
function handleViewMemory(memory: MemoryEntry, index: number) {
setChunksFile(getMemoryTitle(memory, index));
setChunksOpen(true);
setChunksLoading(false);
setChunks([
{
id: memory.id,
text: memory.text,
index: 1,
},
]);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium flex items-center gap-2">
Knowledge Base
</h3>
<div>
<input
type="file"
ref={fileInputRef}
onChange={handleUpload}
className="hidden"
accept=".txt,.md,.json,.csv,.pdf,.docx,.xlsx,.xls,.png,.jpg,.jpeg,.gif,.bmp,.webp"
/>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
variant="outline"
className="gap-2"
>
{uploading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Upload className="size-4" />
)}
{uploading ? "Uploading..." : "Upload File"}
</Button>
</div>
</div>
<div className="border rounded-lg bg-card">
<div className="flex items-center justify-between px-4 py-3 border-b">
<h4 className="text-sm font-medium">Files Memory</h4>
</div>
{loading ? (
<div className="p-8 text-center text-muted-foreground flex items-center justify-center gap-2">
<Loader2 className="size-4 animate-spin" />
Loading files...
</div>
) : files.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<FolderOpen className="size-10 mx-auto mb-3 opacity-20" />
<p>No files in knowledge base yet.</p>
<p className="text-xs mt-1">Upload PDF, Word, Excel, text, or images to give the agent context.</p>
</div>
) : (
<div className="divide-y">
{files.map((file) => {
const Icon = getFileIcon(file.name);
return (
<div
key={file.name}
className="p-3 flex items-center justify-between hover:bg-muted/50 transition-colors gap-2 cursor-pointer"
onClick={() => {
if ((file.chunkCount ?? 0) > 0) {
handleViewChunks(file.name);
}
}}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="bg-primary/10 p-2 rounded shrink-0">
<Icon className="size-4 text-primary" />
</div>
<div className="min-w-0">
<p className="font-medium text-sm truncate max-w-[200px] sm:max-w-md">
{file.name}
</p>
<p className="text-xs text-muted-foreground">
{formatSize(file.size)} {new Date(file.createdAt).toLocaleDateString()}
{(file.chunkCount ?? 0) > 0 && (
<span> {file.chunkCount} chunks</span>
)}
</p>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDelete(file.name);
}}
disabled={deleting === file.name}
title="Delete"
>
{deleting === file.name ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Chat / agent memory entries for this project */}
<div className="border rounded-lg bg-card">
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium">Chat Memory</h4>
</div>
</div>
{memoriesLoading ? (
<div className="p-8 text-center text-muted-foreground flex items-center justify-center gap-2">
<Loader2 className="size-4 animate-spin" />
Loading memory...
</div>
) : memories.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">
No chat memory saved for this project yet.
</div>
) : (
<div className="divide-y">
{memories.map((memory, index) => (
<div
key={memory.id}
className="p-3 flex items-center justify-between hover:bg-muted/50 transition-colors gap-2"
>
<div
className="flex items-center gap-3 min-w-0 flex-1 cursor-pointer"
onClick={() => handleViewMemory(memory, index)}
>
<div className="bg-primary/10 p-2 rounded shrink-0">
<MessageCircle className="size-4 text-primary" />
</div>
<div className="min-w-0">
<p className="font-medium text-sm truncate">
{getMemoryTitle(memory, index)}
</p>
<p className="text-xs text-muted-foreground line-clamp-2">
{memory.text}
</p>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
onClick={() => handleDeleteMemory(memory.id)}
disabled={deletingMemoryId === memory.id}
title="Delete memory"
>
{deletingMemoryId === memory.id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
</Button>
</div>
</div>
))}
</div>
)}
</div>
<Sheet open={chunksOpen} onOpenChange={setChunksOpen}>
<SheetContent
side="right"
className="w-full sm:max-w-2xl flex flex-col"
>
<SheetHeader>
<SheetTitle className="truncate pr-8">
Chunks: {chunksFile ?? ""}
</SheetTitle>
<SheetDescription>
{chunks.length} vectorized text chunk{chunks.length !== 1 ? "s" : ""}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 pb-4 space-y-3">
{chunksLoading ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading chunks...
</div>
) : chunks.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
No chunks for this file.
</p>
) : (
chunks.map((chunk) => (
<div
key={chunk.id}
className="rounded-lg border bg-muted/30 p-3 text-sm"
>
<p className="font-medium text-xs text-muted-foreground mb-2">
Chunk {chunk.index}
</p>
<pre className="whitespace-pre-wrap wrap-break-word font-sans text-foreground max-h-48 overflow-y-auto">
{chunk.text}
</pre>
</div>
))
)}
</div>
</SheetContent>
</Sheet>
</div>
);
}

View File

@@ -0,0 +1,78 @@
"use client"
import { ChevronRight, type LucideIcon } from "lucide-react"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar"
export function NavMain({
items,
}: {
items: {
title: string
url: string
icon: LucideIcon
isActive?: boolean
items?: {
title: string
url: string
}[]
}[]
}) {
return (
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<Collapsible key={item.title} asChild defaultOpen={item.isActive}>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip={item.title}>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
{item.items?.length ? (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<ChevronRight />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild>
<a href={subItem.url}>
<span>{subItem.title}</span>
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
) : null}
</SidebarMenuItem>
</Collapsible>
))}
</SidebarMenu>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,89 @@
"use client"
import {
Folder,
MoreHorizontal,
Share,
Trash2,
type LucideIcon,
} from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
export function NavProjects({
projects,
}: {
projects: {
name: string
url: string
icon: LucideIcon
}[]
}) {
const { isMobile } = useSidebar()
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarMenu>
{projects.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreHorizontal />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<Folder className="text-muted-foreground" />
<span>View Project</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Share className="text-muted-foreground" />
<span>Share Project</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2 className="text-muted-foreground" />
<span>Delete Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton>
<MoreHorizontal />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}

View File

@@ -0,0 +1,40 @@
import * as React from "react"
import { type LucideIcon } from "lucide-react"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export function NavSecondary({
items,
...props
}: {
items: {
title: string
url: string
icon: LucideIcon
}[]
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild size="sm">
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}

114
src/components/nav-user.tsx Normal file
View File

@@ -0,0 +1,114 @@
"use client"
import {
BadgeCheck,
Bell,
ChevronsUpDown,
CreditCard,
LogOut,
Sparkles,
} from "lucide-react"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
export function NavUser({
user,
}: {
user: {
name: string
email: string
avatar: string
}
}) {
const { isMobile } = useSidebar()
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}

View File

@@ -0,0 +1,186 @@
"use client";
import { useEffect, useState } from "react";
import { BookText, Loader2, Puzzle, Wrench } from "lucide-react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
interface ProjectSkillItem {
name: string;
description: string;
content: string;
license?: string;
compatibility?: string;
}
interface ProjectContextSectionProps {
projectId: string;
}
export function ProjectContextSection({ projectId }: ProjectContextSectionProps) {
const [mcpContent, setMcpContent] = useState<string | null>(null);
const [mcpLoading, setMcpLoading] = useState(true);
const [skills, setSkills] = useState<ProjectSkillItem[]>([]);
const [skillsLoading, setSkillsLoading] = useState(true);
const [selectedSkill, setSelectedSkill] = useState<ProjectSkillItem | null>(null);
const [skillSheetOpen, setSkillSheetOpen] = useState(false);
useEffect(() => {
async function loadContext() {
setMcpLoading(true);
setSkillsLoading(true);
try {
const [mcpRes, skillsRes] = await Promise.all([
fetch(`/api/projects/${projectId}/mcp`),
fetch(`/api/projects/${projectId}/skills`),
]);
if (mcpRes.ok) {
const mcpData = await mcpRes.json();
setMcpContent(typeof mcpData.content === "string" ? mcpData.content : null);
} else {
setMcpContent(null);
}
if (skillsRes.ok) {
const skillsData = await skillsRes.json();
if (Array.isArray(skillsData)) {
setSkills(
skillsData.map((skill) => ({
name: typeof skill.name === "string" ? skill.name : "unknown-skill",
description: typeof skill.description === "string" ? skill.description : "",
content: typeof skill.content === "string" ? skill.content : "",
license: typeof skill.license === "string" ? skill.license : undefined,
compatibility:
typeof skill.compatibility === "string"
? skill.compatibility
: undefined,
}))
);
} else {
setSkills([]);
}
} else {
setSkills([]);
}
} catch {
setMcpContent(null);
setSkills([]);
} finally {
setMcpLoading(false);
setSkillsLoading(false);
}
}
loadContext();
}, [projectId]);
function handleOpenSkill(skill: ProjectSkillItem) {
setSelectedSkill(skill);
setSkillSheetOpen(true);
}
return (
<>
<div className="grid gap-4 lg:grid-cols-2">
<div className="border rounded-lg bg-card">
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2">
<Wrench className="size-4 text-primary" />
<h4 className="text-sm font-medium">MCP Servers</h4>
</div>
</div>
{mcpLoading ? (
<div className="p-8 text-center text-muted-foreground flex items-center justify-center gap-2">
<Loader2 className="size-4 animate-spin" />
Loading MCP config...
</div>
) : !mcpContent ? (
<div className="p-4 text-sm text-muted-foreground">
No `servers.json` found for this project.
</div>
) : (
<div className="p-4">
<pre className="max-h-[360px] overflow-auto rounded-lg border bg-muted/30 p-3 text-xs font-mono whitespace-pre-wrap break-words">
{mcpContent}
</pre>
</div>
)}
</div>
<div className="border rounded-lg bg-card">
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2">
<Puzzle className="size-4 text-primary" />
<h4 className="text-sm font-medium">Project Skills</h4>
</div>
{!skillsLoading && (
<span className="text-xs text-muted-foreground">
{skills.length} total
</span>
)}
</div>
{skillsLoading ? (
<div className="p-8 text-center text-muted-foreground flex items-center justify-center gap-2">
<Loader2 className="size-4 animate-spin" />
Loading skills...
</div>
) : skills.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">
No skills configured for this project yet.
</div>
) : (
<div className="divide-y">
{skills.map((skill) => (
<button
key={skill.name}
type="button"
className="w-full p-3 flex items-start gap-3 hover:bg-muted/50 transition-colors text-left"
onClick={() => handleOpenSkill(skill)}
>
<div className="bg-primary/10 p-2 rounded shrink-0 mt-0.5">
<BookText className="size-4 text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{skill.name}</p>
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">
{skill.description || "No description"}
</p>
</div>
</button>
))}
</div>
)}
</div>
</div>
<Sheet open={skillSheetOpen} onOpenChange={setSkillSheetOpen}>
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col">
<SheetHeader>
<SheetTitle className="truncate pr-8">
Skill: {selectedSkill?.name ?? ""}
</SheetTitle>
<SheetDescription>
{selectedSkill?.description || "Skill instructions"}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 pb-4">
<pre className="rounded-lg border bg-muted/30 p-3 text-sm font-mono whitespace-pre-wrap break-words">
{selectedSkill?.content || "No skill content."}
</pre>
</div>
</SheetContent>
</Sheet>
</>
);
}

View File

@@ -0,0 +1,22 @@
import { Search } from "lucide-react"
import { Label } from "@/components/ui/label"
import { SidebarInput } from "@/components/ui/sidebar"
export function SearchForm({ ...props }: React.ComponentProps<"form">) {
return (
<form {...props}>
<div className="relative">
<Label htmlFor="search" className="sr-only">
Search
</Label>
<SidebarInput
id="search"
placeholder="Type to search..."
className="h-8 pl-7"
/>
<Search className="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 opacity-50 select-none" />
</div>
</form>
)
}

View File

@@ -0,0 +1,614 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { AlertCircle, Check, ChevronDown, Loader2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { MODEL_PROVIDERS } from "@/lib/providers/model-config";
import type { AppSettings } from "@/lib/types";
export type UpdateSettingsFn = (path: string, value: unknown) => void;
function StepIndicator({
step,
currentStep,
label,
}: {
step: number;
currentStep: number;
label: string;
}) {
const completed = currentStep > step;
const active = currentStep === step;
return (
<div className="flex items-center gap-2">
<div
className={`
flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold
transition-all duration-300 shrink-0
${
completed
? "bg-emerald-500 text-white shadow-md shadow-emerald-500/30"
: active
? "bg-primary text-primary-foreground shadow-md shadow-primary/30 ring-2 ring-primary/20"
: "bg-muted text-muted-foreground"
}
`}
>
{completed ? <Check className="size-3.5" /> : step}
</div>
<span
className={`text-sm transition-colors duration-200 ${
active
? "text-foreground font-medium"
: completed
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground"
}`}
>
{label}
</span>
</div>
);
}
function ModelSelect({
value,
models,
loading,
error,
disabled,
onChange,
placeholder,
}: {
value: string;
models: { id: string; name: string }[];
loading: boolean;
error: string | null;
disabled: boolean;
onChange: (value: string) => void;
placeholder?: string;
}) {
return (
<div className="space-y-1.5">
<div className="relative">
<select
value={value}
onChange={(event) => onChange(event.target.value)}
disabled={disabled || loading}
className={`
w-full rounded-md border bg-background px-3 py-2 text-sm appearance-none pr-8
transition-all duration-200
${disabled ? "opacity-50 cursor-not-allowed" : ""}
${error ? "border-red-400 dark:border-red-500" : ""}
`}
>
<option value="">
{loading ? "Loading models..." : placeholder || "Select model"}
</option>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</select>
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground">
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ChevronDown className="size-4" />
)}
</div>
</div>
{error && (
<div className="flex items-center gap-1.5 text-xs text-red-500">
<AlertCircle className="size-3" />
{error}
</div>
)}
</div>
);
}
function useModels(
provider: string,
apiKey: string,
requiresApiKey: boolean,
type: "chat" | "embedding" = "chat",
baseUrl?: string
) {
const [models, setModels] = useState<Array<{ id: string; name: string }>>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchModels = useCallback(async () => {
if (!provider) return;
if (requiresApiKey && !apiKey) return;
const providerConfig = MODEL_PROVIDERS[provider];
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ provider, type });
if (apiKey) params.set("apiKey", apiKey);
if (baseUrl) {
params.set("baseUrl", baseUrl);
} else if (providerConfig?.baseUrl) {
params.set("baseUrl", providerConfig.baseUrl);
}
const response = await fetch(`/api/models?${params}`);
const payload = (await response.json()) as {
models?: Array<{ id: string; name: string }>;
error?: string;
};
if (!response.ok) {
throw new Error(payload.error || "Failed to fetch models");
}
if (payload.models?.length) {
setModels(payload.models);
} else {
const dynamicProviders = [
"openai",
"openrouter",
"ollama",
"anthropic",
"google",
];
if (!dynamicProviders.includes(provider) && providerConfig?.models?.length) {
setModels([...providerConfig.models]);
} else {
setModels([]);
}
}
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Failed to load models");
const providerConfig = MODEL_PROVIDERS[provider];
const dynamicProviders = [
"openai",
"openrouter",
"ollama",
"anthropic",
"google",
];
if (!dynamicProviders.includes(provider) && providerConfig?.models?.length) {
setModels([...providerConfig.models]);
} else {
setModels([]);
}
} finally {
setLoading(false);
}
}, [provider, apiKey, requiresApiKey, type, baseUrl]);
useEffect(() => {
void fetchModels();
}, [fetchModels]);
return { models, loading, error };
}
export function ChatModelWizard({
settings,
updateSettings,
}: {
settings: AppSettings;
updateSettings: UpdateSettingsFn;
}) {
const provider = settings.chatModel.provider;
const apiKey = settings.chatModel.apiKey || "";
const model = settings.chatModel.model;
const providerConfig = MODEL_PROVIDERS[provider];
const requiresApiKey = providerConfig?.requiresApiKey ?? true;
const hasProvider = !!provider;
const hasApiKey = !requiresApiKey || !!apiKey;
const hasModel = !!model;
const currentStep = !hasProvider
? 1
: !hasApiKey
? 2
: !hasModel
? requiresApiKey
? 3
: 2
: requiresApiKey
? 4
: 3;
const { models, loading, error } = useModels(
provider,
apiKey,
requiresApiKey,
"chat",
settings.chatModel.baseUrl
);
return (
<section className="border rounded-xl p-5 bg-card space-y-5 transition-all duration-300">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Chat Model</h3>
<div className="flex items-center gap-4">
<StepIndicator step={1} currentStep={currentStep} label="Provider" />
{requiresApiKey && (
<StepIndicator step={2} currentStep={currentStep} label="API Key" />
)}
<StepIndicator
step={requiresApiKey ? 3 : 2}
currentStep={currentStep}
label="Model"
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Step 1 Provider
</Label>
<select
value={provider}
onChange={(event) => {
const nextProvider = event.target.value;
updateSettings("chatModel.provider", nextProvider);
updateSettings("chatModel.model", "");
if (nextProvider === "ollama") {
updateSettings("chatModel.baseUrl", "http://localhost:11434/v1");
updateSettings("chatModel.apiKey", "");
} else {
updateSettings("chatModel.baseUrl", "");
}
}}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="">Select provider...</option>
{Object.entries(MODEL_PROVIDERS).map(([key, providerOption]) => (
<option key={key} value={key}>
{providerOption.name}
</option>
))}
<option value="custom">Custom (OpenAI-compatible)</option>
</select>
</div>
<div
className={`space-y-2 transition-all duration-300 ${
!hasProvider ? "opacity-40 pointer-events-none" : ""
} ${!requiresApiKey ? "hidden" : ""}`}
>
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Step 2 API Key
</Label>
<Input
type="password"
value={apiKey}
onChange={(event) => updateSettings("chatModel.apiKey", event.target.value)}
placeholder={
providerConfig?.envKey
? `Enter key or set ${providerConfig.envKey} in .env`
: "sk-..."
}
disabled={!hasProvider}
/>
{providerConfig?.envKey && (
<p className="text-xs text-muted-foreground">
Or set{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[11px]">
{providerConfig.envKey}
</code>{" "}
as an environment variable
</p>
)}
</div>
{hasProvider && !requiresApiKey && provider === "ollama" && (
<div className="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/30 rounded-lg px-3 py-2">
<Check className="size-4" />
API Key not required connecting to local Ollama
</div>
)}
{(provider === "custom" || provider === "ollama") && (
<div
className={`space-y-2 transition-all duration-300 ${
!hasApiKey ? "opacity-40 pointer-events-none" : ""
}`}
>
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Base URL
</Label>
<Input
value={settings.chatModel.baseUrl || ""}
onChange={(event) => updateSettings("chatModel.baseUrl", event.target.value)}
placeholder={
provider === "ollama"
? "http://localhost:11434/v1"
: "https://api.example.com/v1"
}
disabled={!hasApiKey}
/>
</div>
)}
<div
className={`space-y-2 transition-all duration-300 ${
!hasApiKey ? "opacity-40 pointer-events-none" : ""
}`}
>
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{requiresApiKey ? "Step 3" : "Step 2"} Model
</Label>
<ModelSelect
value={model}
models={models}
loading={loading}
error={error}
disabled={!hasApiKey}
onChange={(value) => updateSettings("chatModel.model", value)}
placeholder="Select model..."
/>
</div>
<div
className={`space-y-2 transition-all duration-300 ${
!model ? "opacity-40 pointer-events-none" : ""
}`}
>
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Temperature
</Label>
<Input
type="number"
step="0.1"
min="0"
max="2"
value={settings.chatModel.temperature || 0.7}
onChange={(event) =>
updateSettings("chatModel.temperature", parseFloat(event.target.value))
}
disabled={!model}
className="max-w-[120px]"
/>
</div>
</section>
);
}
export function EmbeddingsModelWizard({
settings,
updateSettings,
}: {
settings: AppSettings;
updateSettings: UpdateSettingsFn;
}) {
const provider = settings.embeddingsModel.provider;
const apiKey = settings.embeddingsModel.apiKey || "";
const model = settings.embeddingsModel.model;
const embeddingProviders: Record<
string,
{ name: string; requiresApiKey: boolean; envKey?: string; baseUrl?: string }
> = {
openai: { name: "OpenAI", requiresApiKey: true, envKey: "OPENAI_API_KEY" },
openrouter: {
name: "OpenRouter",
requiresApiKey: true,
envKey: "OPENROUTER_API_KEY",
},
ollama: {
name: "Ollama",
requiresApiKey: false,
baseUrl: "http://localhost:11434",
},
google: { name: "Google", requiresApiKey: true, envKey: "GOOGLE_API_KEY" },
custom: { name: "Custom (OpenAI-compatible)", requiresApiKey: true },
};
const providerConfig = embeddingProviders[provider] || embeddingProviders.openai;
const requiresApiKey = providerConfig.requiresApiKey;
const hasProvider = !!provider && provider !== "mock";
const hasApiKey = !requiresApiKey || !!apiKey;
const hasModel = !!model;
const currentStep = !hasProvider
? 1
: !hasApiKey
? 2
: !hasModel
? requiresApiKey
? 3
: 2
: requiresApiKey
? 4
: 3;
const { models, loading, error } = useModels(
provider,
apiKey,
requiresApiKey,
"embedding",
settings.embeddingsModel.baseUrl
);
const knownDimensions: Record<string, number> = {
"text-embedding-3-small": 1536,
"text-embedding-3-large": 3072,
"text-embedding-ada-002": 1536,
"text-embedding-004": 768,
"nomic-embed-text": 768,
"mxbai-embed-large": 1024,
"all-minilm": 384,
"bge-m3": 1024,
"bge-large": 1024,
"bge-base": 768,
"e5-large": 1024,
"e5-base": 768,
"multilingual-e5": 1024,
"mistral-embed": 1024,
"gte-large": 1024,
"gte-base": 768,
"mpnet-base": 768,
};
return (
<section className="border rounded-xl p-5 bg-card space-y-5 transition-all duration-300">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Embeddings Model</h3>
<div className="flex items-center gap-4">
<StepIndicator step={1} currentStep={currentStep} label="Provider" />
{requiresApiKey && (
<StepIndicator step={2} currentStep={currentStep} label="API Key" />
)}
<StepIndicator
step={requiresApiKey ? 3 : 2}
currentStep={currentStep}
label="Model"
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Step 1 Provider
</Label>
<select
value={provider}
onChange={(event) => {
const nextProvider = event.target.value;
updateSettings("embeddingsModel.provider", nextProvider);
updateSettings("embeddingsModel.model", "");
if (nextProvider === "ollama") {
updateSettings("embeddingsModel.baseUrl", "http://localhost:11434/v1");
updateSettings("embeddingsModel.apiKey", "");
} else {
updateSettings("embeddingsModel.baseUrl", "");
}
}}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="">Select provider...</option>
{Object.entries(embeddingProviders).map(([key, providerOption]) => (
<option key={key} value={key}>
{providerOption.name}
</option>
))}
</select>
</div>
<div
className={`space-y-2 transition-all duration-300 ${
!hasProvider ? "opacity-40 pointer-events-none" : ""
} ${!requiresApiKey ? "hidden" : ""}`}
>
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Step 2 API Key
</Label>
<Input
type="password"
value={apiKey}
onChange={(event) =>
updateSettings("embeddingsModel.apiKey", event.target.value)
}
placeholder={
providerConfig.envKey
? `Enter key or set ${providerConfig.envKey} in .env`
: "sk-..."
}
disabled={!hasProvider}
/>
{providerConfig.envKey && (
<p className="text-xs text-muted-foreground">
Or set{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[11px]">
{providerConfig.envKey}
</code>{" "}
as an environment variable
</p>
)}
</div>
{hasProvider && !requiresApiKey && provider === "ollama" && (
<div className="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/30 rounded-lg px-3 py-2">
<Check className="size-4" />
API Key not required connecting to local Ollama
</div>
)}
{provider === "ollama" && (
<div
className={`space-y-2 transition-all duration-300 ${
!hasProvider ? "opacity-40 pointer-events-none" : ""
}`}
>
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Base URL
</Label>
<Input
value={settings.embeddingsModel.baseUrl || ""}
onChange={(event) =>
updateSettings("embeddingsModel.baseUrl", event.target.value)
}
placeholder="http://localhost:11434/v1"
disabled={!hasProvider}
/>
</div>
)}
<div
className={`space-y-2 transition-all duration-300 ${
!hasApiKey ? "opacity-40 pointer-events-none" : ""
}`}
>
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{requiresApiKey ? "Step 3" : "Step 2"} Model
</Label>
<div className="flex gap-2">
<div className="flex-1">
<ModelSelect
value={model}
models={models}
loading={loading}
error={error}
disabled={!hasApiKey}
onChange={(value) => {
updateSettings("embeddingsModel.model", value);
let dimensions = 1536;
for (const [pattern, knownValue] of Object.entries(knownDimensions)) {
if (value.includes(pattern)) {
dimensions = knownValue;
break;
}
}
updateSettings("embeddingsModel.dimensions", dimensions);
}}
placeholder="Select embedding model..."
/>
</div>
<div className="w-24">
<Input
type="number"
value={settings.embeddingsModel.dimensions || 1536}
onChange={(event) =>
updateSettings(
"embeddingsModel.dimensions",
parseInt(event.target.value, 10)
)
}
placeholder="Dims"
title="Embedding Dimensions"
disabled={!hasApiKey}
/>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
Dimensions are auto-detected for known models. Adjust if necessary.
</p>
</div>
</section>
);
}

View File

@@ -0,0 +1,29 @@
"use client"
import { SidebarIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { useSidebar } from "@/components/ui/sidebar"
export function SiteHeader({ title }: { title?: string }) {
const { toggleSidebar } = useSidebar()
return (
<header className="bg-background sticky top-0 z-50 flex w-full items-center border-b">
<div className="flex h-(--header-height) w-full items-center gap-2 px-4">
<Button
className="h-8 w-8"
variant="ghost"
size="icon"
onClick={toggleSidebar}
>
<SidebarIcon />
</Button>
<Separator orientation="vertical" className="mr-2 h-4" />
<h1 className="text-sm font-medium">
{title || "Eggent"}
</h1>
</div>
</header>
)
}

View File

@@ -0,0 +1,569 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { KeyRound, Loader2, Link2, RotateCcw, ShieldCheck, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface TelegramSettingsResponse {
botToken: string;
webhookSecret: string;
publicBaseUrl: string;
defaultProjectId: string;
allowedUserIds: string[];
pendingAccessCodes: number;
updatedAt: string | null;
sources: {
botToken: "stored" | "env" | "none";
webhookSecret: "stored" | "env" | "none";
};
error?: string;
}
interface TelegramAccessCodeResponse {
success?: boolean;
code?: string;
createdAt?: string;
expiresAt?: string;
error?: string;
}
interface WebhookStatusResponse {
configured: boolean;
message?: string;
webhook: {
url: string;
pendingUpdateCount: number;
lastErrorDate: number | null;
lastErrorMessage: string | null;
} | null;
error?: string;
}
type ActionState = "idle" | "loading";
function sourceLabel(source: "stored" | "env" | "none"): string {
if (source === "stored") return "stored in app";
if (source === "env") return "from .env";
return "not configured";
}
export function TelegramIntegrationManager() {
const [botToken, setBotToken] = useState("");
const [publicBaseUrl, setPublicBaseUrl] = useState("");
const [storedMaskedToken, setStoredMaskedToken] = useState("");
const [tokenSource, setTokenSource] = useState<"stored" | "env" | "none">(
"none"
);
const [allowedUserIdsInput, setAllowedUserIdsInput] = useState("");
const [pendingAccessCodes, setPendingAccessCodes] = useState(0);
const [generatedAccessCode, setGeneratedAccessCode] = useState<string | null>(null);
const [generatedAccessCodeExpiresAt, setGeneratedAccessCodeExpiresAt] = useState<
string | null
>(null);
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
const [webhookStatus, setWebhookStatus] = useState<WebhookStatusResponse | null>(
null
);
const [loadingSettings, setLoadingSettings] = useState(true);
const [connectState, setConnectState] = useState<ActionState>("idle");
const [reconnectState, setReconnectState] = useState<ActionState>("idle");
const [disconnectState, setDisconnectState] = useState<ActionState>("idle");
const [saveAllowedUsersState, setSaveAllowedUsersState] = useState<ActionState>("idle");
const [generateCodeState, setGenerateCodeState] = useState<ActionState>("idle");
const [webhookState, setWebhookState] = useState<ActionState>("idle");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const loadSettings = useCallback(async () => {
setLoadingSettings(true);
setError(null);
try {
const res = await fetch("/api/integrations/telegram/config", {
cache: "no-store",
});
const data = (await res.json()) as TelegramSettingsResponse;
if (!res.ok) {
throw new Error(data.error || "Failed to load Telegram settings");
}
setStoredMaskedToken(data.botToken || "");
setPublicBaseUrl(data.publicBaseUrl || "");
setTokenSource(data.sources.botToken);
setAllowedUserIdsInput((data.allowedUserIds || []).join(", "));
setPendingAccessCodes(
typeof data.pendingAccessCodes === "number" ? data.pendingAccessCodes : 0
);
setUpdatedAt(data.updatedAt);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load Telegram settings");
} finally {
setLoadingSettings(false);
}
}, []);
const loadWebhookStatus = useCallback(async () => {
setWebhookState("loading");
try {
const res = await fetch("/api/integrations/telegram/webhook", {
cache: "no-store",
});
const data = (await res.json()) as WebhookStatusResponse;
if (!res.ok) {
throw new Error(data.error || "Failed to load webhook status");
}
setWebhookStatus(data);
} catch {
setWebhookStatus(null);
} finally {
setWebhookState("idle");
}
}, []);
useEffect(() => {
loadSettings();
loadWebhookStatus();
}, [loadSettings, loadWebhookStatus]);
const connectTelegram = useCallback(async () => {
setConnectState("loading");
setError(null);
setSuccess(null);
try {
const trimmedToken = botToken.trim();
const trimmedBaseUrl = publicBaseUrl.trim();
if (!trimmedBaseUrl) {
throw new Error("Public Base URL is required");
}
if (!trimmedToken && tokenSource === "none") {
throw new Error("Telegram bot token is required");
}
const saveConfigRes = await fetch("/api/integrations/telegram/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...(trimmedToken ? { botToken: trimmedToken } : {}),
publicBaseUrl: trimmedBaseUrl,
}),
});
const saveConfigData = (await saveConfigRes.json()) as { error?: string };
if (!saveConfigRes.ok) {
throw new Error(saveConfigData.error || "Failed to save Telegram settings");
}
const setupRes = await fetch("/api/integrations/telegram/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
botToken: trimmedToken,
}),
});
const setupData = (await setupRes.json()) as {
success?: boolean;
message?: string;
error?: string;
};
if (!setupRes.ok) {
throw new Error(setupData.error || "Failed to connect Telegram");
}
setSuccess(setupData.message || "Telegram connected");
setBotToken("");
await Promise.all([loadSettings(), loadWebhookStatus()]);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to connect Telegram");
} finally {
setConnectState("idle");
}
}, [botToken, loadSettings, loadWebhookStatus, publicBaseUrl, tokenSource]);
const reconnectTelegram = useCallback(async () => {
setReconnectState("loading");
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/integrations/telegram/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const data = (await res.json()) as {
success?: boolean;
message?: string;
error?: string;
};
if (!res.ok) {
throw new Error(data.error || "Failed to reconnect Telegram");
}
setSuccess(data.message || "Telegram reconnected");
await Promise.all([loadSettings(), loadWebhookStatus()]);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to reconnect Telegram");
} finally {
setReconnectState("idle");
}
}, [loadSettings, loadWebhookStatus]);
const disconnectTelegram = useCallback(async () => {
setDisconnectState("loading");
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/integrations/telegram/disconnect", {
method: "POST",
});
const data = (await res.json()) as {
message?: string;
note?: string | null;
webhookWarning?: string | null;
error?: string;
};
if (!res.ok) {
throw new Error(data.error || "Failed to disconnect Telegram");
}
const messages = [data.message || "Telegram disconnected"];
if (data.webhookWarning) messages.push(`Webhook warning: ${data.webhookWarning}`);
if (data.note) messages.push(data.note);
setSuccess(messages.join(" "));
setBotToken("");
await Promise.all([loadSettings(), loadWebhookStatus()]);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to disconnect Telegram");
} finally {
setDisconnectState("idle");
}
}, [loadSettings, loadWebhookStatus]);
const saveAllowedUsers = useCallback(async () => {
setSaveAllowedUsersState("loading");
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/integrations/telegram/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
allowedUserIds: allowedUserIdsInput,
}),
});
const data = (await res.json()) as TelegramSettingsResponse;
if (!res.ok) {
throw new Error(data.error || "Failed to save allowed users");
}
setAllowedUserIdsInput((data.allowedUserIds || []).join(", "));
setPendingAccessCodes(
typeof data.pendingAccessCodes === "number" ? data.pendingAccessCodes : 0
);
setSuccess("Allowed Telegram user_id list updated");
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save allowed users");
} finally {
setSaveAllowedUsersState("idle");
}
}, [allowedUserIdsInput]);
const generateAccessCode = useCallback(async () => {
setGenerateCodeState("loading");
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/integrations/telegram/access-code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const data = (await res.json()) as TelegramAccessCodeResponse;
if (!res.ok || !data.code) {
throw new Error(data.error || "Failed to generate access code");
}
setGeneratedAccessCode(data.code);
setGeneratedAccessCodeExpiresAt(
typeof data.expiresAt === "string" ? data.expiresAt : null
);
setSuccess("Access code generated");
await loadSettings();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to generate access code");
} finally {
setGenerateCodeState("idle");
}
}, [loadSettings]);
const hasTokenConfigured = tokenSource !== "none";
const hasBaseUrlConfigured = publicBaseUrl.trim().length > 0;
const isConnected = hasTokenConfigured && hasBaseUrlConfigured;
const canConnect = useMemo(() => {
if (!publicBaseUrl.trim()) return false;
if (botToken.trim()) return true;
return tokenSource !== "none";
}, [botToken, publicBaseUrl, tokenSource]);
const isBusy =
loadingSettings ||
connectState === "loading" ||
reconnectState === "loading" ||
disconnectState === "loading" ||
saveAllowedUsersState === "loading" ||
generateCodeState === "loading";
const updatedAtLabel = useMemo(() => {
if (!updatedAt) return null;
const date = new Date(updatedAt);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString();
}, [updatedAt]);
return (
<div className="space-y-4">
<section className="rounded-lg border bg-card p-4 space-y-4">
<div className="space-y-1">
<h3 className="text-lg font-medium">Telegram</h3>
{!isConnected ? (
<p className="text-sm text-muted-foreground">
Enter the bot token and Public Base URL, then click Connect Telegram.
</p>
) : (
<p className="text-sm text-muted-foreground">
Telegram is connected. You can reconnect or disconnect it.
</p>
)}
</div>
{!isConnected ? (
<>
<div className="space-y-2">
<Label htmlFor="telegram-bot-token">Bot Token</Label>
<Input
id="telegram-bot-token"
type="password"
value={botToken}
onChange={(e) => setBotToken(e.target.value)}
placeholder="123456789:AA..."
disabled={isBusy}
/>
<p className="text-xs text-muted-foreground">
Current source: {sourceLabel(tokenSource)}
{storedMaskedToken ? ` (${storedMaskedToken})` : ""}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="telegram-public-base-url">Public Base URL</Label>
<Input
id="telegram-public-base-url"
type="text"
value={publicBaseUrl}
onChange={(e) => setPublicBaseUrl(e.target.value)}
placeholder="https://your-public-host.example.com"
disabled={isBusy}
/>
<p className="text-xs text-muted-foreground">
Webhook endpoint:{" "}
<span className="font-mono">{publicBaseUrl || "https://..."}/api/integrations/telegram</span>
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={connectTelegram} disabled={!canConnect || isBusy}>
{connectState === "loading" ? (
<>
<Loader2 className="size-4 animate-spin" />
Connecting...
</>
) : (
<>
<Link2 className="size-4" />
Connect Telegram
</>
)}
</Button>
</div>
</>
) : (
<>
<div className="rounded-md border bg-muted/20 p-3 text-sm space-y-1">
<div>
Token source: {sourceLabel(tokenSource)}
{storedMaskedToken ? ` (${storedMaskedToken})` : ""}
</div>
<div>
Public Base URL:{" "}
<span className="font-mono text-xs break-all">{publicBaseUrl}</span>
</div>
{updatedAtLabel && (
<div className="text-xs text-muted-foreground">Updated: {updatedAtLabel}</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
onClick={reconnectTelegram}
disabled={isBusy}
>
{reconnectState === "loading" ? (
<>
<Loader2 className="size-4 animate-spin" />
Reconnecting...
</>
) : (
<>
<RotateCcw className="size-4" />
Reconnect Telegram
</>
)}
</Button>
<Button
variant="outline"
onClick={disconnectTelegram}
disabled={isBusy}
>
{disconnectState === "loading" ? (
<>
<Loader2 className="size-4 animate-spin" />
Disconnecting...
</>
) : (
<>
<Trash2 className="size-4" />
Disconnect Telegram
</>
)}
</Button>
</div>
</>
)}
</section>
<section className="rounded-lg border bg-card p-4 space-y-4">
<div className="space-y-1">
<h4 className="font-medium">Access Control</h4>
<p className="text-sm text-muted-foreground">
Only users from this allowlist can chat with the bot. Others must send an access
code first.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="telegram-allowed-user-ids">Allowed Telegram user_id</Label>
<Input
id="telegram-allowed-user-ids"
type="text"
value={allowedUserIdsInput}
onChange={(e) => setAllowedUserIdsInput(e.target.value)}
placeholder="123456789, 987654321"
disabled={isBusy}
/>
<p className="text-xs text-muted-foreground">
Use comma, space, or newline as separator.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
onClick={saveAllowedUsers}
disabled={isBusy}
>
{saveAllowedUsersState === "loading" ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving...
</>
) : (
<>
<ShieldCheck className="size-4" />
Save Allowlist
</>
)}
</Button>
<Button
variant="outline"
onClick={generateAccessCode}
disabled={isBusy}
>
{generateCodeState === "loading" ? (
<>
<Loader2 className="size-4 animate-spin" />
Generating...
</>
) : (
<>
<KeyRound className="size-4" />
Generate Access Code
</>
)}
</Button>
</div>
<div className="rounded-md border bg-muted/20 p-3 text-sm space-y-1">
<div>Pending access codes: {pendingAccessCodes}</div>
{generatedAccessCode && (
<div>
Latest code: <span className="font-mono">{generatedAccessCode}</span>
</div>
)}
{generatedAccessCodeExpiresAt && (
<div className="text-xs text-muted-foreground">
Expires at: {new Date(generatedAccessCodeExpiresAt).toLocaleString()}
</div>
)}
</div>
</section>
{isConnected && (
<section className="rounded-lg border bg-card p-4 space-y-4">
<div className="space-y-1">
<h4 className="font-medium">Webhook Status</h4>
<p className="text-sm text-muted-foreground">
Current webhook status from the latest check.
</p>
</div>
{webhookState === "loading" && (
<p className="text-sm text-muted-foreground">Loading webhook status...</p>
)}
{webhookStatus?.webhook && (
<div className="rounded-md border bg-muted/20 p-3 text-sm space-y-1">
<div>
URL:{" "}
<span className="font-mono text-xs break-all">
{webhookStatus.webhook.url || "(empty)"}
</span>
</div>
<div>Pending updates: {webhookStatus.webhook.pendingUpdateCount}</div>
{webhookStatus.webhook.lastErrorMessage && (
<div className="text-red-600">
Last error: {webhookStatus.webhook.lastErrorMessage}
</div>
)}
{webhookStatus.webhook.lastErrorDate && (
<div className="text-xs text-muted-foreground">
Last error at:{" "}
{new Date(webhookStatus.webhook.lastErrorDate * 1000).toLocaleString()}
</div>
)}
</div>
)}
{webhookState !== "loading" && !webhookStatus?.webhook && (
<p className="text-sm text-muted-foreground">
{webhookStatus?.message || "Webhook status is not loaded yet."}
</p>
)}
</section>
)}
{success && <p className="text-sm text-emerald-600">{success}</p>}
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
}

View File

@@ -0,0 +1,109 @@
import * as React from "react"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,33 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

143
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,726 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { Slot } from "radix-ui"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,114 @@
"use client";
import { useEffect, useState } from "react";
import type { UiSyncEvent, UiSyncTopic } from "@/lib/realtime/types";
interface BackgroundSyncOptions {
topics?: UiSyncTopic[];
projectId?: string | null;
chatId?: string | null;
fallbackIntervalMs?: number;
}
function matchesScope(
event: UiSyncEvent,
options: BackgroundSyncOptions
): boolean {
if (options.topics && options.topics.length > 0) {
if (!options.topics.includes(event.topic)) {
return false;
}
}
if (event.topic === "projects" || event.topic === "global") {
return true;
}
const expectedProject = options.projectId ?? null;
if (options.projectId !== undefined) {
const eventProject = event.projectId ?? null;
if (eventProject !== expectedProject) {
return false;
}
}
if (options.chatId !== undefined && options.chatId !== null) {
if (!event.chatId || event.chatId !== options.chatId) {
return false;
}
}
return true;
}
export function useBackgroundSync(options: BackgroundSyncOptions = {}): number {
const fallbackIntervalMs = options.fallbackIntervalMs ?? 30000;
const topicsKey = options.topics?.join(",") ?? "";
const projectId = options.projectId;
const chatId = options.chatId;
const [tick, setTick] = useState(0);
useEffect(() => {
let eventSource: EventSource | null = null;
const scope: BackgroundSyncOptions = {
topics: topicsKey
? (topicsKey.split(",").filter(Boolean) as UiSyncTopic[])
: undefined,
projectId,
chatId,
};
const bump = () => {
if (document.visibilityState !== "visible") return;
setTick((value) => value + 1);
};
const onSync = (event: MessageEvent<string>) => {
try {
const parsed = JSON.parse(event.data) as UiSyncEvent;
if (!matchesScope(parsed, scope)) {
return;
}
bump();
} catch {
// Ignore malformed SSE event payloads.
}
};
const connect = () => {
eventSource = new EventSource("/api/events");
eventSource.addEventListener("sync", onSync as EventListener);
};
connect();
const fallbackTimer =
fallbackIntervalMs > 0 ? window.setInterval(bump, fallbackIntervalMs) : null;
const onVisibilityChange = () => {
if (document.visibilityState !== "visible") return;
setTick((value) => value + 1);
};
const onWindowFocus = () => {
setTick((value) => value + 1);
};
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("focus", onWindowFocus);
return () => {
if (fallbackTimer) {
window.clearInterval(fallbackTimer);
}
if (eventSource) {
eventSource.removeEventListener("sync", onSync as EventListener);
eventSource.close();
}
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("focus", onWindowFocus);
};
}, [chatId, projectId, fallbackIntervalMs, topicsKey]);
return tick;
}

19
src/hooks/use-mobile.ts Normal file
View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

676
src/lib/agent/agent.ts Normal file
View File

@@ -0,0 +1,676 @@
import {
streamText,
generateText,
stepCountIs,
type ModelMessage,
type ToolExecutionOptions,
type ToolSet,
} from "ai";
import { createModel } from "@/lib/providers/llm-provider";
import { buildSystemPrompt } from "@/lib/agent/prompts";
import { getSettings } from "@/lib/storage/settings-store";
import { getChat, saveChat } from "@/lib/storage/chat-store";
import { createAgentTools } from "@/lib/tools/tool";
import { getProjectMcpTools } from "@/lib/mcp/client";
import type { AgentContext } from "@/lib/agent/types";
import type { ChatMessage } from "@/lib/types";
import { publishUiSyncEvent } from "@/lib/realtime/event-bus";
const LLM_LOG_BORDER = "═".repeat(60);
function asRecord(value: unknown): Record<string, unknown> | null {
if (value == null || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function toStableValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => toStableValue(item));
}
const record = asRecord(value);
if (!record) {
return value;
}
return Object.keys(record)
.sort()
.reduce<Record<string, unknown>>((acc, key) => {
acc[key] = toStableValue(record[key]);
return acc;
}, {});
}
function stableSerialize(value: unknown): string {
try {
return JSON.stringify(toStableValue(value));
} catch {
return String(value);
}
}
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return null;
}
try {
const parsed = JSON.parse(trimmed);
return asRecord(parsed);
} catch {
return null;
}
}
function extractDeterministicFailureSignature(output: unknown): string | null {
const outputRecord = asRecord(output);
if (outputRecord && outputRecord.success === false) {
const errorText =
typeof outputRecord.error === "string"
? outputRecord.error
: "Tool returned success=false";
const codeText = typeof outputRecord.code === "string" ? outputRecord.code : "";
return [errorText, codeText].filter(Boolean).join(" | ");
}
if (typeof output !== "string") {
return null;
}
const trimmed = output.trim();
const parsed = parseJsonObject(trimmed);
if (parsed && parsed.success === false) {
const errorText =
typeof parsed.error === "string" ? parsed.error : "Tool returned success=false";
const codeText = typeof parsed.code === "string" ? parsed.code : "";
return [errorText, codeText].filter(Boolean).join(" | ");
}
const isExplicitFailure =
trimmed.startsWith("[MCP tool error]") ||
trimmed.startsWith("[Preflight error]") ||
trimmed.startsWith("[Loop guard]") ||
/^Failed\b/i.test(trimmed) ||
/^Skill ".+" not found\./i.test(trimmed) ||
(/\bnot found\b/i.test(trimmed) &&
!/No relevant memories found\./i.test(trimmed));
if (!isExplicitFailure) {
return null;
}
return trimmed.length > 400 ? `${trimmed.slice(0, 400)}...` : trimmed;
}
function applyGlobalToolLoopGuard(tools: ToolSet): ToolSet {
const deterministicFailureByCall = new Map<string, string>();
const wrappedTools: ToolSet = {};
for (const [toolName, toolDef] of Object.entries(tools)) {
if (toolName === "response" || typeof toolDef.execute !== "function") {
wrappedTools[toolName] = toolDef;
continue;
}
wrappedTools[toolName] = {
...toolDef,
execute: async (input: unknown, options: ToolExecutionOptions) => {
const callKey = `${toolName}:${stableSerialize(input)}`;
const previousFailure = deterministicFailureByCall.get(callKey);
if (previousFailure) {
return (
`[Loop guard] Blocked repeated tool call "${toolName}" with identical arguments.\n` +
`Previous deterministic error: ${previousFailure}\n` +
"Change arguments based on the tool error before retrying."
);
}
const output = await toolDef.execute(input as never, options as never);
const failureSignature = extractDeterministicFailureSignature(output);
if (failureSignature) {
deterministicFailureByCall.set(callKey, failureSignature);
} else {
deterministicFailureByCall.delete(callKey);
}
return output;
},
} as typeof toolDef;
}
return wrappedTools;
}
/**
* Convert stored ChatMessages to AI SDK ModelMessage format
*/
function convertChatMessagesToModelMessages(messages: ChatMessage[]): ModelMessage[] {
const result: ModelMessage[] = [];
for (const m of messages) {
if (m.role === "tool") {
// Tool result message - AI SDK uses 'output' not 'result'
result.push({
role: "tool",
content: [{
type: "tool-result",
toolCallId: m.toolCallId!,
toolName: m.toolName!,
output: { type: "json", value: m.toolResult as import("@ai-sdk/provider").JSONValue },
}],
});
} else if (m.role === "assistant" && m.toolCalls && m.toolCalls.length > 0) {
// Assistant message with tool calls - AI SDK uses 'input' not 'args'
const content: Array<
| { type: "text"; text: string }
| { type: "tool-call"; toolCallId: string; toolName: string; input: unknown }
> = [];
if (m.content) {
content.push({ type: "text", text: m.content });
}
for (const tc of m.toolCalls) {
content.push({
type: "tool-call",
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: tc.args,
});
}
result.push({ role: "assistant", content });
} else if (m.role === "user" || m.role === "assistant") {
// Regular user or assistant message
result.push({ role: m.role, content: m.content });
}
// Skip system messages for now
}
return result;
}
/**
* Convert AI SDK ModelMessage to our ChatMessage format for storage.
* Tool messages can contain multiple tool results, so this returns an array.
*/
function convertModelMessageToChatMessages(msg: ModelMessage, now: string): ChatMessage[] {
if (msg.role === "tool") {
// Tool result - AI SDK may include multiple tool-result parts in one message.
const content = Array.isArray(msg.content) ? msg.content : [];
const toolMessages: ChatMessage[] = [];
for (const part of content) {
if (!(typeof part === "object" && part !== null && "type" in part && part.type === "tool-result")) {
continue;
}
const tr = part as {
toolCallId: string;
toolName: string;
output?: { type: string; value: unknown } | unknown;
result?: unknown;
};
const outputContainer = tr.output ?? tr.result;
const outputValue =
typeof outputContainer === "object" &&
outputContainer !== null &&
"value" in outputContainer
? (outputContainer as { value: unknown }).value
: outputContainer;
toolMessages.push({
id: crypto.randomUUID(),
role: "tool",
content:
outputValue === undefined
? ""
: typeof outputValue === "string"
? outputValue
: JSON.stringify(outputValue),
toolCallId: tr.toolCallId,
toolName: tr.toolName,
toolResult: outputValue,
createdAt: now,
});
}
return toolMessages;
}
if (msg.role === "assistant") {
const content = msg.content;
if (Array.isArray(content)) {
// Extract text and tool calls - AI SDK uses 'input' not 'args'
let textContent = "";
const toolCalls: ChatMessage["toolCalls"] = [];
for (const part of content) {
if (typeof part === "object" && part !== null) {
if ("type" in part && part.type === "text" && "text" in part) {
textContent += (part as { text: string }).text;
} else if ("type" in part && part.type === "tool-call") {
const tc = part as { toolCallId: string; toolName: string; input: unknown };
toolCalls.push({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
args: tc.input as Record<string, unknown>,
});
}
}
}
return [{
id: crypto.randomUUID(),
role: "assistant",
content: textContent,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
createdAt: now,
}];
}
// String content
return [{
id: crypto.randomUUID(),
role: "assistant",
content: typeof content === "string" ? content : "",
createdAt: now,
}];
}
// User or other
return [{
id: crypto.randomUUID(),
role: msg.role as "user" | "assistant" | "system" | "tool",
content: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content),
createdAt: now,
}];
}
function logLLMRequest(options: {
model: string;
system: string;
messages: ModelMessage[];
toolNames: string[];
temperature?: number;
maxTokens?: number;
label?: string;
}) {
const { model, system, messages, toolNames, temperature, maxTokens, label = "LLM Request" } = options;
console.log(`\n${LLM_LOG_BORDER}`);
console.log(` ${label}`);
console.log(LLM_LOG_BORDER);
console.log(` Model: ${model}`);
console.log(` Temperature: ${temperature ?? "default"}`);
console.log(` Max tokens: ${maxTokens ?? "default"}`);
console.log(` Tools: ${toolNames.length ? toolNames.join(", ") : "none"}`);
console.log(` Messages: ${messages.length}`);
console.log(LLM_LOG_BORDER);
console.log(" --- SYSTEM ---\n");
console.log(system);
console.log("\n --- MESSAGES ---");
for (let i = 0; i < messages.length; i++) {
const m = messages[i];
const role = m.role.toUpperCase();
const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
const preview = content.length > 500 ? content.slice(0, 500) + "…" : content;
console.log(` [${i + 1}] ${role}:\n${preview}`);
}
console.log(`\n${LLM_LOG_BORDER}\n`);
}
/**
* Run the agent for a given chat context and return a streamable result.
* Uses Vercel AI SDK's streamText with stopWhen for automatic tool loop.
*/
export async function runAgent(options: {
chatId: string;
userMessage: string;
projectId?: string;
currentPath?: string;
agentNumber?: number;
}) {
const settings = await getSettings();
const model = createModel(settings.chatModel);
// Build context
const context: AgentContext = {
chatId: options.chatId,
projectId: options.projectId,
currentPath: options.currentPath,
memorySubdir: options.projectId
? `${options.projectId}`
: "main",
knowledgeSubdirs: options.projectId
? [`${options.projectId}`, "main"]
: ["main"],
history: [],
agentNumber: options.agentNumber ?? 0,
data: {
currentUserMessage: options.userMessage,
},
};
// Load existing chat history
const chat = await getChat(options.chatId);
if (chat) {
// Convert stored messages to ModelMessage format (including tool calls/results)
context.history = convertChatMessagesToModelMessages(chat.messages);
}
// Build tools: base + optional MCP tools from project .meta/mcp
const baseTools = createAgentTools(context, settings);
let mcpCleanup: (() => Promise<void>) | undefined;
let tools = baseTools;
if (options.projectId) {
const mcp = await getProjectMcpTools(options.projectId);
if (mcp) {
tools = { ...baseTools, ...mcp.tools };
mcpCleanup = mcp.cleanup;
}
}
tools = applyGlobalToolLoopGuard(tools);
const toolNames = Object.keys(tools);
// Build system prompt
const systemPrompt = await buildSystemPrompt({
projectId: options.projectId,
chatId: options.chatId,
agentNumber: options.agentNumber,
tools: toolNames,
});
// Append user message to history
const messages: ModelMessage[] = [
...context.history,
{ role: "user", content: options.userMessage },
];
logLLMRequest({
model: `${settings.chatModel.provider}/${settings.chatModel.model}`,
system: systemPrompt,
messages,
toolNames,
temperature: settings.chatModel.temperature,
maxTokens: settings.chatModel.maxTokens,
label: "LLM Request (stream)",
});
// Run the agent with streaming
const result = streamText({
model,
system: systemPrompt,
messages,
tools,
stopWhen: stepCountIs(15), // Allow up to 15 tool call rounds
temperature: settings.chatModel.temperature ?? 0.7,
maxOutputTokens: settings.chatModel.maxTokens ?? 4096,
onFinish: async (event) => {
if (mcpCleanup) {
try {
await mcpCleanup();
} catch {
// non-critical
}
}
// Save to chat history (including tool calls and results)
try {
const chat = await getChat(options.chatId);
if (chat) {
const now = new Date().toISOString();
// Add user message
chat.messages.push({
id: crypto.randomUUID(),
role: "user",
content: options.userMessage,
createdAt: now,
});
// Add all response messages (assistant + tool calls + tool results)
const responseMessages = event.response.messages;
for (const msg of responseMessages) {
chat.messages.push(...convertModelMessageToChatMessages(msg, now));
}
chat.updatedAt = now;
// Auto-title from first user message (count user messages, not total)
const userMessageCount = chat.messages.filter(m => m.role === "user").length;
if (userMessageCount === 1 && chat.title === "New Chat") {
chat.title =
options.userMessage.slice(0, 60) +
(options.userMessage.length > 60 ? "..." : "");
}
await saveChat(chat);
}
} catch {
// Non-critical, don't fail the response
}
publishUiSyncEvent({
topic: "files",
projectId: options.projectId ?? null,
reason: "agent_turn_finished",
});
},
});
return result;
}
/**
* Non-streaming agent turn for background tasks (cron/scheduler).
*/
export async function runAgentText(options: {
chatId: string;
userMessage: string;
projectId?: string;
currentPath?: string;
agentNumber?: number;
runtimeData?: Record<string, unknown>;
}): Promise<string> {
const settings = await getSettings();
const model = createModel(settings.chatModel);
const context: AgentContext = {
chatId: options.chatId,
projectId: options.projectId,
currentPath: options.currentPath,
memorySubdir: options.projectId ? `${options.projectId}` : "main",
knowledgeSubdirs: options.projectId ? [`${options.projectId}`, "main"] : ["main"],
history: [],
agentNumber: options.agentNumber ?? 0,
data: {
...(options.runtimeData ?? {}),
currentUserMessage: options.userMessage,
},
};
const chat = await getChat(options.chatId);
if (chat) {
context.history = convertChatMessagesToModelMessages(chat.messages);
}
const baseTools = createAgentTools(context, settings);
let mcpCleanup: (() => Promise<void>) | undefined;
let tools = baseTools;
if (options.projectId) {
const mcp = await getProjectMcpTools(options.projectId);
if (mcp) {
tools = { ...baseTools, ...mcp.tools };
mcpCleanup = mcp.cleanup;
}
}
tools = applyGlobalToolLoopGuard(tools);
const toolNames = Object.keys(tools);
const systemPrompt = await buildSystemPrompt({
projectId: options.projectId,
chatId: options.chatId,
agentNumber: options.agentNumber,
tools: toolNames,
});
const messages: ModelMessage[] = [
...context.history,
{ role: "user", content: options.userMessage },
];
logLLMRequest({
model: `${settings.chatModel.provider}/${settings.chatModel.model}`,
system: systemPrompt,
messages,
toolNames,
temperature: settings.chatModel.temperature,
maxTokens: settings.chatModel.maxTokens,
label: "LLM Request (non-stream)",
});
try {
const generated = await generateText({
model,
system: systemPrompt,
messages,
tools,
stopWhen: stepCountIs(15),
temperature: settings.chatModel.temperature ?? 0.7,
maxOutputTokens: settings.chatModel.maxTokens ?? 4096,
});
const text = generated.text ?? "";
try {
const latest = await getChat(options.chatId);
if (latest) {
const now = new Date().toISOString();
latest.messages.push({
id: crypto.randomUUID(),
role: "user",
content: options.userMessage,
createdAt: now,
});
const responseMessages = (
generated as unknown as { response?: { messages?: ModelMessage[] } }
).response?.messages;
if (Array.isArray(responseMessages) && responseMessages.length > 0) {
for (const msg of responseMessages) {
latest.messages.push(...convertModelMessageToChatMessages(msg, now));
}
} else {
latest.messages.push({
id: crypto.randomUUID(),
role: "assistant",
content: text,
createdAt: now,
});
}
latest.updatedAt = now;
await saveChat(latest);
}
} catch {
// Non-critical for background runs.
}
publishUiSyncEvent({
topic: "files",
projectId: options.projectId ?? null,
reason: "agent_turn_finished",
});
return text;
} finally {
if (mcpCleanup) {
try {
await mcpCleanup();
} catch {
// non-critical
}
}
}
}
/**
* Run agent for subordinate delegation (non-streaming, returns result)
*/
export async function runSubordinateAgent(options: {
task: string;
projectId?: string;
parentAgentNumber: number;
parentHistory: ModelMessage[];
}): Promise<string> {
const settings = await getSettings();
const model = createModel(settings.chatModel);
const context: AgentContext = {
chatId: `subordinate-${Date.now()}`,
projectId: options.projectId,
memorySubdir: options.projectId
? `projects/${options.projectId}`
: "main",
knowledgeSubdirs: options.projectId
? [`projects/${options.projectId}`, "main"]
: ["main"],
history: [],
agentNumber: options.parentAgentNumber + 1,
data: {},
};
let tools = createAgentTools(context, settings);
let mcpCleanupSub: (() => Promise<void>) | undefined;
if (options.projectId) {
const mcp = await getProjectMcpTools(options.projectId);
if (mcp) {
tools = { ...tools, ...mcp.tools };
mcpCleanupSub = mcp.cleanup;
}
}
tools = applyGlobalToolLoopGuard(tools);
const toolNames = Object.keys(tools);
const systemPrompt = await buildSystemPrompt({
projectId: options.projectId,
agentNumber: context.agentNumber,
tools: toolNames,
});
// Include relevant parent history for context
const relevantHistory = options.parentHistory.slice(-6);
const messages: ModelMessage[] = [
...relevantHistory,
{
role: "user",
content: `You are a subordinate agent. Complete this task and report back:\n\n${options.task}`,
},
];
logLLMRequest({
model: `${settings.chatModel.provider}/${settings.chatModel.model}`,
system: systemPrompt,
messages,
toolNames,
temperature: settings.chatModel.temperature,
maxTokens: settings.chatModel.maxTokens,
label: "LLM Request (subordinate)",
});
try {
const { text } = await generateText({
model,
system: systemPrompt,
messages,
tools,
stopWhen: stepCountIs(10),
temperature: settings.chatModel.temperature ?? 0.7,
maxOutputTokens: settings.chatModel.maxTokens ?? 4096,
});
return text;
} finally {
if (mcpCleanupSub) {
try {
await mcpCleanupSub();
} catch {
// non-critical
}
}
}
}

65
src/lib/agent/history.ts Normal file
View File

@@ -0,0 +1,65 @@
import type { ModelMessage } from "ai";
/**
* Manage conversation history with size limits
*/
export class History {
private messages: ModelMessage[] = [];
private maxMessages: number;
constructor(maxMessages: number = 100) {
this.maxMessages = maxMessages;
}
add(message: ModelMessage): void {
this.messages.push(message);
this.trim();
}
addMany(messages: ModelMessage[]): void {
this.messages.push(...messages);
this.trim();
}
getAll(): ModelMessage[] {
return [...this.messages];
}
getLast(n: number): ModelMessage[] {
return this.messages.slice(-n);
}
clear(): void {
this.messages = [];
}
get length(): number {
return this.messages.length;
}
private trim(): void {
if (this.messages.length > this.maxMessages) {
// Keep system messages and trim from the beginning
const systemMessages = this.messages.filter(
(m) => m.role === "system"
);
const nonSystemMessages = this.messages.filter(
(m) => m.role !== "system"
);
const trimmed = nonSystemMessages.slice(
nonSystemMessages.length - this.maxMessages + systemMessages.length
);
this.messages = [...systemMessages, ...trimmed];
}
}
toJSON(): ModelMessage[] {
return this.getAll();
}
static fromJSON(messages: ModelMessage[], maxMessages?: number): History {
const history = new History(maxMessages);
history.messages = messages;
return history;
}
}

247
src/lib/agent/prompts.ts Normal file
View File

@@ -0,0 +1,247 @@
import fs from "fs/promises";
import path from "path";
import {
getProject,
loadProjectSkillsMetadata,
getProjectFiles,
getWorkDir,
} from "@/lib/storage/project-store";
import { getChatFiles } from "@/lib/storage/chat-files-store";
const PROMPTS_DIR = path.join(process.cwd(), "src", "prompts");
function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* Load a prompt template from the prompts directory
*/
async function loadPrompt(name: string): Promise<string> {
try {
const filePath = path.join(PROMPTS_DIR, `${name}.md`);
return await fs.readFile(filePath, "utf-8");
} catch {
return "";
}
}
/**
* Recursively get all files from a directory with full paths
*/
async function getAllProjectFilesRecursive(
projectId: string,
subPath: string = ""
): Promise<{ name: string; path: string; size: number }[]> {
const baseDir = getWorkDir(projectId);
const files = await getProjectFiles(projectId, subPath);
const result: { name: string; path: string; size: number }[] = [];
for (const file of files) {
const relativePath = subPath ? `${subPath}/${file.name}` : file.name;
const fullPath = path.join(baseDir, relativePath);
if (file.type === "file") {
result.push({
name: file.name,
path: fullPath,
size: file.size,
});
} else if (file.type === "directory") {
// Recursively get files from subdirectories
const subFiles = await getAllProjectFilesRecursive(projectId, relativePath);
result.push(...subFiles);
}
}
return result;
}
/**
* Build the complete system prompt for the agent
*/
export async function buildSystemPrompt(options: {
projectId?: string;
chatId?: string;
agentNumber?: number;
tools?: string[];
}): Promise<string> {
const parts: string[] = [];
// 1. Base system prompt
const basePrompt = await loadPrompt("system");
if (basePrompt) {
parts.push(basePrompt);
} else {
parts.push(getDefaultSystemPrompt());
}
// 2. Agent identity
const agentNum = options.agentNumber ?? 0;
parts.push(
`\n## Agent Identity\nYou are AI Agent` +
(agentNum === 0
? "You are the primary agent communicating directly with the user."
: `You are a subordinate agent (level ${agentNum}), delegated a task by Agent ${agentNum - 1}.`)
);
// 3. Tool prompts
if (options.tools && options.tools.length > 0) {
const mcpToolNames = options.tools.filter((t) => t.startsWith("mcp_"));
for (const toolName of options.tools) {
const toolPrompt = await loadPrompt(`tool-${toolName}`);
if (toolPrompt) {
parts.push(`\n## Tool: ${toolName}\n${toolPrompt}`);
}
}
if (mcpToolNames.length > 0) {
parts.push(
`\n## MCP (Model Context Protocol) tools\n` +
`This project has ${mcpToolNames.length} tool(s) from connected MCP servers. ` +
`Tool names are prefixed with \`mcp_<server>_<tool>\`. Use them when the task matches their description.\n\n` +
`MCP execution rules:\n` +
`- After an error, do not repeat the same MCP tool call with identical arguments.\n` +
`- Read error details and change the payload before retrying.\n` +
`- For n8n workflow updates, use a real workflow id from a successful tool response; never guess ids.`
);
}
parts.push(
`\n## Tool Loop Safety\n` +
`- After a failed tool call, do not repeat the same tool with identical arguments.\n` +
`- Use the tool's error details to change parameters before retrying.\n` +
`- For skill tools (load_skill/load_skill_resource/create_skill/update_skill/delete_skill/write_skill_file), use exact skill names and valid paths.\n` +
`- If two corrected attempts still fail, report the blocker to the user instead of retrying endlessly.`
);
}
// 4. Project instructions and Skills
if (options.projectId) {
const project = await getProject(options.projectId);
if (project) {
parts.push(
`\n## Active Project: ${project.name}\n` +
`Description: ${project.description}\n` +
(project.instructions
? `\n### Project Instructions\n${project.instructions}`
: "")
);
// 4b. Project Skills — metadata only at startup; full instructions via load_skill tool (integrate-skills)
const skillsMeta = await loadProjectSkillsMetadata(options.projectId);
if (skillsMeta.length > 0) {
parts.push(
`\n## Project Skills (available)\n` +
`This project has ${skillsMeta.length} skill(s). Match the user's task to a skill by description. When a task matches a skill, call the **load_skill** tool with that skill's name to load its full instructions, then follow them. Use only skills that apply.\n` +
`<available_skills>\n` +
skillsMeta
.map(
(s) =>
` <skill>\n <name>${escapeXml(s.name)}</name>\n <description>${escapeXml(s.description)}</description>\n </skill>`
)
.join("\n") +
`\n</available_skills>`
);
}
}
}
// 5. Available Files (Project Directory + Chat Uploaded)
if (options.projectId || options.chatId) {
const filesSections: string[] = [];
// 5a. Project directory files
if (options.projectId) {
try {
const projectFiles = await getAllProjectFilesRecursive(options.projectId);
if (projectFiles.length > 0) {
const rows = projectFiles
.slice(0, 50) // Limit to 50 files to avoid huge prompts
.map((f) => `| ${f.name} | ${f.path} | ${formatFileSize(f.size)} |`)
.join("\n");
filesSections.push(
`### Project Directory Files\n` +
`| File | Path | Size |\n|------|------|------|\n${rows}` +
(projectFiles.length > 50 ? `\n\n*...and ${projectFiles.length - 50} more files*` : "")
);
}
} catch {
// Ignore errors when getting project files
}
}
// 5b. Chat uploaded files
if (options.chatId) {
try {
const chatFiles = await getChatFiles(options.chatId);
if (chatFiles.length > 0) {
const rows = chatFiles
.map((f) => `| ${f.name} | ${f.path} | ${formatFileSize(f.size)} |`)
.join("\n");
filesSections.push(
`### Chat Uploaded Files\n` +
`| File | Path | Size |\n|------|------|------|\n${rows}`
);
}
} catch {
// Ignore errors when getting chat files
}
}
if (filesSections.length > 0) {
parts.push(
`\n## Available Files\n` +
`These files are available in this context. You can read them using the code_execution tool.\n\n` +
filesSections.join("\n\n")
);
}
}
// 6. Current date/time
parts.push(
`\n## Current Information\n- Date/Time: ${new Date().toISOString()}\n- Timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}`
);
return parts.join("\n\n");
}
/**
* Format file size in human-readable format
*/
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getDefaultSystemPrompt(): string {
return `# Eggent Agent
You are a helpful AI assistant with access to tools that allow you to:
- Execute code (Python, Node.js, Shell commands)
- Save and retrieve information from persistent memory
- Search the internet for current information
- Query a knowledge base of documents
- Delegate complex subtasks to subordinate agents
## Guidelines
1. **Be helpful and direct.** Answer the user's question or complete their task.
2. **Use tools when needed.** If a task requires running code, searching, or remembering information, use the appropriate tool.
3. **Think step by step.** For complex tasks, break them down and use tools iteratively.
4. **Memory management.** Save important facts, preferences, and solutions to memory for future reference.
5. **Code execution.** When writing code, prefer Python for data processing and Node.js for web tasks. Always handle errors.
6. **Respond clearly.** Use markdown formatting for readability. Include code blocks with language tags.
## Important Rules
- Always use the response tool to provide your final answer to the user.
- If you need to execute code, use the code_execution tool.
- If the user asks you to remember something, save it to memory.
- If you need current information, use the search tool.
- Never make up information. If you don't know something, say so or search for it.`;
}

32
src/lib/agent/types.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { ModelMessage } from "ai";
export interface AgentContext {
chatId: string;
projectId?: string;
currentPath?: string; // relative path within the project for cwd
memorySubdir: string;
knowledgeSubdirs: string[];
history: ModelMessage[];
agentNumber: number;
parentContext?: AgentContext;
data: Record<string, unknown>;
}
export interface AgentLoopResult {
response: string;
toolCalls: AgentToolCallRecord[];
}
export interface AgentToolCallRecord {
toolName: string;
args: Record<string, unknown>;
result: string;
timestamp: string;
}
export interface StreamCallbacks {
onTextDelta?: (delta: string) => void;
onToolCall?: (toolName: string, args: Record<string, unknown>) => void;
onToolResult?: (toolName: string, result: string) => void;
onFinish?: (result: AgentLoopResult) => void;
}

76
src/lib/auth/password.ts Normal file
View File

@@ -0,0 +1,76 @@
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
const SCRYPT_KEY_LEN = 64;
const DEFAULT_SALT = "XLqs3H3hyIdkLImyxg8Trg";
const DEFAULT_HASH =
"EJ6r0rW1FjbcfamS-XMzbzxQJklry49niFbCBDbldZZ8oo7oOTTwZyz8cFWLfb18lml72SPyA5KgLxdNm7tKPg";
export const DEFAULT_AUTH_USERNAME = "admin";
export const DEFAULT_AUTH_PASSWORD = "admin";
export const DEFAULT_AUTH_PASSWORD_HASH = `scrypt$${DEFAULT_SALT}$${DEFAULT_HASH}`;
export function isDefaultAuthCredentials(
username: string,
passwordHash: string
): boolean {
return (
username.trim() === DEFAULT_AUTH_USERNAME &&
passwordHash.trim() === DEFAULT_AUTH_PASSWORD_HASH
);
}
function encodeBase64Url(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function decodeBase64Url(input: string): Uint8Array | null {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
try {
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
} catch {
return null;
}
}
export function hashPassword(password: string): string {
const trimmed = password.trim();
if (!trimmed) {
throw new Error("Password is required");
}
const salt = encodeBase64Url(randomBytes(16));
const derived = scryptSync(trimmed, salt, SCRYPT_KEY_LEN);
return `scrypt$${salt}$${encodeBase64Url(derived)}`;
}
export function verifyPassword(password: string, storedHash: string): boolean {
const parts = storedHash.split("$");
if (parts.length !== 3 || parts[0] !== "scrypt") {
return false;
}
const salt = parts[1];
const expected = decodeBase64Url(parts[2]);
if (!expected) {
return false;
}
try {
const actual = scryptSync(password, salt, expected.length);
if (actual.length !== expected.length) {
return false;
}
return timingSafeEqual(actual, expected);
} catch {
return false;
}
}

203
src/lib/auth/session.ts Normal file
View File

@@ -0,0 +1,203 @@
export const AUTH_COOKIE_NAME = "eggent_auth";
export const AUTH_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
export interface AuthSessionPayload {
username: string;
issuedAt: number;
expiresAt: number;
mustChangeCredentials: boolean;
}
interface AuthSessionWirePayload {
u: string;
iat: number;
exp: number;
mcc?: 1;
}
function parseBooleanEnv(value: string | undefined): boolean | null {
if (!value) return null;
const normalized = value.trim().toLowerCase();
if (normalized === "1" || normalized === "true" || normalized === "yes") {
return true;
}
if (normalized === "0" || normalized === "false" || normalized === "no") {
return false;
}
return null;
}
function getSessionSecret(): string {
return (
process.env.EGGENT_AUTH_SECRET?.trim() ||
"eggent-default-auth-secret-change-me"
);
}
function utf8ToBytes(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
function bytesToUtf8(value: Uint8Array): string {
return new TextDecoder().decode(value);
}
function encodeBase64Url(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function decodeBase64Url(input: string): Uint8Array | null {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
try {
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
} catch {
return null;
}
}
async function importHmacKey(secret: string): Promise<CryptoKey> {
const keyBytes = Uint8Array.from(utf8ToBytes(secret));
return crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"]
);
}
async function signPayload(payloadB64: string): Promise<string> {
const key = await importHmacKey(getSessionSecret());
const data = Uint8Array.from(utf8ToBytes(payloadB64));
const signature = await crypto.subtle.sign("HMAC", key, data);
return encodeBase64Url(new Uint8Array(signature));
}
function safeEqual(left: string, right: string): boolean {
const max = Math.max(left.length, right.length);
let diff = left.length ^ right.length;
for (let i = 0; i < max; i += 1) {
const l = left.charCodeAt(i) || 0;
const r = right.charCodeAt(i) || 0;
diff |= l ^ r;
}
return diff === 0;
}
export async function createSessionToken(
username: string,
mustChangeCredentials: boolean
): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const payload: AuthSessionWirePayload = {
u: username,
iat: now,
exp: now + AUTH_SESSION_TTL_SECONDS,
...(mustChangeCredentials ? { mcc: 1 } : {}),
};
const payloadB64 = encodeBase64Url(utf8ToBytes(JSON.stringify(payload)));
const sigB64 = await signPayload(payloadB64);
return `${payloadB64}.${sigB64}`;
}
export async function verifySessionToken(
token: string
): Promise<AuthSessionPayload | null> {
const [payloadB64, signatureB64] = token.split(".", 2);
if (!payloadB64 || !signatureB64) {
return null;
}
const expectedSignature = await signPayload(payloadB64);
if (!safeEqual(signatureB64, expectedSignature)) {
return null;
}
const payloadBytes = decodeBase64Url(payloadB64);
if (!payloadBytes) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(bytesToUtf8(payloadBytes));
} catch {
return null;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const wire = parsed as Partial<AuthSessionWirePayload>;
if (
typeof wire.u !== "string" ||
!wire.u.trim() ||
typeof wire.iat !== "number" ||
typeof wire.exp !== "number"
) {
return null;
}
const now = Math.floor(Date.now() / 1000);
if (wire.exp <= now) {
return null;
}
return {
username: wire.u,
issuedAt: wire.iat,
expiresAt: wire.exp,
mustChangeCredentials: wire.mcc === 1,
};
}
export function getSessionCookieOptions() {
return getSessionCookieOptionsForRequest(false);
}
export function isRequestSecure(url: string, headers: Headers): boolean {
const forwardedProto = headers
.get("x-forwarded-proto")
?.split(",")[0]
?.trim()
.toLowerCase();
if (forwardedProto) {
return forwardedProto === "https";
}
try {
return new URL(url).protocol === "https:";
} catch {
return false;
}
}
export function getSessionCookieOptionsForRequest(requestSecure: boolean) {
const secureOverride = parseBooleanEnv(process.env.EGGENT_AUTH_COOKIE_SECURE);
return {
httpOnly: true,
sameSite: "lax" as const,
secure: secureOverride ?? requestSecure,
path: "/",
maxAge: AUTH_SESSION_TTL_SECONDS,
};
}
export function getClearedSessionCookieOptions(requestSecure = false) {
return {
...getSessionCookieOptionsForRequest(requestSecure),
maxAge: 0,
};
}

31
src/lib/cron/parse.ts Normal file
View File

@@ -0,0 +1,31 @@
const ISO_TZ_RE = /(Z|[+-]\d{2}:?\d{2})$/i;
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T/;
function normalizeUtcIso(raw: string): string {
if (ISO_TZ_RE.test(raw)) {
return raw;
}
if (ISO_DATE_RE.test(raw)) {
return `${raw}T00:00:00Z`;
}
if (ISO_DATE_TIME_RE.test(raw)) {
return `${raw}Z`;
}
return raw;
}
export function parseAbsoluteTimeMs(input: string): number | null {
const raw = input.trim();
if (!raw) {
return null;
}
if (/^\d+$/.test(raw)) {
const n = Number(raw);
if (Number.isFinite(n) && n > 0) {
return Math.floor(n);
}
}
const parsed = Date.parse(normalizeUtcIso(raw));
return Number.isFinite(parsed) ? parsed : null;
}

26
src/lib/cron/paths.ts Normal file
View File

@@ -0,0 +1,26 @@
import path from "path";
const DATA_DIR = path.join(process.cwd(), "data");
const PROJECTS_DIR = path.join(DATA_DIR, "projects");
export const GLOBAL_CRON_PROJECT_ID = "none";
export function resolveCronProjectDir(projectId: string): string {
const normalized = projectId.trim();
if (!normalized || normalized === GLOBAL_CRON_PROJECT_ID) {
return path.join(DATA_DIR, "cron", "main");
}
return path.join(PROJECTS_DIR, normalized, ".meta", "cron");
}
export function resolveCronStorePath(projectId: string): string {
return path.join(resolveCronProjectDir(projectId), "jobs.json");
}
export function resolveCronRunsDir(projectId: string): string {
return path.join(resolveCronProjectDir(projectId), "runs");
}
export function resolveCronRunLogPath(projectId: string, jobId: string): string {
return path.join(resolveCronRunsDir(projectId), `${jobId}.jsonl`);
}

79
src/lib/cron/run-log.ts Normal file
View File

@@ -0,0 +1,79 @@
import fs from "fs/promises";
import path from "path";
import type { CronRunLogEntry } from "@/lib/cron/types";
const writesByPath = new Map<string, Promise<void>>();
async function pruneIfNeeded(filePath: string, maxBytes: number, keepLines: number) {
const stat = await fs.stat(filePath).catch(() => null);
if (!stat || stat.size <= maxBytes) {
return;
}
const raw = await fs.readFile(filePath, "utf-8").catch(() => "");
const lines = raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const kept = lines.slice(Math.max(0, lines.length - keepLines));
const tmp = `${filePath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
await fs.writeFile(tmp, `${kept.join("\n")}\n`, "utf-8");
await fs.rename(tmp, filePath);
}
export async function appendCronRunLog(
filePath: string,
entry: CronRunLogEntry,
opts?: { maxBytes?: number; keepLines?: number }
): Promise<void> {
const resolved = path.resolve(filePath);
const previous = writesByPath.get(resolved) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
.then(async () => {
await fs.mkdir(path.dirname(resolved), { recursive: true });
await fs.appendFile(resolved, `${JSON.stringify(entry)}\n`, "utf-8");
await pruneIfNeeded(
resolved,
opts?.maxBytes ?? 1_000_000,
opts?.keepLines ?? 1_000
);
});
writesByPath.set(resolved, next);
await next;
}
export async function readCronRunLogEntries(
filePath: string,
opts?: { limit?: number }
): Promise<CronRunLogEntry[]> {
const limit = Math.max(1, Math.min(5000, Math.floor(opts?.limit ?? 200)));
const raw = await fs.readFile(path.resolve(filePath), "utf-8").catch(() => "");
if (!raw.trim()) {
return [];
}
const parsed: CronRunLogEntry[] = [];
const lines = raw.split("\n");
for (let i = lines.length - 1; i >= 0 && parsed.length < limit; i -= 1) {
const line = lines[i]?.trim();
if (!line) {
continue;
}
try {
const value = JSON.parse(line) as Partial<CronRunLogEntry>;
if (
typeof value.ts === "number" &&
typeof value.jobId === "string" &&
typeof value.projectId === "string" &&
(value.status === "ok" || value.status === "error" || value.status === "skipped")
) {
parsed.push(value as CronRunLogEntry);
}
} catch {
// Skip malformed lines.
}
}
return parsed.reverse();
}

13
src/lib/cron/runtime.ts Normal file
View File

@@ -0,0 +1,13 @@
import { CronScheduler } from "@/lib/cron/service";
declare global {
// eslint-disable-next-line no-var
var __eggentCronScheduler__: CronScheduler | undefined;
}
export async function ensureCronSchedulerStarted(): Promise<void> {
if (!globalThis.__eggentCronScheduler__) {
globalThis.__eggentCronScheduler__ = new CronScheduler();
}
globalThis.__eggentCronScheduler__.start();
}

248
src/lib/cron/schedule.ts Normal file
View File

@@ -0,0 +1,248 @@
import { parseAbsoluteTimeMs } from "@/lib/cron/parse";
import type { CronSchedule } from "@/lib/cron/types";
const MINUTE_MS = 60_000;
const MAX_CRON_LOOKAHEAD_MINUTES = 60 * 24 * 366 * 2; // 2 years
type ZonedDateParts = {
minute: number;
hour: number;
dayOfMonth: number;
month: number;
dayOfWeek: number;
};
type CronMatcher = {
matches: (parts: ZonedDateParts) => boolean;
};
function resolveCronTimezone(tz?: string): string {
const trimmed = typeof tz === "string" ? tz.trim() : "";
if (trimmed) {
return trimmed;
}
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
function parseNumberToken(token: string, min: number, max: number): number | null {
if (!/^\d+$/.test(token)) {
return null;
}
const value = Number(token);
if (!Number.isInteger(value) || value < min || value > max) {
return null;
}
return value;
}
function expandRange(
token: string,
min: number,
max: number,
mapDow7To0: boolean,
): number[] | null {
const [leftRaw, rightRaw] = token.split("-");
if (!leftRaw || !rightRaw) {
return null;
}
const left = parseNumberToken(leftRaw, min, max);
const right = parseNumberToken(rightRaw, min, max);
if (left === null || right === null || left > right) {
return null;
}
const out: number[] = [];
for (let value = left; value <= right; value += 1) {
out.push(mapDow7To0 && value === 7 ? 0 : value);
}
return out;
}
function parseCronField(
field: string,
min: number,
max: number,
mapDow7To0: boolean,
): Set<number> | null {
const trimmed = field.trim();
if (!trimmed) {
return null;
}
const values = new Set<number>();
const parts = trimmed.split(",");
for (const part of parts) {
const token = part.trim();
if (!token) {
return null;
}
const [baseRaw, stepRaw] = token.split("/");
const hasStep = stepRaw !== undefined;
const step = hasStep ? parseNumberToken(stepRaw, 1, max - min + 1) : 1;
if (step === null) {
return null;
}
let baseValues: number[] = [];
if (baseRaw === "*") {
for (let v = min; v <= max; v += 1) {
baseValues.push(mapDow7To0 && v === 7 ? 0 : v);
}
} else if (baseRaw.includes("-")) {
const expanded = expandRange(baseRaw, min, max, mapDow7To0);
if (!expanded) {
return null;
}
baseValues = expanded;
} else {
const single = parseNumberToken(baseRaw, min, max);
if (single === null) {
return null;
}
baseValues = [mapDow7To0 && single === 7 ? 0 : single];
}
if (hasStep) {
const sorted = [...new Set(baseValues)].sort((a, b) => a - b);
if (sorted.length === 0) {
return null;
}
const start = sorted[0];
for (const value of sorted) {
if ((value - start) % step === 0) {
values.add(value);
}
}
continue;
}
for (const value of baseValues) {
values.add(value);
}
}
return values.size > 0 ? values : null;
}
function parseCronExpr(expr: string): CronMatcher | null {
const fields = expr.trim().split(/\s+/).filter(Boolean);
if (fields.length !== 5) {
return null;
}
const minute = parseCronField(fields[0], 0, 59, false);
const hour = parseCronField(fields[1], 0, 23, false);
const dayOfMonth = parseCronField(fields[2], 1, 31, false);
const month = parseCronField(fields[3], 1, 12, false);
const dayOfWeek = parseCronField(fields[4], 0, 7, true);
if (!minute || !hour || !dayOfMonth || !month || !dayOfWeek) {
return null;
}
return {
matches(parts: ZonedDateParts) {
return (
minute.has(parts.minute) &&
hour.has(parts.hour) &&
dayOfMonth.has(parts.dayOfMonth) &&
month.has(parts.month) &&
dayOfWeek.has(parts.dayOfWeek)
);
},
};
}
function getZonedDateParts(ms: number, tz: string): ZonedDateParts {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
hour12: false,
minute: "2-digit",
hour: "2-digit",
day: "2-digit",
month: "2-digit",
weekday: "short",
});
const parts = formatter.formatToParts(new Date(ms));
let minute = 0;
let hour = 0;
let dayOfMonth = 0;
let month = 0;
let weekdayRaw = "";
for (const part of parts) {
if (part.type === "minute") minute = Number(part.value);
if (part.type === "hour") hour = Number(part.value);
if (part.type === "day") dayOfMonth = Number(part.value);
if (part.type === "month") month = Number(part.value);
if (part.type === "weekday") weekdayRaw = part.value.toLowerCase();
}
const dayOfWeekMap: Record<string, number> = {
sun: 0,
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6,
};
return {
minute,
hour,
dayOfMonth,
month,
dayOfWeek: dayOfWeekMap[weekdayRaw.slice(0, 3)] ?? 0,
};
}
function computeNextCronRunAtMs(expr: string, nowMs: number, tz?: string): number | undefined {
const matcher = parseCronExpr(expr);
if (!matcher) {
return undefined;
}
const timezone = resolveCronTimezone(tz);
let cursor = Math.floor(nowMs / MINUTE_MS) * MINUTE_MS + MINUTE_MS;
for (let i = 0; i < MAX_CRON_LOOKAHEAD_MINUTES; i += 1) {
const parts = getZonedDateParts(cursor, timezone);
if (matcher.matches(parts)) {
return cursor;
}
cursor += MINUTE_MS;
}
return undefined;
}
export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined {
if (schedule.kind === "at") {
const atMs = parseAbsoluteTimeMs(schedule.at);
if (atMs === null) {
return undefined;
}
return atMs > nowMs ? atMs : undefined;
}
if (schedule.kind === "every") {
const everyMs = Math.max(1, Math.floor(schedule.everyMs));
const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs));
if (nowMs < anchor) {
return anchor;
}
const elapsed = nowMs - anchor;
const steps = Math.max(1, Math.floor((elapsed + everyMs - 1) / everyMs));
return anchor + steps * everyMs;
}
const expr = schedule.expr.trim();
if (!expr) {
return undefined;
}
return computeNextCronRunAtMs(expr, nowMs, schedule.tz);
}
export function validateCronExpression(expr: string): string | null {
if (!expr.trim()) {
return "Cron expression is required.";
}
const matcher = parseCronExpr(expr);
if (!matcher) {
return "Cron expression must contain 5 fields and only use numbers, '*', ranges, lists, or steps.";
}
return null;
}

925
src/lib/cron/service.ts Normal file
View File

@@ -0,0 +1,925 @@
import { createChat, getChat } from "@/lib/storage/chat-store";
import { getAllProjects, getProject } from "@/lib/storage/project-store";
import { getTelegramIntegrationRuntimeConfig } from "@/lib/storage/telegram-integration-store";
import { runAgentText } from "@/lib/agent/agent";
import { parseAbsoluteTimeMs } from "@/lib/cron/parse";
import { resolveCronRunLogPath, resolveCronStorePath, GLOBAL_CRON_PROJECT_ID } from "@/lib/cron/paths";
import { appendCronRunLog, readCronRunLogEntries } from "@/lib/cron/run-log";
import { computeNextRunAtMs, validateCronExpression } from "@/lib/cron/schedule";
import { loadCronStore, saveCronStore, withCronStoreLock } from "@/lib/cron/store";
import type {
CronJob,
CronJobCreate,
CronJobPatch,
CronRunLogEntry,
CronRunStatus,
CronSchedule,
CronStoreFile,
} from "@/lib/cron/types";
const STUCK_RUN_MS = 2 * 60 * 60 * 1000;
const MAX_TIMER_DELAY_MS = 60_000;
const MIN_REFIRE_GAP_MS = 2_000;
const DEFAULT_JOB_TIMEOUT_MS = 10 * 60_000;
const ERROR_BACKOFF_SCHEDULE_MS = [30_000, 60_000, 5 * 60_000, 15 * 60_000, 60 * 60_000];
const TELEGRAM_TEXT_LIMIT = 4096;
type RunResult = {
status: CronRunStatus;
error?: string;
summary?: string;
startedAt: number;
endedAt: number;
};
type ClaimedCronJob = {
projectId: string;
job: CronJob;
};
type ProjectStatus = {
projectId: string;
jobs: number;
nextWakeAtMs: number | null;
};
function normalizeProjectId(projectId: string): string {
const trimmed = projectId.trim();
return trimmed || GLOBAL_CRON_PROJECT_ID;
}
function throwIfInvalidProjectId(projectId: string): void {
if (projectId === GLOBAL_CRON_PROJECT_ID) {
return;
}
if (!/^[a-z0-9][a-z0-9-]{0,127}$/.test(projectId)) {
throw new Error("Invalid project id.");
}
}
async function assertProjectExists(projectId: string): Promise<void> {
if (projectId === GLOBAL_CRON_PROJECT_ID) {
return;
}
const project = await getProject(projectId);
if (!project) {
throw new Error(`Project "${projectId}" not found.`);
}
}
function normalizeName(raw: unknown): string {
if (typeof raw !== "string" || !raw.trim()) {
throw new Error("Cron job name is required.");
}
return raw.trim();
}
function normalizeOptionalText(raw: unknown): string | undefined {
if (typeof raw !== "string") {
return undefined;
}
const trimmed = raw.trim();
return trimmed || undefined;
}
function normalizeTelegramChatId(raw: unknown): string | undefined {
if (typeof raw === "number" && Number.isFinite(raw)) {
return String(Math.trunc(raw));
}
if (typeof raw !== "string") {
return undefined;
}
const trimmed = raw.trim();
return trimmed || undefined;
}
function normalizeSchedule(raw: CronSchedule): CronSchedule {
if (!raw || typeof raw !== "object" || typeof raw.kind !== "string") {
throw new Error("schedule is required.");
}
if (raw.kind === "at") {
const at = typeof raw.at === "string" ? raw.at.trim() : "";
if (!at) {
throw new Error('schedule.at is required for schedule.kind="at".');
}
const atMs = parseAbsoluteTimeMs(at);
if (atMs === null || !Number.isFinite(atMs)) {
throw new Error("schedule.at must be a valid timestamp.");
}
return { kind: "at", at: new Date(atMs).toISOString() };
}
if (raw.kind === "every") {
const everyMs = Math.floor(Number(raw.everyMs));
if (!Number.isFinite(everyMs) || everyMs <= 0) {
throw new Error("schedule.everyMs must be a positive integer.");
}
const anchorMs =
typeof raw.anchorMs === "number" && Number.isFinite(raw.anchorMs)
? Math.max(0, Math.floor(raw.anchorMs))
: undefined;
return { kind: "every", everyMs, anchorMs };
}
if (raw.kind === "cron") {
const expr = typeof raw.expr === "string" ? raw.expr.trim() : "";
if (!expr) {
throw new Error("schedule.expr is required for cron schedule.");
}
const cronError = validateCronExpression(expr);
if (cronError) {
throw new Error(cronError);
}
const tz = typeof raw.tz === "string" && raw.tz.trim() ? raw.tz.trim() : undefined;
return { kind: "cron", expr, tz };
}
throw new Error('schedule.kind must be one of "at", "every", or "cron".');
}
function normalizePayload(raw: CronJobCreate["payload"]): CronJob["payload"] {
if (!raw || typeof raw !== "object" || raw.kind !== "agentTurn") {
throw new Error('payload.kind must be "agentTurn".');
}
const message = typeof raw.message === "string" ? raw.message.trim() : "";
if (!message) {
throw new Error("payload.message is required.");
}
const chatId = normalizeOptionalText(raw.chatId);
const telegramChatId = normalizeTelegramChatId(raw.telegramChatId);
const currentPath = normalizeOptionalText(raw.currentPath);
const timeoutSecondsRaw = raw.timeoutSeconds;
const timeoutSeconds =
typeof timeoutSecondsRaw === "number" && Number.isFinite(timeoutSecondsRaw)
? Math.max(0, Math.floor(timeoutSecondsRaw))
: undefined;
return {
kind: "agentTurn",
message,
chatId,
telegramChatId,
currentPath,
timeoutSeconds,
};
}
function computeAtRunMs(schedule: Extract<CronSchedule, { kind: "at" }>): number | undefined {
const atMs = parseAbsoluteTimeMs(schedule.at);
if (atMs === null || !Number.isFinite(atMs)) {
return undefined;
}
return atMs;
}
function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | undefined {
if (!job.enabled) {
return undefined;
}
if (job.schedule.kind === "at") {
const atMs = computeAtRunMs(job.schedule);
if (atMs === undefined) {
return undefined;
}
if (
typeof job.state.lastRunAtMs === "number" &&
(job.state.lastStatus === "ok" || job.state.lastStatus === "error" || job.state.lastStatus === "skipped") &&
job.state.lastRunAtMs >= atMs
) {
return undefined;
}
return atMs;
}
if (job.schedule.kind === "every") {
const anchorMs =
typeof job.schedule.anchorMs === "number" && Number.isFinite(job.schedule.anchorMs)
? Math.max(0, Math.floor(job.schedule.anchorMs))
: Math.max(0, Math.floor(job.createdAtMs));
return computeNextRunAtMs({ ...job.schedule, anchorMs }, nowMs);
}
return computeNextRunAtMs(job.schedule, nowMs);
}
function isJobDue(job: CronJob, nowMs: number): boolean {
if (!job.enabled) {
return false;
}
if (typeof job.state.runningAtMs === "number") {
return false;
}
return typeof job.state.nextRunAtMs === "number" && nowMs >= job.state.nextRunAtMs;
}
function getErrorBackoffMs(consecutiveErrors: number): number {
const idx = Math.min(consecutiveErrors - 1, ERROR_BACKOFF_SCHEDULE_MS.length - 1);
return ERROR_BACKOFF_SCHEDULE_MS[Math.max(0, idx)];
}
function applyPatch(job: CronJob, patch: CronJobPatch, nowMs: number): void {
if ("name" in patch && patch.name !== undefined) {
job.name = normalizeName(patch.name);
}
if ("description" in patch) {
job.description = normalizeOptionalText(patch.description);
}
if (typeof patch.enabled === "boolean") {
job.enabled = patch.enabled;
if (!job.enabled) {
job.state.runningAtMs = undefined;
}
}
if (typeof patch.deleteAfterRun === "boolean") {
job.deleteAfterRun = patch.deleteAfterRun;
}
if (patch.schedule) {
const schedule = normalizeSchedule(patch.schedule);
if (schedule.kind === "every" && typeof schedule.anchorMs !== "number") {
schedule.anchorMs = nowMs;
}
job.schedule = schedule;
}
if (patch.payload) {
const payloadPatch = patch.payload;
const message =
typeof payloadPatch.message === "string" ? payloadPatch.message.trim() : job.payload.message;
if (!message) {
throw new Error("payload.message cannot be empty.");
}
job.payload = {
kind: "agentTurn",
message,
chatId:
"chatId" in payloadPatch
? normalizeOptionalText(payloadPatch.chatId)
: job.payload.chatId,
telegramChatId:
"telegramChatId" in payloadPatch
? normalizeTelegramChatId(payloadPatch.telegramChatId)
: normalizeTelegramChatId(job.payload.telegramChatId),
currentPath:
"currentPath" in payloadPatch
? normalizeOptionalText(payloadPatch.currentPath)
: job.payload.currentPath,
timeoutSeconds:
"timeoutSeconds" in payloadPatch
? typeof payloadPatch.timeoutSeconds === "number" &&
Number.isFinite(payloadPatch.timeoutSeconds)
? Math.max(0, Math.floor(payloadPatch.timeoutSeconds))
: undefined
: job.payload.timeoutSeconds,
};
}
job.updatedAtMs = nowMs;
job.state.nextRunAtMs = computeJobNextRunAtMs(job, nowMs);
}
function sanitizeStore(store: CronStoreFile, projectId: string, nowMs: number): boolean {
let changed = false;
if (!Array.isArray(store.jobs)) {
store.jobs = [];
changed = true;
}
const normalizedJobs: CronJob[] = [];
for (const raw of store.jobs) {
if (!raw || typeof raw !== "object") {
changed = true;
continue;
}
const job = raw as CronJob;
if (typeof job.id !== "string" || !job.id.trim()) {
changed = true;
continue;
}
if (
!job.schedule ||
typeof job.schedule !== "object" ||
!("kind" in job.schedule) ||
(job.schedule.kind !== "at" &&
job.schedule.kind !== "every" &&
job.schedule.kind !== "cron")
) {
changed = true;
continue;
}
if (
!job.payload ||
typeof job.payload !== "object" ||
job.payload.kind !== "agentTurn" ||
typeof job.payload.message !== "string"
) {
changed = true;
continue;
}
if (typeof job.name !== "string" || !job.name.trim()) {
job.name = `Cron job ${job.id.slice(0, 8)}`;
changed = true;
}
if (typeof job.createdAtMs !== "number" || !Number.isFinite(job.createdAtMs)) {
job.createdAtMs = nowMs;
changed = true;
}
if (typeof job.updatedAtMs !== "number" || !Number.isFinite(job.updatedAtMs)) {
job.updatedAtMs = nowMs;
changed = true;
}
const normalizedTelegramChatId = normalizeTelegramChatId(job.payload.telegramChatId);
if (job.payload.telegramChatId !== normalizedTelegramChatId) {
job.payload.telegramChatId = normalizedTelegramChatId;
changed = true;
}
if (job.projectId !== projectId) {
job.projectId = projectId;
changed = true;
}
if (typeof job.enabled !== "boolean") {
job.enabled = true;
changed = true;
}
if (!job.state || typeof job.state !== "object") {
job.state = {};
changed = true;
}
if (typeof job.state.runningAtMs === "number" && nowMs - job.state.runningAtMs > STUCK_RUN_MS) {
job.state.runningAtMs = undefined;
changed = true;
}
if (job.schedule.kind === "every") {
if (
typeof job.schedule.anchorMs !== "number" ||
!Number.isFinite(job.schedule.anchorMs)
) {
job.schedule.anchorMs = Math.max(0, Math.floor(job.createdAtMs || nowMs));
changed = true;
}
}
if (!job.enabled) {
if (job.state.nextRunAtMs !== undefined) {
job.state.nextRunAtMs = undefined;
changed = true;
}
} else if (typeof job.state.nextRunAtMs !== "number" || !Number.isFinite(job.state.nextRunAtMs)) {
job.state.nextRunAtMs = computeJobNextRunAtMs(job, nowMs);
changed = true;
}
normalizedJobs.push(job);
}
if (normalizedJobs.length !== store.jobs.length) {
changed = true;
}
store.jobs = normalizedJobs;
return changed;
}
async function withProjectStore<T>(
projectIdRaw: string,
fn: (ctx: { store: CronStoreFile; nowMs: number; storePath: string; markChanged: () => void }) => Promise<T>
): Promise<T> {
const projectId = normalizeProjectId(projectIdRaw);
throwIfInvalidProjectId(projectId);
const storePath = resolveCronStorePath(projectId);
return await withCronStoreLock(storePath, async () => {
const store = await loadCronStore(storePath);
const nowMs = Date.now();
let changed = sanitizeStore(store, projectId, nowMs);
const value = await fn({
store,
nowMs,
storePath,
markChanged: () => {
changed = true;
},
});
if (changed) {
await saveCronStore(storePath, store);
}
return value;
});
}
async function claimDueJobsForProject(projectIdRaw: string, nowMs: number): Promise<ClaimedCronJob[]> {
return await withProjectStore(projectIdRaw, async ({ store, markChanged }) => {
const due: ClaimedCronJob[] = [];
for (const job of store.jobs) {
if (!job.enabled) {
continue;
}
if (typeof job.state.nextRunAtMs !== "number") {
job.state.nextRunAtMs = computeJobNextRunAtMs(job, nowMs);
markChanged();
}
if (!isJobDue(job, nowMs)) {
continue;
}
job.state.runningAtMs = nowMs;
job.state.lastError = undefined;
markChanged();
due.push({
projectId: job.projectId,
job: structuredClone(job),
});
}
return due;
});
}
async function finalizeJobRun(projectIdRaw: string, jobId: string, result: RunResult): Promise<void> {
const projectId = normalizeProjectId(projectIdRaw);
let logEntry: CronRunLogEntry | null = null;
await withProjectStore(projectId, async ({ store, markChanged }) => {
const job = store.jobs.find((item) => item.id === jobId);
if (!job) {
return;
}
job.state.runningAtMs = undefined;
job.state.lastRunAtMs = result.startedAt;
job.state.lastStatus = result.status;
job.state.lastDurationMs = Math.max(0, result.endedAt - result.startedAt);
job.state.lastError = result.error;
job.updatedAtMs = result.endedAt;
if (result.status === "error") {
job.state.consecutiveErrors = (job.state.consecutiveErrors ?? 0) + 1;
} else {
job.state.consecutiveErrors = 0;
}
const shouldDelete =
job.schedule.kind === "at" && job.deleteAfterRun === true && result.status === "ok";
if (shouldDelete) {
store.jobs = store.jobs.filter((item) => item.id !== job.id);
markChanged();
logEntry = {
ts: Date.now(),
projectId,
jobId,
status: result.status,
error: result.error,
summary: result.summary,
runAtMs: result.startedAt,
durationMs: job.state.lastDurationMs,
};
return;
}
if (job.schedule.kind === "at") {
job.enabled = false;
job.state.nextRunAtMs = undefined;
markChanged();
} else if (result.status === "error" && job.enabled) {
const backoffMs = getErrorBackoffMs(job.state.consecutiveErrors ?? 1);
const naturalNext = computeJobNextRunAtMs(job, result.endedAt);
const backoffNext = result.endedAt + backoffMs;
job.state.nextRunAtMs =
naturalNext !== undefined ? Math.max(naturalNext, backoffNext) : backoffNext;
markChanged();
} else if (job.enabled) {
const naturalNext = computeJobNextRunAtMs(job, result.endedAt);
if (job.schedule.kind === "cron") {
const minNext = result.endedAt + MIN_REFIRE_GAP_MS;
job.state.nextRunAtMs =
naturalNext !== undefined ? Math.max(naturalNext, minNext) : minNext;
} else {
job.state.nextRunAtMs = naturalNext;
}
markChanged();
} else {
job.state.nextRunAtMs = undefined;
markChanged();
}
logEntry = {
ts: Date.now(),
projectId,
jobId,
status: result.status,
error: result.error,
summary: result.summary,
runAtMs: result.startedAt,
durationMs: job.state.lastDurationMs,
nextRunAtMs: job.state.nextRunAtMs,
};
});
if (logEntry) {
const logPath = resolveCronRunLogPath(projectId, jobId);
await appendCronRunLog(logPath, logEntry);
}
}
async function executeCronJob(job: CronJob): Promise<RunResult> {
const startedAt = Date.now();
if (job.payload.kind !== "agentTurn") {
return {
status: "skipped",
error: 'Only payload.kind="agentTurn" is supported.',
startedAt,
endedAt: Date.now(),
};
}
const chatId = (job.payload.chatId?.trim() || `cron-${job.id}`);
const projectId = job.projectId === GLOBAL_CRON_PROJECT_ID ? undefined : job.projectId;
const existingChat = await getChat(chatId);
if (!existingChat) {
await createChat(chatId, `Cron: ${job.name}`, projectId);
}
const timeoutMs =
typeof job.payload.timeoutSeconds === "number"
? job.payload.timeoutSeconds <= 0
? undefined
: job.payload.timeoutSeconds * 1_000
: DEFAULT_JOB_TIMEOUT_MS;
const telegramChatId = normalizeTelegramChatId(job.payload.telegramChatId);
let telegramBotToken = "";
if (telegramChatId) {
const telegramRuntime = await getTelegramIntegrationRuntimeConfig();
telegramBotToken = telegramRuntime.botToken.trim();
if (!telegramBotToken) {
return {
status: "error",
error:
"payload.telegramChatId is set, but Telegram bot token is not configured.",
startedAt,
endedAt: Date.now(),
};
}
}
const normalizeOutgoingTelegramText = (text: string): string => {
const value = text.trim();
if (!value) return "Пустой ответ от cron-задачи.";
if (value.length <= TELEGRAM_TEXT_LIMIT) return value;
return `${value.slice(0, TELEGRAM_TEXT_LIMIT - 1)}`;
};
const formatTelegramCronResult = (result: RunResult): string => {
if (result.status === "ok") {
return `Cron "${job.name}" выполнен.\n\n${result.summary ?? "Без текста ответа."}`;
}
if (result.status === "skipped") {
return `Cron "${job.name}" пропущен.\n\n${result.error ?? "Пустой ответ."}`;
}
return `Cron "${job.name}" завершился с ошибкой.\n\n${result.error ?? "Unknown error."}`;
};
const sendTelegramMessage = async (chatIdValue: string, text: string): Promise<void> => {
const response = await fetch(
`https://api.telegram.org/bot${telegramBotToken}/sendMessage`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
chat_id: chatIdValue,
text: normalizeOutgoingTelegramText(text),
}),
}
);
const payload = (await response.json().catch(() => null)) as
| { ok?: boolean; description?: string }
| null;
if (!response.ok || !payload?.ok) {
throw new Error(
`Telegram sendMessage failed (${response.status})${payload?.description ? `: ${payload.description}` : ""}`
);
}
};
const deliverToTelegram = async (result: RunResult): Promise<RunResult> => {
if (!telegramChatId || !telegramBotToken) {
return result;
}
try {
await sendTelegramMessage(telegramChatId, formatTelegramCronResult(result));
return result;
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
const deliveryError = `Telegram delivery failed: ${message}`;
if (result.status === "error") {
return {
...result,
endedAt: Date.now(),
error: result.error ? `${result.error} | ${deliveryError}` : deliveryError,
};
}
return {
...result,
status: "error",
endedAt: Date.now(),
error: deliveryError,
};
}
};
try {
const runPromise = runAgentText({
chatId,
userMessage: job.payload.message,
projectId,
currentPath: job.payload.currentPath,
runtimeData:
telegramChatId && telegramBotToken
? {
telegram: {
botToken: telegramBotToken,
chatId: telegramChatId,
},
}
: undefined,
});
const output =
typeof timeoutMs === "number"
? await Promise.race([
runPromise,
new Promise<string>((_, reject) => {
setTimeout(() => reject(new Error("Cron job execution timed out.")), timeoutMs);
}),
])
: await runPromise;
const summary = output.trim();
return await deliverToTelegram({
status: summary ? "ok" : "skipped",
summary: summary || undefined,
startedAt,
endedAt: Date.now(),
});
} catch (error) {
return await deliverToTelegram({
status: "error",
error: error instanceof Error ? error.message : String(error),
startedAt,
endedAt: Date.now(),
});
}
}
async function executeClaimedJobs(claimed: ClaimedCronJob[]): Promise<void> {
for (const item of claimed) {
const result = await executeCronJob(item.job);
await finalizeJobRun(item.projectId, item.job.id, result);
}
}
export async function listKnownCronProjectIds(): Promise<string[]> {
const projects = await getAllProjects();
const ids = projects.map((project) => project.id).filter(Boolean);
return [GLOBAL_CRON_PROJECT_ID, ...ids];
}
export async function listCronJobs(
projectIdRaw: string,
opts?: { includeDisabled?: boolean }
): Promise<CronJob[]> {
const projectId = normalizeProjectId(projectIdRaw);
await assertProjectExists(projectId);
return await withProjectStore(projectId, async ({ store }) => {
const includeDisabled = opts?.includeDisabled === true;
const jobs = store.jobs.filter((job) => includeDisabled || job.enabled);
jobs.sort(
(a, b) =>
(a.state.nextRunAtMs ?? Number.MAX_SAFE_INTEGER) -
(b.state.nextRunAtMs ?? Number.MAX_SAFE_INTEGER)
);
return jobs.map((job) => structuredClone(job));
});
}
export async function getCronJob(
projectIdRaw: string,
jobId: string
): Promise<CronJob | null> {
const projectId = normalizeProjectId(projectIdRaw);
await assertProjectExists(projectId);
return await withProjectStore(projectId, async ({ store }) => {
const job = store.jobs.find((item) => item.id === jobId);
return job ? structuredClone(job) : null;
});
}
export async function getCronProjectStatus(projectIdRaw: string): Promise<ProjectStatus> {
const projectId = normalizeProjectId(projectIdRaw);
await assertProjectExists(projectId);
return await withProjectStore(projectId, async ({ store }) => {
let nextWakeAtMs: number | null = null;
for (const job of store.jobs) {
if (!job.enabled || typeof job.state.nextRunAtMs !== "number") {
continue;
}
if (nextWakeAtMs === null || job.state.nextRunAtMs < nextWakeAtMs) {
nextWakeAtMs = job.state.nextRunAtMs;
}
}
return {
projectId,
jobs: store.jobs.length,
nextWakeAtMs,
};
});
}
export async function addCronJob(
projectIdRaw: string,
input: CronJobCreate
): Promise<CronJob> {
const projectId = normalizeProjectId(projectIdRaw);
await assertProjectExists(projectId);
return await withProjectStore(projectId, async ({ store, nowMs, markChanged }) => {
const schedule = normalizeSchedule(input.schedule);
const payload = normalizePayload(input.payload);
const enabled = typeof input.enabled === "boolean" ? input.enabled : true;
const deleteAfterRun =
typeof input.deleteAfterRun === "boolean"
? input.deleteAfterRun
: schedule.kind === "at"
? true
: undefined;
const job: CronJob = {
id: crypto.randomUUID(),
projectId,
name: normalizeName(input.name),
description: normalizeOptionalText(input.description),
enabled,
deleteAfterRun,
createdAtMs: nowMs,
updatedAtMs: nowMs,
schedule:
schedule.kind === "every" && typeof schedule.anchorMs !== "number"
? { ...schedule, anchorMs: nowMs }
: schedule,
payload,
state: {},
};
job.state.nextRunAtMs = computeJobNextRunAtMs(job, nowMs);
store.jobs.push(job);
markChanged();
return structuredClone(job);
});
}
export async function updateCronJob(
projectIdRaw: string,
jobId: string,
patch: CronJobPatch
): Promise<CronJob | null> {
const projectId = normalizeProjectId(projectIdRaw);
await assertProjectExists(projectId);
return await withProjectStore(projectId, async ({ store, nowMs, markChanged }) => {
const job = store.jobs.find((item) => item.id === jobId);
if (!job) {
return null;
}
applyPatch(job, patch, nowMs);
markChanged();
return structuredClone(job);
});
}
export async function removeCronJob(
projectIdRaw: string,
jobId: string
): Promise<{ removed: boolean }> {
const projectId = normalizeProjectId(projectIdRaw);
await assertProjectExists(projectId);
return await withProjectStore(projectId, async ({ store, markChanged }) => {
const before = store.jobs.length;
store.jobs = store.jobs.filter((item) => item.id !== jobId);
const removed = store.jobs.length !== before;
if (removed) {
markChanged();
}
return { removed };
});
}
export async function runCronJobNow(
projectIdRaw: string,
jobId: string
): Promise<{ ran: boolean; reason?: "not-found" | "already-running" }> {
const projectId = normalizeProjectId(projectIdRaw);
await assertProjectExists(projectId);
const claimed = await withProjectStore(projectId, async ({ store, nowMs, markChanged }) => {
const job = store.jobs.find((item) => item.id === jobId);
if (!job) {
return null;
}
if (typeof job.state.runningAtMs === "number") {
return "already-running" as const;
}
job.state.runningAtMs = nowMs;
job.state.lastError = undefined;
markChanged();
return structuredClone(job);
});
if (claimed === null) {
return { ran: false, reason: "not-found" };
}
if (claimed === "already-running") {
return { ran: false, reason: "already-running" };
}
const result = await executeCronJob(claimed);
await finalizeJobRun(projectId, claimed.id, result);
return { ran: true };
}
export async function listCronRuns(
projectIdRaw: string,
jobId: string,
limit?: number
): Promise<CronRunLogEntry[]> {
const projectId = normalizeProjectId(projectIdRaw);
await assertProjectExists(projectId);
const logPath = resolveCronRunLogPath(projectId, jobId);
return await readCronRunLogEntries(logPath, { limit });
}
async function computeNextGlobalWakeAtMs(projectIds: string[]): Promise<number | null> {
let min: number | null = null;
for (const projectId of projectIds) {
const status = await getCronProjectStatus(projectId).catch(() => null);
if (!status || status.nextWakeAtMs === null) {
continue;
}
if (min === null || status.nextWakeAtMs < min) {
min = status.nextWakeAtMs;
}
}
return min;
}
export class CronScheduler {
private timer: NodeJS.Timeout | null = null;
private running = false;
private started = false;
start() {
if (this.started) {
return;
}
this.started = true;
this.arm(200);
}
stop() {
this.started = false;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
private arm(delayMs: number) {
if (!this.started) {
return;
}
if (this.timer) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
void this.tick();
}, Math.max(50, delayMs));
}
private async tick() {
if (!this.started) {
return;
}
if (this.running) {
this.arm(MAX_TIMER_DELAY_MS);
return;
}
this.running = true;
try {
const nowMs = Date.now();
const projectIds = await listKnownCronProjectIds();
const claimed: ClaimedCronJob[] = [];
for (const projectId of projectIds) {
const due = await claimDueJobsForProject(projectId, nowMs).catch(() => []);
claimed.push(...due);
}
if (claimed.length > 0) {
await executeClaimedJobs(claimed);
}
const nextWakeAtMs = await computeNextGlobalWakeAtMs(projectIds);
const delay = nextWakeAtMs === null ? MAX_TIMER_DELAY_MS : Math.max(nextWakeAtMs - Date.now(), 0);
this.arm(Math.min(delay, MAX_TIMER_DELAY_MS));
} catch {
this.arm(MAX_TIMER_DELAY_MS);
} finally {
this.running = false;
}
}
}

54
src/lib/cron/store.ts Normal file
View File

@@ -0,0 +1,54 @@
import fs from "fs/promises";
import path from "path";
import type { CronStoreFile } from "@/lib/cron/types";
const storeLocks = new Map<string, Promise<void>>();
async function resolveChain(promise: Promise<unknown>): Promise<void> {
await promise.then(
() => undefined,
() => undefined
);
}
async function withStoreLock<T>(storePath: string, fn: () => Promise<T>): Promise<T> {
const resolved = path.resolve(storePath);
const previous = storeLocks.get(resolved) ?? Promise.resolve();
const next = resolveChain(previous).then(fn);
storeLocks.set(resolved, resolveChain(next));
return await next;
}
export async function withCronStoreLock<T>(
storePath: string,
fn: () => Promise<T>
): Promise<T> {
return await withStoreLock(storePath, fn);
}
export async function loadCronStore(storePath: string): Promise<CronStoreFile> {
try {
const raw = await fs.readFile(storePath, "utf-8");
const parsed = JSON.parse(raw) as Partial<CronStoreFile>;
const jobs = Array.isArray(parsed.jobs) ? parsed.jobs : [];
return {
version: 1,
jobs: jobs.filter(Boolean),
};
} catch (error) {
if ((error as { code?: string }).code === "ENOENT") {
return { version: 1, jobs: [] };
}
throw error;
}
}
export async function saveCronStore(
storePath: string,
store: CronStoreFile
): Promise<void> {
await fs.mkdir(path.dirname(storePath), { recursive: true });
const tmp = `${storePath}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
await fs.writeFile(tmp, JSON.stringify(store, null, 2), "utf-8");
await fs.rename(tmp, storePath);
}

View File

@@ -0,0 +1,405 @@
import type { CronJobCreate, CronJobPatch, CronSchedule } from "@/lib/cron/types";
type UnknownRecord = Record<string, unknown>;
const CRON_JOB_KEYS: ReadonlySet<string> = new Set([
"name",
"description",
"enabled",
"deleteAfterRun",
"data",
"schedule",
"scheduleKind",
"scheduleAt",
"at",
"everyMs",
"anchorMs",
"expr",
"cronExpr",
"cronTz",
"tz",
"delaySeconds",
"delayMs",
"payload",
"message",
"payloadMessage",
"text",
"chatId",
"telegramChatId",
"telegram_chat_id",
"currentPath",
"timeoutSeconds",
"job",
]);
const CRON_PATCH_KEYS: ReadonlySet<string> = new Set([
"name",
"description",
"enabled",
"deleteAfterRun",
"data",
"schedule",
"payload",
"message",
"text",
"chatId",
"telegramChatId",
"telegram_chat_id",
"currentPath",
"timeoutSeconds",
"patch",
]);
function asRecord(value: unknown): UnknownRecord | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as UnknownRecord;
}
function toRecord(value: unknown): UnknownRecord | null {
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return null;
}
try {
return asRecord(JSON.parse(trimmed));
} catch {
return null;
}
}
return asRecord(value);
}
function readString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function readNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
}
function readBoolean(value: unknown): boolean | undefined {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
const lowered = value.trim().toLowerCase();
if (lowered === "true") return true;
if (lowered === "false") return false;
}
return undefined;
}
function copyKnownFields(input: UnknownRecord, keys: ReadonlySet<string>): UnknownRecord {
const out: UnknownRecord = {};
for (const key of Object.keys(input)) {
if (keys.has(key) && input[key] !== undefined) {
out[key] = input[key];
}
}
return out;
}
function unwrapKnownWrappers(
input: UnknownRecord,
wrapperKeys: readonly string[]
): UnknownRecord {
let current = input;
for (let i = 0; i < 4; i++) {
let next: UnknownRecord | null = null;
for (const key of wrapperKeys) {
const candidate = toRecord(current[key]);
if (candidate && Object.keys(candidate).length > 0) {
next = candidate;
break;
}
}
if (!next) {
break;
}
current = next;
}
return current;
}
function hasMeaningfulAddFields(input: UnknownRecord): boolean {
return Boolean(
input.schedule !== undefined ||
input.payload !== undefined ||
input.message !== undefined ||
input.text !== undefined ||
input.delaySeconds !== undefined ||
input.delayMs !== undefined ||
input.scheduleKind !== undefined ||
input.at !== undefined ||
input.everyMs !== undefined ||
input.expr !== undefined ||
input.cronExpr !== undefined
);
}
function extractAddSource(input: UnknownRecord): UnknownRecord {
const rawData = toRecord(input.data);
if (rawData && Object.keys(rawData).length > 0) {
return unwrapKnownWrappers(rawData, ["data", "job"]);
}
const rawJob = toRecord(input.job);
if (rawJob && Object.keys(rawJob).length > 0) {
return unwrapKnownWrappers(rawJob, ["data", "job"]);
}
const synthetic = copyKnownFields(input, CRON_JOB_KEYS);
if (hasMeaningfulAddFields(synthetic)) {
return unwrapKnownWrappers(synthetic, ["data", "job"]);
}
return unwrapKnownWrappers(input, ["data", "job"]);
}
function extractPatchSource(input: UnknownRecord, patch: unknown): UnknownRecord | null {
const patchRecord = toRecord(patch);
if (patchRecord && Object.keys(patchRecord).length > 0) {
return unwrapKnownWrappers(patchRecord, ["data", "patch", "job"]);
}
const synthetic = copyKnownFields(input, CRON_PATCH_KEYS);
return Object.keys(synthetic).length > 0
? unwrapKnownWrappers(synthetic, ["data", "patch", "job"])
: null;
}
function normalizeScheduleFromRecord(input: UnknownRecord): CronSchedule | null {
const scheduleRecord = toRecord(input.schedule);
const scheduleRaw = scheduleRecord
? unwrapKnownWrappers(scheduleRecord, ["data", "schedule"])
: input;
const rawKind = readString(scheduleRaw.kind)?.toLowerCase();
const at =
readString(scheduleRaw.at) ??
readString(scheduleRaw.scheduleAt) ??
readString(scheduleRaw.runAt) ??
readString(scheduleRaw.when);
const everyMs = readNumber(scheduleRaw.everyMs);
const anchorMs = readNumber(scheduleRaw.anchorMs);
const expr = readString(scheduleRaw.expr) ?? readString(scheduleRaw.cronExpr);
const tz = readString(scheduleRaw.tz) ?? readString(scheduleRaw.cronTz);
const kind =
rawKind === "at" || rawKind === "every" || rawKind === "cron"
? rawKind
: at
? "at"
: everyMs
? "every"
: expr
? "cron"
: readString(scheduleRaw.scheduleKind)?.toLowerCase();
if (kind === "at" && at) {
return { kind: "at", at };
}
if (kind === "every" && everyMs && everyMs > 0) {
return {
kind: "every",
everyMs: Math.max(1, Math.floor(everyMs)),
anchorMs:
typeof anchorMs === "number" && Number.isFinite(anchorMs)
? Math.max(0, Math.floor(anchorMs))
: undefined,
};
}
if (kind === "cron" && expr) {
return { kind: "cron", expr, tz };
}
const delaySeconds =
readNumber(scheduleRaw.delaySeconds) ??
readNumber(scheduleRaw.inSeconds) ??
readNumber(scheduleRaw.afterSeconds) ??
readNumber(scheduleRaw.seconds);
const delayMs =
readNumber(scheduleRaw.delayMs) ??
readNumber(scheduleRaw.inMs) ??
readNumber(scheduleRaw.afterMs);
const totalMs =
typeof delayMs === "number" && delayMs > 0
? delayMs
: typeof delaySeconds === "number" && delaySeconds > 0
? delaySeconds * 1_000
: 0;
if (totalMs > 0) {
return { kind: "at", at: new Date(Date.now() + totalMs).toISOString() };
}
return null;
}
function normalizePayloadFromRecord(input: UnknownRecord): CronJobCreate["payload"] | null {
const payloadRaw = toRecord(input.payload);
const payload = payloadRaw ? unwrapKnownWrappers(payloadRaw, ["data", "payload"]) : input;
const rawKind = readString(payload.kind)?.toLowerCase();
const kind = rawKind === "agentturn" ? "agentTurn" : rawKind;
const message =
readString(payload.message) ??
readString(payload.text) ??
readString(input.message) ??
readString(input.payloadMessage) ??
readString(input.text);
if ((kind && kind !== "agentturn" && kind !== "agentTurn") || !message) {
return null;
}
const timeoutSeconds = readNumber(payload.timeoutSeconds) ?? readNumber(input.timeoutSeconds);
return {
kind: "agentTurn",
message,
chatId: readString(payload.chatId) ?? readString(input.chatId),
telegramChatId:
readString(payload.telegramChatId) ??
readString(payload.telegram_chat_id) ??
readString(input.telegramChatId) ??
readString(input.telegram_chat_id),
currentPath: readString(payload.currentPath) ?? readString(input.currentPath),
timeoutSeconds:
typeof timeoutSeconds === "number" && Number.isFinite(timeoutSeconds)
? Math.max(0, Math.floor(timeoutSeconds))
: undefined,
};
}
function explainAddInputFailure(source: UnknownRecord): string {
const schedule = normalizeScheduleFromRecord(source);
const payload = normalizePayloadFromRecord(source);
if (schedule && payload) {
return "";
}
const problems: string[] = [];
if (!schedule) {
problems.push(
"Missing schedule. Provide `schedule` (`at`/`every`/`cron`) or `delaySeconds`/`delayMs`."
);
}
if (!payload) {
const payloadRecord = toRecord(source.payload);
const hasMessage =
Boolean(readString(payloadRecord?.message)) ||
Boolean(readString(payloadRecord?.text)) ||
Boolean(readString(source.message)) ||
Boolean(readString(source.payloadMessage)) ||
Boolean(readString(source.text));
const rawKind = readString(payloadRecord?.kind);
if (!hasMessage) {
problems.push("Missing payload message. Provide `payload.message` (or top-level `message`).");
} else if (rawKind && rawKind.toLowerCase() !== "agentturn") {
problems.push("Invalid payload kind. `payload.kind` must be `agentTurn`.");
} else {
problems.push("Invalid payload object. Expected `payload.kind=\"agentTurn\"` + `payload.message`.");
}
}
problems.push(
"Example: {\"action\":\"add\",\"delaySeconds\":30,\"message\":\"Отправь пользователю: привет\"}"
);
return problems.join(" ");
}
export function normalizeCronToolAddInput(rawInput: unknown): CronJobCreate | null {
const input = toRecord(rawInput);
if (!input) return null;
const source = extractAddSource(input);
const schedule = normalizeScheduleFromRecord(source);
const payload = normalizePayloadFromRecord(source);
if (schedule && payload) {
const enabled = readBoolean(source.enabled);
const deleteAfterRun = readBoolean(source.deleteAfterRun);
return {
name: readString(source.name) ?? "Cron job",
description: readString(source.description),
enabled: enabled ?? true,
deleteAfterRun: deleteAfterRun ?? (schedule.kind === "at" ? true : undefined),
schedule,
payload,
};
}
return null;
}
export function explainCronToolAddInputFailure(rawInput: unknown): string {
const input = toRecord(rawInput);
if (!input) {
return "Arguments must be a JSON object.";
}
const source = extractAddSource(input);
return explainAddInputFailure(source);
}
export function normalizeCronToolPatchInput(
rawInput: unknown,
rawPatch: unknown
): CronJobPatch | null {
const input = toRecord(rawInput);
if (!input) return null;
const source = extractPatchSource(input, rawPatch);
if (!source) return null;
const patch: CronJobPatch = {};
if ("name" in source) {
patch.name = readString(source.name) ?? "";
}
if ("description" in source) {
patch.description = readString(source.description) ?? "";
}
if ("enabled" in source) {
const enabled = readBoolean(source.enabled);
if (typeof enabled === "boolean") patch.enabled = enabled;
}
if ("deleteAfterRun" in source) {
const deleteAfterRun = readBoolean(source.deleteAfterRun);
if (typeof deleteAfterRun === "boolean") patch.deleteAfterRun = deleteAfterRun;
}
if ("schedule" in source || "scheduleKind" in source || "at" in source || "everyMs" in source || "expr" in source || "cronExpr" in source) {
const schedule = normalizeScheduleFromRecord(source);
if (!schedule) return null;
patch.schedule = schedule;
}
const payload = normalizePayloadFromRecord(source);
if (payload) {
patch.payload = {
kind: "agentTurn",
message: payload.message,
chatId: payload.chatId,
telegramChatId: payload.telegramChatId,
currentPath: payload.currentPath,
timeoutSeconds: payload.timeoutSeconds,
};
}
return Object.keys(patch).length > 0 ? patch : null;
}

76
src/lib/cron/types.ts Normal file
View File

@@ -0,0 +1,76 @@
export type CronProjectId = string;
export type CronSchedule =
| { kind: "at"; at: string }
| { kind: "every"; everyMs: number; anchorMs?: number }
| { kind: "cron"; expr: string; tz?: string };
export type CronPayload = {
kind: "agentTurn";
message: string;
chatId?: string;
telegramChatId?: string;
currentPath?: string;
timeoutSeconds?: number;
};
export type CronRunStatus = "ok" | "error" | "skipped";
export type CronJobState = {
nextRunAtMs?: number;
runningAtMs?: number;
lastRunAtMs?: number;
lastStatus?: CronRunStatus;
lastError?: string;
lastDurationMs?: number;
consecutiveErrors?: number;
};
export type CronJob = {
id: string;
projectId: CronProjectId;
name: string;
description?: string;
enabled: boolean;
deleteAfterRun?: boolean;
createdAtMs: number;
updatedAtMs: number;
schedule: CronSchedule;
payload: CronPayload;
state: CronJobState;
};
export type CronStoreFile = {
version: 1;
jobs: CronJob[];
};
export type CronJobCreate = {
name: string;
description?: string;
enabled?: boolean;
deleteAfterRun?: boolean;
schedule: CronSchedule;
payload: CronPayload;
};
export type CronJobPatch = Partial<{
name: string;
description: string;
enabled: boolean;
deleteAfterRun: boolean;
schedule: CronSchedule;
payload: Partial<CronPayload>;
}>;
export type CronRunLogEntry = {
ts: number;
jobId: string;
projectId: CronProjectId;
status: CronRunStatus;
error?: string;
summary?: string;
runAtMs?: number;
durationMs?: number;
nextRunAtMs?: number;
};

View File

@@ -0,0 +1,352 @@
import { runAgentText } from "@/lib/agent/agent";
import { createChat, getChat } from "@/lib/storage/chat-store";
import { getAllProjects, getProject } from "@/lib/storage/project-store";
import {
contextKey,
getOrCreateExternalSession,
saveExternalSession,
type ExternalSession,
} from "@/lib/storage/external-session-store";
import type { ChatMessage } from "@/lib/types";
export interface HandleExternalMessageInput {
sessionId: string;
message: string;
projectId?: string;
chatId?: string;
currentPath?: string;
runtimeData?: Record<string, unknown>;
}
interface SwitchProjectSignal {
projectId: string;
currentPath: string;
}
interface CreateProjectSignal {
projectId: string;
}
export interface ExternalMessageResult {
success: true;
sessionId: string;
reply: string;
context: {
activeProjectId: string | null;
activeProjectName: string | null;
activeChatId: string;
currentPath: string;
};
switchedProject: {
toProjectId: string;
toProjectName: string | null;
} | null;
createdProject: {
id: string;
name: string | null;
} | null;
}
export class ExternalMessageError extends Error {
status: number;
payload: Record<string, unknown>;
constructor(status: number, payload: Record<string, unknown>) {
super(
typeof payload.error === "string"
? payload.error
: `External message failed with status ${status}`
);
this.status = status;
this.payload = payload;
}
}
function parseSwitchProjectSignal(
message: ChatMessage
): SwitchProjectSignal | null {
if (message.role !== "tool" || message.toolName !== "switch_project") {
return null;
}
let parsed: unknown = message.toolResult ?? message.content;
if (typeof parsed === "string") {
const trimmed = parsed.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return null;
}
try {
parsed = JSON.parse(trimmed);
} catch {
return null;
}
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const record = parsed as Record<string, unknown>;
if (record.success !== true || record.action !== "switch_project") {
return null;
}
const projectId =
typeof record.projectId === "string" ? record.projectId.trim() : "";
if (!projectId) return null;
const currentPath =
typeof record.currentPath === "string" ? record.currentPath : "";
return { projectId, currentPath };
}
function parseCreateProjectSignal(
message: ChatMessage
): CreateProjectSignal | null {
if (message.role !== "tool" || message.toolName !== "create_project") {
return null;
}
let parsed: unknown = message.toolResult ?? message.content;
if (typeof parsed === "string") {
const trimmed = parsed.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return null;
}
try {
parsed = JSON.parse(trimmed);
} catch {
return null;
}
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const record = parsed as Record<string, unknown>;
if (record.success !== true || record.action !== "create_project") {
return null;
}
const projectId =
typeof record.projectId === "string" ? record.projectId.trim() : "";
if (!projectId) return null;
return { projectId };
}
function chatBelongsToProject(
chatProjectId: string | undefined,
projectId: string | undefined
): boolean {
const left = chatProjectId ?? null;
const right = projectId ?? null;
return left === right;
}
async function ensureChatForProject(
session: ExternalSession,
projectId: string | undefined
): Promise<string> {
const key = contextKey(projectId);
const existingId = session.activeChats[key];
if (existingId) {
const existingChat = await getChat(existingId);
if (existingChat && chatBelongsToProject(existingChat.projectId, projectId)) {
return existingId;
}
}
const newChatId = crypto.randomUUID();
const title = `External session ${session.id}`;
await createChat(newChatId, title, projectId);
session.activeChats[key] = newChatId;
return newChatId;
}
export async function handleExternalMessage(
input: HandleExternalMessageInput
): Promise<ExternalMessageResult> {
const sessionId = input.sessionId.trim();
const message = input.message.trim();
const explicitProjectId = input.projectId?.trim() ?? "";
const explicitChatId = input.chatId?.trim() ?? "";
const explicitCurrentPath =
typeof input.currentPath === "string" ? input.currentPath : undefined;
if (!sessionId) {
throw new ExternalMessageError(400, { error: "sessionId is required" });
}
if (!message) {
throw new ExternalMessageError(400, { error: "message is required" });
}
const session = await getOrCreateExternalSession(sessionId);
const projects = await getAllProjects();
const projectById = new Map(projects.map((project) => [project.id, project]));
if (session.activeProjectId && !projectById.has(session.activeProjectId)) {
session.activeProjectId = null;
}
let resolvedProjectId: string | undefined;
if (explicitProjectId) {
if (!projectById.has(explicitProjectId)) {
throw new ExternalMessageError(404, {
error: `Project "${explicitProjectId}" not found`,
availableProjects: projects.map((project) => ({
id: project.id,
name: project.name,
})),
});
}
resolvedProjectId = explicitProjectId;
session.activeProjectId = explicitProjectId;
} else if (session.activeProjectId && projectById.has(session.activeProjectId)) {
resolvedProjectId = session.activeProjectId;
} else if (projects.length > 0) {
resolvedProjectId = projects[0].id;
session.activeProjectId = projects[0].id;
}
const contextId = contextKey(resolvedProjectId);
const currentPath =
explicitCurrentPath ??
session.currentPaths[contextId] ??
"";
let resolvedChatId: string;
if (explicitChatId) {
const explicitChat = await getChat(explicitChatId);
if (!explicitChat) {
throw new ExternalMessageError(404, {
error: `Chat "${explicitChatId}" not found`,
});
}
if (!chatBelongsToProject(explicitChat.projectId, resolvedProjectId)) {
throw new ExternalMessageError(409, {
error:
'Provided chatId belongs to a different project context. Send matching chatId/projectId or omit chatId.',
});
}
resolvedChatId = explicitChatId;
} else {
const sessionChatId = session.activeChats[contextId];
if (sessionChatId) {
const sessionChat = await getChat(sessionChatId);
if (
sessionChat &&
chatBelongsToProject(sessionChat.projectId, resolvedProjectId)
) {
resolvedChatId = sessionChatId;
} else {
resolvedChatId = await ensureChatForProject(session, resolvedProjectId);
}
} else {
resolvedChatId = await ensureChatForProject(session, resolvedProjectId);
}
}
const beforeChat = await getChat(resolvedChatId);
const beforeCount = beforeChat?.messages.length ?? 0;
const reply = await runAgentText({
chatId: resolvedChatId,
userMessage: message,
projectId: resolvedProjectId,
currentPath: currentPath || undefined,
runtimeData: input.runtimeData,
});
const afterChat = await getChat(resolvedChatId);
const newMessages = afterChat?.messages.slice(beforeCount) ?? [];
let switchSignal: SwitchProjectSignal | null = null;
let createSignal: CreateProjectSignal | null = null;
for (let i = newMessages.length - 1; i >= 0; i -= 1) {
if (!switchSignal) {
const parsedSwitch = parseSwitchProjectSignal(newMessages[i]);
if (parsedSwitch) {
switchSignal = parsedSwitch;
}
}
if (!createSignal) {
const parsedCreate = parseCreateProjectSignal(newMessages[i]);
if (parsedCreate) {
createSignal = parsedCreate;
}
}
if (switchSignal && createSignal) {
break;
}
}
const projectsAfter = await getAllProjects();
const projectByIdAfter = new Map(
projectsAfter.map((project) => [project.id, project])
);
let activeProjectId = resolvedProjectId ?? null;
let activeChatId = resolvedChatId;
let activeCurrentPath = currentPath;
if (switchSignal && projectByIdAfter.has(switchSignal.projectId)) {
activeProjectId = switchSignal.projectId;
session.activeProjectId = switchSignal.projectId;
const switchedContextKey = contextKey(switchSignal.projectId);
session.currentPaths[switchedContextKey] = switchSignal.currentPath ?? "";
activeCurrentPath = switchSignal.currentPath ?? "";
activeChatId = await ensureChatForProject(session, switchSignal.projectId);
} else if (createSignal && projectByIdAfter.has(createSignal.projectId)) {
activeProjectId = createSignal.projectId;
session.activeProjectId = createSignal.projectId;
const createdContextKey = contextKey(createSignal.projectId);
session.currentPaths[createdContextKey] = "";
activeCurrentPath = "";
activeChatId = await ensureChatForProject(session, createSignal.projectId);
} else {
if (resolvedProjectId) {
session.activeProjectId = resolvedProjectId;
}
session.currentPaths[contextId] = currentPath;
session.activeChats[contextId] = resolvedChatId;
}
const activeContextKey = contextKey(activeProjectId ?? undefined);
session.activeChats[activeContextKey] = activeChatId;
session.updatedAt = new Date().toISOString();
await saveExternalSession(session);
const activeProject = activeProjectId
? await getProject(activeProjectId)
: null;
return {
success: true,
sessionId: session.id,
reply,
context: {
activeProjectId,
activeProjectName: activeProject?.name ?? null,
activeChatId,
currentPath: activeCurrentPath,
},
switchedProject:
switchSignal && projectByIdAfter.has(switchSignal.projectId)
? {
toProjectId: switchSignal.projectId,
toProjectName:
projectByIdAfter.get(switchSignal.projectId)?.name ?? null,
}
: null,
createdProject:
createSignal && projectByIdAfter.has(createSignal.projectId)
? {
id: createSignal.projectId,
name: projectByIdAfter.get(createSignal.projectId)?.name ?? null,
}
: null,
};
}

Some files were not shown because too many files have changed in this diff Show More