mirror of
https://github.com/eggent-ai/eggent.git
synced 2026-03-08 02:23:06 +00:00
Initial commit
This commit is contained in:
100
src/app/api/auth/credentials/route.ts
Normal file
100
src/app/api/auth/credentials/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/app/api/auth/login/route.ts
Normal file
73
src/app/api/auth/login/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
16
src/app/api/auth/logout/route.ts
Normal file
16
src/app/api/auth/logout/route.ts
Normal 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;
|
||||
}
|
||||
38
src/app/api/auth/status/route.ts
Normal file
38
src/app/api/auth/status/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
102
src/app/api/chat/files/route.ts
Normal file
102
src/app/api/chat/files/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/app/api/chat/history/route.ts
Normal file
41
src/app/api/chat/history/route.ts
Normal 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
74
src/app/api/chat/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
79
src/app/api/events/route.ts
Normal file
79
src/app/api/events/route.ts
Normal 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
92
src/app/api/external/message/route.ts
vendored
Normal 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
64
src/app/api/external/token/route.ts
vendored
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
43
src/app/api/files/download/route.ts
Normal file
43
src/app/api/files/download/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
62
src/app/api/files/route.ts
Normal file
62
src/app/api/files/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
7
src/app/api/health/route.ts
Normal file
7
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export async function GET() {
|
||||
return Response.json({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: "0.1.0",
|
||||
});
|
||||
}
|
||||
30
src/app/api/integrations/telegram/access-code/route.ts
Normal file
30
src/app/api/integrations/telegram/access-code/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
44
src/app/api/integrations/telegram/config/route.ts
Normal file
44
src/app/api/integrations/telegram/config/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
93
src/app/api/integrations/telegram/disconnect/route.ts
Normal file
93
src/app/api/integrations/telegram/disconnect/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
742
src/app/api/integrations/telegram/route.ts
Normal file
742
src/app/api/integrations/telegram/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
152
src/app/api/integrations/telegram/setup/route.ts
Normal file
152
src/app/api/integrations/telegram/setup/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
166
src/app/api/integrations/telegram/webhook/route.ts
Normal file
166
src/app/api/integrations/telegram/webhook/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/app/api/knowledge/route.ts
Normal file
42
src/app/api/knowledge/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
75
src/app/api/memory/route.ts
Normal file
75
src/app/api/memory/route.ts
Normal 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
161
src/app/api/models/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
142
src/app/api/projects/[id]/cron/[jobId]/route.ts
Normal file
142
src/app/api/projects/[id]/cron/[jobId]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
30
src/app/api/projects/[id]/cron/[jobId]/run/route.ts
Normal file
30
src/app/api/projects/[id]/cron/[jobId]/run/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
22
src/app/api/projects/[id]/cron/[jobId]/runs/route.ts
Normal file
22
src/app/api/projects/[id]/cron/[jobId]/runs/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
106
src/app/api/projects/[id]/cron/route.ts
Normal file
106
src/app/api/projects/[id]/cron/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
18
src/app/api/projects/[id]/cron/status/route.ts
Normal file
18
src/app/api/projects/[id]/cron/status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
34
src/app/api/projects/[id]/knowledge/chunks/route.ts
Normal file
34
src/app/api/projects/[id]/knowledge/chunks/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
156
src/app/api/projects/[id]/knowledge/route.ts
Normal file
156
src/app/api/projects/[id]/knowledge/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
43
src/app/api/projects/[id]/mcp/route.ts
Normal file
43
src/app/api/projects/[id]/mcp/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
43
src/app/api/projects/[id]/route.ts
Normal file
43
src/app/api/projects/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
31
src/app/api/projects/[id]/skills/route.ts
Normal file
31
src/app/api/projects/[id]/skills/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
46
src/app/api/projects/route.ts
Normal file
46
src/app/api/projects/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
92
src/app/api/settings/route.ts
Normal file
92
src/app/api/settings/route.ts
Normal 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);
|
||||
}
|
||||
82
src/app/api/skills/route.ts
Normal file
82
src/app/api/skills/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
194
src/app/dashboard/api/page.tsx
Normal file
194
src/app/dashboard/api/page.tsx
Normal 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 <token></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 -> 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 <access_code></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'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>
|
||||
);
|
||||
}
|
||||
106
src/app/dashboard/cron/page.tsx
Normal file
106
src/app/dashboard/cron/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/app/dashboard/layout.tsx
Normal file
7
src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
343
src/app/dashboard/mcp/page.tsx
Normal file
343
src/app/dashboard/mcp/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
src/app/dashboard/memory/page.tsx
Normal file
220
src/app/dashboard/memory/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
54
src/app/dashboard/messengers/page.tsx
Normal file
54
src/app/dashboard/messengers/page.tsx
Normal 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 <access_code></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>
|
||||
);
|
||||
}
|
||||
37
src/app/dashboard/page.tsx
Normal file
37
src/app/dashboard/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
src/app/dashboard/projects/[id]/page.tsx
Normal file
116
src/app/dashboard/projects/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
950
src/app/dashboard/projects/page.tsx
Normal file
950
src/app/dashboard/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
391
src/app/dashboard/settings/page.tsx
Normal file
391
src/app/dashboard/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
444
src/app/dashboard/skills/page.tsx
Normal file
444
src/app/dashboard/skills/page.tsx
Normal 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
122
src/app/globals.css
Normal 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
34
src/app/layout.tsx
Normal 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
131
src/app/login/page.tsx
Normal 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
5
src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
358
src/components/app-sidebar.tsx
Normal file
358
src/components/app-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
291
src/components/chat/chat-input.tsx
Normal file
291
src/components/chat/chat-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/components/chat/chat-messages.tsx
Normal file
76
src/components/chat/chat-messages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
487
src/components/chat/chat-panel.tsx
Normal file
487
src/components/chat/chat-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/chat/code-block.tsx
Normal file
55
src/components/chat/code-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
src/components/chat/message-bubble.tsx
Normal file
178
src/components/chat/message-bubble.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
src/components/chat/tool-output.tsx
Normal file
135
src/components/chat/tool-output.tsx
Normal 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">
|
||||
"{String(args.query)}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
712
src/components/cron-section.tsx
Normal file
712
src/components/cron-section.tsx
Normal 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 "Runs" 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>
|
||||
);
|
||||
}
|
||||
175
src/components/external-api-token-manager.tsx
Normal file
175
src/components/external-api-token-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
288
src/components/file-tree.tsx
Normal file
288
src/components/file-tree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
449
src/components/knowledge-section.tsx
Normal file
449
src/components/knowledge-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/components/nav-main.tsx
Normal file
78
src/components/nav-main.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
src/components/nav-projects.tsx
Normal file
89
src/components/nav-projects.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
src/components/nav-secondary.tsx
Normal file
40
src/components/nav-secondary.tsx
Normal 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
114
src/components/nav-user.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
186
src/components/project-context-section.tsx
Normal file
186
src/components/project-context-section.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
src/components/search-form.tsx
Normal file
22
src/components/search-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
614
src/components/settings/model-wizards.tsx
Normal file
614
src/components/settings/model-wizards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/site-header.tsx
Normal file
29
src/components/site-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
569
src/components/telegram-integration-manager.tsx
Normal file
569
src/components/telegram-integration-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/components/ui/avatar.tsx
Normal file
109
src/components/ui/avatar.tsx
Normal 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,
|
||||
}
|
||||
109
src/components/ui/breadcrumb.tsx
Normal file
109
src/components/ui/breadcrumb.tsx
Normal 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,
|
||||
}
|
||||
64
src/components/ui/button.tsx
Normal file
64
src/components/ui/button.tsx
Normal 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 }
|
||||
33
src/components/ui/collapsible.tsx
Normal file
33
src/components/ui/collapsible.tsx
Normal 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 }
|
||||
257
src/components/ui/dropdown-menu.tsx
Normal file
257
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 }
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal 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 }
|
||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal 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
143
src/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
726
src/components/ui/sidebar.tsx
Normal file
726
src/components/ui/sidebar.tsx
Normal 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,
|
||||
}
|
||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal 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 }
|
||||
57
src/components/ui/tooltip.tsx
Normal file
57
src/components/ui/tooltip.tsx
Normal 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 }
|
||||
114
src/hooks/use-background-sync.ts
Normal file
114
src/hooks/use-background-sync.ts
Normal 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
19
src/hooks/use-mobile.ts
Normal 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
676
src/lib/agent/agent.ts
Normal 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
65
src/lib/agent/history.ts
Normal 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
247
src/lib/agent/prompts.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
32
src/lib/agent/types.ts
Normal 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
76
src/lib/auth/password.ts
Normal 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
203
src/lib/auth/session.ts
Normal 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
31
src/lib/cron/parse.ts
Normal 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
26
src/lib/cron/paths.ts
Normal 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
79
src/lib/cron/run-log.ts
Normal 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
13
src/lib/cron/runtime.ts
Normal 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
248
src/lib/cron/schedule.ts
Normal 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
925
src/lib/cron/service.ts
Normal 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
54
src/lib/cron/store.ts
Normal 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);
|
||||
}
|
||||
405
src/lib/cron/tool-normalize.ts
Normal file
405
src/lib/cron/tool-normalize.ts
Normal 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
76
src/lib/cron/types.ts
Normal 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;
|
||||
};
|
||||
352
src/lib/external/handle-external-message.ts
vendored
Normal file
352
src/lib/external/handle-external-message.ts
vendored
Normal 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
Reference in New Issue
Block a user