feat: add Omega AI agent chat feature
- Add Prisma models for conversations, messages, participants, tool runs, and user settings - Create API endpoints for conversation CRUD and SSE message streaming - Build landing page with sample prompts at /omega - Build chat interface with real-time streaming at /omega/[conversationId] - Integrate @tpmjs/registry-search and @tpmjs/registry-execute packages - Use OpenAI GPT-4.1 Mini as the default model
This commit is contained in:
parent
d2740c3f69
commit
3259e038fb
11 changed files with 2074 additions and 1650 deletions
|
|
@ -33,6 +33,8 @@
|
|||
"@tpmjs/env": "workspace:*",
|
||||
"@tpmjs/npm-client": "workspace:*",
|
||||
"@tpmjs/package-executor": "workspace:*",
|
||||
"@tpmjs/registry-execute": "workspace:*",
|
||||
"@tpmjs/registry-search": "workspace:*",
|
||||
"@tpmjs/types": "workspace:*",
|
||||
"@tpmjs/ui": "workspace:*",
|
||||
"@tpmjs/utils": "workspace:*",
|
||||
|
|
|
|||
100
apps/web/src/app/api/omega/conversations/[id]/cancel/route.ts
Normal file
100
apps/web/src/app/api/omega/conversations/[id]/cancel/route.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Omega Cancel Endpoint
|
||||
*
|
||||
* POST: Cancel a running conversation execution
|
||||
*/
|
||||
|
||||
import { prisma } from '@tpmjs/db';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { authenticateRequest } from '~/lib/api-keys/middleware';
|
||||
import {
|
||||
apiForbidden,
|
||||
apiInternalError,
|
||||
apiNotFound,
|
||||
apiSuccess,
|
||||
apiUnauthorized,
|
||||
apiValidationError,
|
||||
} from '~/lib/api-response';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 10;
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/omega/conversations/[id]/cancel
|
||||
* Cancel a running conversation execution
|
||||
*/
|
||||
export async function POST(_request: NextRequest, context: RouteContext) {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
const authResult = await authenticateRequest();
|
||||
if (!authResult.authenticated || !authResult.userId) {
|
||||
return apiUnauthorized('Authentication required', requestId);
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
// Fetch conversation
|
||||
const conversation = await prisma.omegaConversation.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
ownerId: true,
|
||||
executionState: true,
|
||||
participants: {
|
||||
select: { userId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
return apiNotFound('Conversation', requestId);
|
||||
}
|
||||
|
||||
// Check if user is owner or participant
|
||||
const isOwner = authResult.userId === conversation.ownerId;
|
||||
const isParticipant = conversation.participants.some((p) => p.userId === authResult.userId);
|
||||
|
||||
if (!isOwner && !isParticipant) {
|
||||
return apiForbidden('Access denied', requestId);
|
||||
}
|
||||
|
||||
// Check if conversation is running
|
||||
if (conversation.executionState !== 'running') {
|
||||
return apiValidationError(
|
||||
'Conversation is not running',
|
||||
{ currentState: conversation.executionState },
|
||||
requestId
|
||||
);
|
||||
}
|
||||
|
||||
// Update conversation state to cancelled
|
||||
await prisma.omegaConversation.update({
|
||||
where: { id },
|
||||
data: { executionState: 'cancelled' },
|
||||
});
|
||||
|
||||
// Mark any running tool runs as cancelled
|
||||
await prisma.omegaToolRun.updateMany({
|
||||
where: {
|
||||
conversationId: id,
|
||||
status: 'running',
|
||||
},
|
||||
data: {
|
||||
status: 'error',
|
||||
error: 'Cancelled by user',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ cancelled: true }, { requestId });
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel Omega conversation:', error);
|
||||
return apiInternalError('Failed to cancel conversation', requestId);
|
||||
}
|
||||
}
|
||||
476
apps/web/src/app/api/omega/conversations/[id]/messages/route.ts
Normal file
476
apps/web/src/app/api/omega/conversations/[id]/messages/route.ts
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
/**
|
||||
* Omega Messages Endpoint
|
||||
*
|
||||
* POST: Send a message and stream the AI response via SSE
|
||||
*
|
||||
* This endpoint implements the core Omega chat functionality with:
|
||||
* - Registry search tool for discovering tools
|
||||
* - Tool executor for running discovered tools
|
||||
* - SSE streaming for real-time updates
|
||||
*/
|
||||
|
||||
import { Prisma, prisma } from '@tpmjs/db';
|
||||
import { registryExecuteTool } from '@tpmjs/registry-execute';
|
||||
import { registrySearchTool } from '@tpmjs/registry-search';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { authenticateRequest } from '~/lib/api-keys/middleware';
|
||||
import { buildSystemPrompt } from '~/lib/omega/system-prompt';
|
||||
import { checkRateLimit, type RateLimitConfig } from '~/lib/rate-limit';
|
||||
|
||||
/**
|
||||
* Rate limit for Omega chat: 20 requests per minute
|
||||
* Stricter limit because this involves multiple tool executions
|
||||
*/
|
||||
const OMEGA_RATE_LIMIT: RateLimitConfig = {
|
||||
limit: 20,
|
||||
windowSeconds: 60,
|
||||
};
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300; // 5 minutes for complex tool chains
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
const SendMessageSchema = z.object({
|
||||
message: z.string().min(1).max(10000),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/omega/conversations/[id]/messages
|
||||
* Send a message and stream the AI response via SSE
|
||||
*/
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex streaming logic required
|
||||
export async function POST(request: NextRequest, context: RouteContext): Promise<Response> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Authenticate request
|
||||
const authResult = await authenticateRequest();
|
||||
if (!authResult.authenticated || !authResult.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
const rateLimitResponse = checkRateLimit(request, OMEGA_RATE_LIMIT);
|
||||
if (rateLimitResponse) {
|
||||
return rateLimitResponse;
|
||||
}
|
||||
|
||||
const { id: conversationId } = await context.params;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const parsed = SendMessageSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid request', details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch conversation and verify access
|
||||
const conversation = await prisma.omegaConversation.findUnique({
|
||||
where: { id: conversationId },
|
||||
include: {
|
||||
participants: {
|
||||
select: { userId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Conversation not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user is owner or participant
|
||||
const isOwner = authResult.userId === conversation.ownerId;
|
||||
const isParticipant = conversation.participants.some((p) => p.userId === authResult.userId);
|
||||
|
||||
if (!isOwner && !isParticipant) {
|
||||
return NextResponse.json({ success: false, error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get user info
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: authResult.userId },
|
||||
select: { id: true, name: true, email: true },
|
||||
});
|
||||
|
||||
// Get user settings for pinned/blocked tools and custom prompt
|
||||
const userSettings = await prisma.omegaUserSettings.findUnique({
|
||||
where: { userId: authResult.userId },
|
||||
});
|
||||
|
||||
// Update conversation state to running
|
||||
await prisma.omegaConversation.update({
|
||||
where: { id: conversationId },
|
||||
data: { executionState: 'running' },
|
||||
});
|
||||
|
||||
// Save user message
|
||||
await prisma.omegaMessage.create({
|
||||
data: {
|
||||
conversationId,
|
||||
role: 'USER',
|
||||
content: parsed.data.message,
|
||||
authorId: user?.id,
|
||||
authorEmail: user?.email,
|
||||
authorName: user?.name,
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch recent messages for context
|
||||
const recentMessages = await prisma.omegaMessage.findMany({
|
||||
where: { conversationId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20, // Last 20 messages for context
|
||||
});
|
||||
recentMessages.reverse();
|
||||
|
||||
// Build AI SDK messages
|
||||
const messages: ModelMessage[] = [];
|
||||
|
||||
// Add system prompt
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
customSystemPrompt: userSettings?.customSystemPrompt,
|
||||
pinnedToolIds: userSettings?.pinnedToolIds || [],
|
||||
});
|
||||
messages.push({ role: 'system', content: systemPrompt });
|
||||
|
||||
// Add conversation history
|
||||
for (const msg of recentMessages.slice(0, -1)) {
|
||||
// Exclude the message we just added
|
||||
if (msg.role === 'USER') {
|
||||
messages.push({ role: 'user', content: msg.content });
|
||||
} else if (msg.role === 'ASSISTANT') {
|
||||
if (msg.toolCalls && Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0) {
|
||||
const toolCallParts = (
|
||||
msg.toolCalls as Array<{ toolCallId: string; toolName: string; args: unknown }>
|
||||
).map((tc) => ({
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: tc.toolCallId,
|
||||
toolName: tc.toolName,
|
||||
input: tc.args,
|
||||
}));
|
||||
|
||||
const content: Array<
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
|
||||
> = [];
|
||||
|
||||
if (msg.content) {
|
||||
content.push({ type: 'text', text: msg.content });
|
||||
}
|
||||
content.push(...toolCallParts);
|
||||
messages.push({ role: 'assistant', content });
|
||||
} else {
|
||||
messages.push({ role: 'assistant', content: msg.content });
|
||||
}
|
||||
} else if (msg.role === 'TOOL') {
|
||||
// Handle tool results (stored in toolCalls as a workaround)
|
||||
const toolResults = msg.toolCalls as Array<{
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
output: unknown;
|
||||
}> | null;
|
||||
if (toolResults && toolResults.length > 0) {
|
||||
for (const tr of toolResults) {
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-result' as const,
|
||||
toolCallId: tr.toolCallId,
|
||||
toolName: tr.toolName,
|
||||
output: {
|
||||
type: 'json' as const,
|
||||
value: tr.output as Parameters<typeof JSON.stringify>[0],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add new user message
|
||||
messages.push({ role: 'user', content: parsed.data.message });
|
||||
|
||||
// Build Omega tools using published TPMJS packages
|
||||
const tools = {
|
||||
registrySearch: registrySearchTool,
|
||||
registryExecute: registryExecuteTool,
|
||||
};
|
||||
|
||||
// Get the provider model (using OpenAI by default)
|
||||
const { createOpenAI } = await import('@ai-sdk/openai');
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
await prisma.omegaConversation.update({
|
||||
where: { id: conversationId },
|
||||
data: { executionState: 'idle' },
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Omega is not configured. Missing OPENAI_API_KEY.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const openai = createOpenAI({ apiKey });
|
||||
const model = openai('gpt-4.1-mini');
|
||||
|
||||
// Create SSE stream
|
||||
const stream = new ReadableStream({
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex streaming logic
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const sendEvent = (event: string, data: unknown) => {
|
||||
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(encoder.encode(message));
|
||||
};
|
||||
|
||||
try {
|
||||
const { streamText, stepCountIs } = await import('ai');
|
||||
|
||||
let fullContent = '';
|
||||
const toolCallsMap: Map<string, { toolCallId: string; toolName: string; args: unknown }> =
|
||||
new Map();
|
||||
const pendingToolResults: Array<{
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
output: unknown;
|
||||
}> = [];
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
|
||||
// Stream the response with up to 10 tool call iterations
|
||||
const result = await streamText({
|
||||
model,
|
||||
messages,
|
||||
tools,
|
||||
stopWhen: stepCountIs(10),
|
||||
onChunk: async ({ chunk }) => {
|
||||
if (chunk.type === 'tool-call') {
|
||||
const input = 'args' in chunk ? chunk.args : chunk.input;
|
||||
|
||||
console.log('[Omega] Tool call:', {
|
||||
toolName: chunk.toolName,
|
||||
toolCallId: chunk.toolCallId,
|
||||
});
|
||||
|
||||
toolCallsMap.set(chunk.toolCallId, {
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
args: input,
|
||||
});
|
||||
|
||||
// Create tool run record
|
||||
await prisma.omegaToolRun.create({
|
||||
data: {
|
||||
conversationId,
|
||||
toolName: chunk.toolName,
|
||||
input: input as Prisma.InputJsonValue,
|
||||
status: 'running',
|
||||
},
|
||||
});
|
||||
|
||||
sendEvent('run.step.tool.started', {
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
input,
|
||||
});
|
||||
}
|
||||
},
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex tool result handling
|
||||
onStepFinish: async ({ toolCalls, toolResults, usage }) => {
|
||||
// Capture tool calls
|
||||
if (toolCalls && Array.isArray(toolCalls)) {
|
||||
for (const tc of toolCalls) {
|
||||
if (!toolCallsMap.has(tc.toolCallId)) {
|
||||
const args =
|
||||
'input' in tc ? tc.input : 'args' in tc ? (tc as { args: unknown }).args : {};
|
||||
toolCallsMap.set(tc.toolCallId, {
|
||||
toolCallId: tc.toolCallId,
|
||||
toolName: tc.toolName,
|
||||
args,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process tool results
|
||||
if (toolResults && toolResults.length > 0) {
|
||||
for (const tr of toolResults) {
|
||||
const isError =
|
||||
tr.output && typeof tr.output === 'object' && 'error' in tr.output;
|
||||
|
||||
// Build update data for tool run
|
||||
const toolRunUpdateData: Prisma.OmegaToolRunUpdateManyMutationInput = {
|
||||
output: tr.output as Prisma.InputJsonValue,
|
||||
status: isError ? 'error' : 'success',
|
||||
completedAt: new Date(),
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
};
|
||||
|
||||
// Add error message if the tool execution failed
|
||||
if (isError) {
|
||||
toolRunUpdateData.error =
|
||||
typeof tr.output === 'object' && tr.output && 'error' in tr.output
|
||||
? String((tr.output as { error: unknown }).error)
|
||||
: 'Unknown error';
|
||||
}
|
||||
|
||||
// Update tool run record
|
||||
await prisma.omegaToolRun.updateMany({
|
||||
where: {
|
||||
conversationId,
|
||||
toolName: tr.toolName,
|
||||
status: 'running',
|
||||
},
|
||||
data: toolRunUpdateData,
|
||||
});
|
||||
|
||||
sendEvent('run.step.tool.completed', {
|
||||
toolCallId: tr.toolCallId,
|
||||
toolName: tr.toolName,
|
||||
output: tr.output,
|
||||
isError,
|
||||
});
|
||||
|
||||
pendingToolResults.push({
|
||||
toolCallId: tr.toolCallId,
|
||||
toolName: tr.toolName,
|
||||
output: tr.output,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (usage) {
|
||||
inputTokens += usage.inputTokens ?? 0;
|
||||
outputTokens += usage.outputTokens ?? 0;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Stream text chunks
|
||||
for await (const chunk of result.textStream) {
|
||||
fullContent += chunk;
|
||||
sendEvent('message.delta', { content: chunk });
|
||||
}
|
||||
|
||||
// Get final usage
|
||||
const finalUsage = await result.usage;
|
||||
if (finalUsage) {
|
||||
inputTokens = finalUsage.inputTokens ?? inputTokens;
|
||||
outputTokens = finalUsage.outputTokens ?? outputTokens;
|
||||
}
|
||||
|
||||
const allToolCalls = Array.from(toolCallsMap.values());
|
||||
|
||||
// Save assistant message
|
||||
const assistantMessage = await prisma.omegaMessage.create({
|
||||
data: {
|
||||
conversationId,
|
||||
role: 'ASSISTANT',
|
||||
content: fullContent,
|
||||
toolCalls:
|
||||
allToolCalls.length > 0
|
||||
? (allToolCalls as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.JsonNull,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
},
|
||||
});
|
||||
|
||||
// Save tool results as TOOL messages
|
||||
if (pendingToolResults.length > 0) {
|
||||
await prisma.omegaMessage.create({
|
||||
data: {
|
||||
conversationId,
|
||||
role: 'TOOL',
|
||||
content: 'Tool results',
|
||||
toolCalls: pendingToolResults as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update conversation
|
||||
const updateData: Prisma.OmegaConversationUpdateInput = {
|
||||
executionState: 'idle',
|
||||
inputTokensTotal: { increment: inputTokens },
|
||||
outputTokensTotal: { increment: outputTokens },
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Auto-generate title from first message if not set
|
||||
if (conversation.title === null) {
|
||||
updateData.title = parsed.data.message.slice(0, 100);
|
||||
}
|
||||
|
||||
await prisma.omegaConversation.update({
|
||||
where: { id: conversationId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
const executionTimeMs = Date.now() - startTime;
|
||||
|
||||
sendEvent('run.completed', {
|
||||
messageId: assistantMessage.id,
|
||||
conversationId,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
executionTimeMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Omega] Stream error:', error);
|
||||
|
||||
// Update conversation state
|
||||
await prisma.omegaConversation.update({
|
||||
where: { id: conversationId },
|
||||
data: { executionState: 'idle' },
|
||||
});
|
||||
|
||||
sendEvent('run.failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Omega] Failed to process message:', error);
|
||||
|
||||
// Reset conversation state
|
||||
await prisma.omegaConversation.update({
|
||||
where: { id: conversationId },
|
||||
data: { executionState: 'idle' },
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to process message',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
202
apps/web/src/app/api/omega/conversations/[id]/route.ts
Normal file
202
apps/web/src/app/api/omega/conversations/[id]/route.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
/**
|
||||
* Omega Single Conversation Endpoint
|
||||
*
|
||||
* GET: Fetch conversation with messages
|
||||
* DELETE: Delete a conversation
|
||||
*/
|
||||
|
||||
import { prisma } from '@tpmjs/db';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { authenticateRequest } from '~/lib/api-keys/middleware';
|
||||
import {
|
||||
apiForbidden,
|
||||
apiInternalError,
|
||||
apiNotFound,
|
||||
apiSuccess,
|
||||
apiUnauthorized,
|
||||
} from '~/lib/api-response';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 30;
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/omega/conversations/[id]
|
||||
* Fetch conversation with messages (paginated)
|
||||
*/
|
||||
export async function GET(request: NextRequest, context: RouteContext) {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
const authResult = await authenticateRequest();
|
||||
const { id } = await context.params;
|
||||
|
||||
// Fetch conversation
|
||||
const conversation = await prisma.omegaConversation.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
participants: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
joinedAt: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
messages: true,
|
||||
toolRuns: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
return apiNotFound('Conversation', requestId);
|
||||
}
|
||||
|
||||
// Check if user is owner or participant
|
||||
const isOwner = authResult.userId === conversation.ownerId;
|
||||
const isParticipant = conversation.participants.some((p) => p.userId === authResult.userId);
|
||||
|
||||
if (!isOwner && !isParticipant) {
|
||||
return apiForbidden('Access denied', requestId);
|
||||
}
|
||||
|
||||
// Fetch messages with pagination
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(Number.parseInt(searchParams.get('limit') || '50', 10), 100);
|
||||
const before = searchParams.get('before');
|
||||
const after = searchParams.get('after');
|
||||
|
||||
const whereClause: {
|
||||
conversationId: string;
|
||||
createdAt?: { lt?: Date; gt?: Date };
|
||||
} = { conversationId: id };
|
||||
|
||||
if (before) {
|
||||
whereClause.createdAt = { lt: new Date(before) };
|
||||
} else if (after) {
|
||||
whereClause.createdAt = { gt: new Date(after) };
|
||||
}
|
||||
|
||||
const shouldFetchDesc = !after;
|
||||
|
||||
const messages = await prisma.omegaMessage.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { createdAt: shouldFetchDesc ? 'desc' : 'asc' },
|
||||
take: limit + 1,
|
||||
});
|
||||
|
||||
const hasMoreMessages = messages.length > limit;
|
||||
let paginatedMessages = hasMoreMessages ? messages.slice(0, limit) : messages;
|
||||
|
||||
if (shouldFetchDesc) {
|
||||
paginatedMessages = paginatedMessages.reverse();
|
||||
}
|
||||
|
||||
// Fetch recent tool runs
|
||||
const toolRuns = await prisma.omegaToolRun.findMany({
|
||||
where: { conversationId: id },
|
||||
orderBy: { startedAt: 'desc' },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
id: conversation.id,
|
||||
title: conversation.title,
|
||||
executionState: conversation.executionState,
|
||||
inputTokensTotal: conversation.inputTokensTotal,
|
||||
outputTokensTotal: conversation.outputTokensTotal,
|
||||
createdAt: conversation.createdAt,
|
||||
updatedAt: conversation.updatedAt,
|
||||
isOwner,
|
||||
participants: conversation.participants,
|
||||
messageCount: conversation._count.messages,
|
||||
toolRunCount: conversation._count.toolRuns,
|
||||
messages: paginatedMessages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
authorId: m.authorId,
|
||||
authorName: m.authorName,
|
||||
toolCalls: m.toolCalls,
|
||||
inputTokens: m.inputTokens,
|
||||
outputTokens: m.outputTokens,
|
||||
createdAt: m.createdAt,
|
||||
})),
|
||||
toolRuns: toolRuns.map((tr) => ({
|
||||
id: tr.id,
|
||||
toolName: tr.toolName,
|
||||
status: tr.status,
|
||||
startedAt: tr.startedAt,
|
||||
completedAt: tr.completedAt,
|
||||
executionTimeMs: tr.executionTimeMs,
|
||||
error: tr.error,
|
||||
})),
|
||||
},
|
||||
{
|
||||
requestId,
|
||||
pagination: {
|
||||
limit,
|
||||
hasMore: hasMoreMessages,
|
||||
...(before && { before }),
|
||||
...(after && { after }),
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Omega conversation:', error);
|
||||
return apiInternalError('Failed to fetch conversation', requestId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/omega/conversations/[id]
|
||||
* Delete a conversation (owner only)
|
||||
*/
|
||||
export async function DELETE(_request: NextRequest, context: RouteContext) {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
const authResult = await authenticateRequest();
|
||||
if (!authResult.authenticated || !authResult.userId) {
|
||||
return apiUnauthorized('Authentication required', requestId);
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
|
||||
// Check ownership
|
||||
const conversation = await prisma.omegaConversation.findUnique({
|
||||
where: { id },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
return apiNotFound('Conversation', requestId);
|
||||
}
|
||||
|
||||
if (conversation.ownerId !== authResult.userId) {
|
||||
return apiForbidden('Only the owner can delete this conversation', requestId);
|
||||
}
|
||||
|
||||
// Delete conversation (cascades to messages, participants, tool runs)
|
||||
await prisma.omegaConversation.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return apiSuccess({ deleted: true }, { requestId });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete Omega conversation:', error);
|
||||
return apiInternalError('Failed to delete conversation', requestId);
|
||||
}
|
||||
}
|
||||
140
apps/web/src/app/api/omega/conversations/route.ts
Normal file
140
apps/web/src/app/api/omega/conversations/route.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Omega Conversations Endpoint
|
||||
*
|
||||
* POST: Create a new Omega conversation
|
||||
* GET: List user's Omega conversations
|
||||
*/
|
||||
|
||||
import { prisma } from '@tpmjs/db';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { authenticateRequest } from '~/lib/api-keys/middleware';
|
||||
import { apiForbidden, apiInternalError, apiSuccess, apiUnauthorized } from '~/lib/api-response';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 30;
|
||||
|
||||
/**
|
||||
* GET /api/omega/conversations
|
||||
* List all Omega conversations for the authenticated user
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
const authResult = await authenticateRequest();
|
||||
if (!authResult.authenticated || !authResult.userId) {
|
||||
return apiUnauthorized('Authentication required', requestId);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(Number.parseInt(searchParams.get('limit') || '20', 10), 50);
|
||||
const offset = Number.parseInt(searchParams.get('offset') || '0', 10);
|
||||
|
||||
const conversations = await prisma.omegaConversation.findMany({
|
||||
where: { ownerId: authResult.userId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: limit + 1,
|
||||
skip: offset,
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
messages: true,
|
||||
toolRuns: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hasMore = conversations.length > limit;
|
||||
const data = hasMore ? conversations.slice(0, limit) : conversations;
|
||||
|
||||
return apiSuccess(
|
||||
data.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
executionState: c.executionState,
|
||||
inputTokensTotal: c.inputTokensTotal,
|
||||
outputTokensTotal: c.outputTokensTotal,
|
||||
messageCount: c._count.messages,
|
||||
toolRunCount: c._count.toolRuns,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
})),
|
||||
{
|
||||
requestId,
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
hasMore,
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to list Omega conversations:', error);
|
||||
return apiInternalError('Failed to list conversations', requestId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/omega/conversations
|
||||
* Create a new Omega conversation
|
||||
*/
|
||||
export async function POST(_request: NextRequest) {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
const authResult = await authenticateRequest();
|
||||
if (!authResult.authenticated || !authResult.userId) {
|
||||
return apiUnauthorized('Authentication required', requestId);
|
||||
}
|
||||
|
||||
// Get user info for participant
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: authResult.userId },
|
||||
select: { id: true, name: true, email: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return apiForbidden('User not found', requestId);
|
||||
}
|
||||
|
||||
const userId = authResult.userId;
|
||||
|
||||
// Create conversation with owner as participant
|
||||
const conversation = await prisma.$transaction(async (tx) => {
|
||||
const conv = await tx.omegaConversation.create({
|
||||
data: {
|
||||
ownerId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// Add owner as participant
|
||||
await tx.omegaParticipant.create({
|
||||
data: {
|
||||
conversationId: conv.id,
|
||||
userId: user.id,
|
||||
displayName: user.name,
|
||||
email: user.email,
|
||||
role: 'owner',
|
||||
},
|
||||
});
|
||||
|
||||
return conv;
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
id: conversation.id,
|
||||
title: conversation.title,
|
||||
executionState: conversation.executionState,
|
||||
createdAt: conversation.createdAt,
|
||||
},
|
||||
{ requestId, status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to create Omega conversation:', error);
|
||||
return apiInternalError('Failed to create conversation', requestId);
|
||||
}
|
||||
}
|
||||
672
apps/web/src/app/omega/[conversationId]/page.tsx
Normal file
672
apps/web/src/app/omega/[conversationId]/page.tsx
Normal file
|
|
@ -0,0 +1,672 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Textarea } from '@tpmjs/ui/Textarea/Textarea';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
||||
import { Streamdown } from 'streamdown';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'USER' | 'ASSISTANT' | 'TOOL';
|
||||
content: string;
|
||||
toolCalls?: Array<{
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
}>;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
status: 'pending' | 'running' | 'success' | 'error';
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string | null;
|
||||
executionState: string;
|
||||
inputTokensTotal: number;
|
||||
outputTokensTotal: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call debug card component
|
||||
*/
|
||||
function ToolCallCard({
|
||||
toolCall,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
toolCall: ToolCall;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const statusColors = {
|
||||
pending: 'bg-warning/10 text-warning border-warning/30',
|
||||
running: 'bg-info/10 text-info border-info/30',
|
||||
success: 'bg-success/10 text-success border-success/30',
|
||||
error: 'bg-error/10 text-error border-error/30',
|
||||
};
|
||||
|
||||
const statusIcons: Record<ToolCall['status'], 'loader' | 'check' | 'alertCircle' | 'info'> = {
|
||||
pending: 'info',
|
||||
running: 'loader',
|
||||
success: 'check',
|
||||
error: 'alertCircle',
|
||||
};
|
||||
|
||||
const formatJson = (data: unknown): React.ReactNode => {
|
||||
try {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-secondary/50 overflow-hidden font-mono text-xs">
|
||||
{/* Header */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-3 p-3 h-auto justify-start rounded-none hover:bg-surface-secondary/80"
|
||||
>
|
||||
<div className={`p-1.5 rounded ${statusColors[toolCall.status]}`}>
|
||||
<Icon
|
||||
icon={statusIcons[toolCall.status]}
|
||||
size="xs"
|
||||
className={toolCall.status === 'running' ? 'animate-spin' : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-foreground font-semibold">{toolCall.toolName}</span>
|
||||
<span className="text-foreground-tertiary text-[10px]">
|
||||
{toolCall.toolCallId.slice(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Icon
|
||||
icon="chevronRight"
|
||||
size="xs"
|
||||
className={`text-foreground-tertiary transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border">
|
||||
{/* Input Section */}
|
||||
{toolCall.input !== undefined && toolCall.input !== null ? (
|
||||
<div className="p-3 border-b border-border/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
|
||||
Input
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border/50" />
|
||||
</div>
|
||||
<pre className="text-[11px] text-foreground-secondary overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto">
|
||||
{formatJson(toolCall.input)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Output Section */}
|
||||
{toolCall.output !== undefined && toolCall.output !== null ? (
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
|
||||
Output
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border/50" />
|
||||
</div>
|
||||
<pre
|
||||
className={`text-[11px] overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto ${toolCall.isError ? 'text-error' : 'text-success'}`}
|
||||
>
|
||||
{formatJson(toolCall.output)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Status indicator for running */}
|
||||
{toolCall.status === 'running' && !toolCall.output && (
|
||||
<div className="p-3 flex items-center gap-2 text-foreground-tertiary">
|
||||
<Icon icon="loader" size="xs" className="animate-spin" />
|
||||
<span>Executing...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Omega Chat Page
|
||||
*/
|
||||
export default function OmegaChatPage(): React.ReactElement {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const conversationId = params.conversationId as string;
|
||||
|
||||
const [conversation, setConversation] = useState<Conversation | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toolCalls, setToolCalls] = useState<ToolCall[]>([]);
|
||||
const [expandedToolCalls, setExpandedToolCalls] = useState<Set<string>>(new Set());
|
||||
const [viewMode, setViewMode] = useState<'chat' | 'debug'>('chat');
|
||||
|
||||
// Track first item index for prepending (Virtuoso pattern)
|
||||
const [firstItemIndex, setFirstItemIndex] = useState(10000);
|
||||
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const toggleToolCall = (toolCallId: string) => {
|
||||
setExpandedToolCalls((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(toolCallId)) {
|
||||
next.delete(toolCallId);
|
||||
} else {
|
||||
next.add(toolCallId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Fetch conversation details
|
||||
const fetchConversation = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/omega/conversations/${conversationId}`);
|
||||
|
||||
if (response.status === 404) {
|
||||
setError('Conversation not found');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to fetch conversation');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// API returns conversation data directly with messages nested
|
||||
const { messages: messageList, ...conversationData } = data.data;
|
||||
setConversation(conversationData);
|
||||
setMessages(messageList || []);
|
||||
setFirstItemIndex(10000);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch conversation:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch conversation');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConversation();
|
||||
}, [fetchConversation]);
|
||||
|
||||
// Check for initial prompt from landing page
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on mount and when messages load
|
||||
useEffect(() => {
|
||||
const initialPrompt = sessionStorage.getItem(`omega_prompt_${conversationId}`);
|
||||
if (initialPrompt && messages.length === 0 && !isSending) {
|
||||
sessionStorage.removeItem(`omega_prompt_${conversationId}`);
|
||||
setInput(initialPrompt);
|
||||
// Auto-send after a brief delay
|
||||
const timer = setTimeout(() => {
|
||||
handleSendWithContent(initialPrompt);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [conversationId, messages.length]);
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Chat send handler with streaming and tool calls
|
||||
const handleSendWithContent = async (messageContent: string) => {
|
||||
if (!messageContent.trim() || isSending) return;
|
||||
|
||||
setInput('');
|
||||
setIsSending(true);
|
||||
setStreamingContent('');
|
||||
setError(null);
|
||||
setToolCalls([]);
|
||||
|
||||
// Optimistically add user message
|
||||
const userMessage: Message = {
|
||||
id: `temp-${Date.now()}`,
|
||||
role: 'USER',
|
||||
content: messageContent,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/omega/conversations/${conversationId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: messageContent }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to send message');
|
||||
}
|
||||
|
||||
// Handle SSE stream
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Parse SSE events
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
let eventType = '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
eventType = line.slice(7);
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
switch (eventType) {
|
||||
case 'message.delta':
|
||||
setStreamingContent((prev) => prev + data.content);
|
||||
break;
|
||||
case 'run.step.tool.started':
|
||||
// Add tool call to tracking
|
||||
setToolCalls((prev) => [
|
||||
...prev,
|
||||
{
|
||||
toolCallId: data.toolCallId,
|
||||
toolName: data.toolName,
|
||||
input: data.input,
|
||||
status: 'running',
|
||||
},
|
||||
]);
|
||||
// Auto-expand new tool calls
|
||||
setExpandedToolCalls((prev) => new Set([...prev, data.toolCallId]));
|
||||
break;
|
||||
case 'run.step.tool.completed':
|
||||
// Update tool call with result
|
||||
setToolCalls((prev) =>
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Tool call update logic
|
||||
prev.map((tc) =>
|
||||
tc.toolCallId === data.toolCallId
|
||||
? {
|
||||
...tc,
|
||||
output: data.output,
|
||||
status: data.isError ? ('error' as const) : ('success' as const),
|
||||
isError: data.isError,
|
||||
}
|
||||
: tc
|
||||
)
|
||||
);
|
||||
break;
|
||||
case 'run.completed':
|
||||
// Refresh messages
|
||||
await fetchConversation();
|
||||
setStreamingContent('');
|
||||
setToolCalls([]);
|
||||
break;
|
||||
case 'run.failed':
|
||||
throw new Error(data.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to send message');
|
||||
// Remove optimistic message on error
|
||||
setMessages((prev) => prev.filter((m) => m.id !== userMessage.id));
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
setStreamingContent('');
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isSending) return;
|
||||
await handleSendWithContent(input.trim());
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const startNewConversation = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/omega/conversations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to create conversation');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
router.push(`/omega/${data.data.id}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to create conversation:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to create conversation');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
<div className="flex items-center justify-center h-[calc(100vh-64px)]">
|
||||
<Icon icon="loader" size="lg" className="animate-spin text-foreground-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !conversation) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
<div className="flex items-center justify-center h-[calc(100vh-64px)]">
|
||||
<div className="text-center">
|
||||
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
|
||||
<h2 className="text-lg font-medium text-foreground mb-2">Unable to Load Chat</h2>
|
||||
<p className="text-foreground-secondary mb-4">{error}</p>
|
||||
<Link href="/omega">
|
||||
<Button>Start New Conversation</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<AppHeader />
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Chat Header */}
|
||||
<div className="border-b border-border bg-surface/50 px-4 py-3">
|
||||
<div className="flex items-center justify-between max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/omega"
|
||||
className="p-2 rounded-lg hover:bg-surface-secondary transition-colors"
|
||||
>
|
||||
<Icon icon="arrowLeft" size="sm" className="text-foreground-secondary" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
{conversation?.title || 'New Conversation'}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-foreground-tertiary">
|
||||
<Badge variant="secondary" size="sm">
|
||||
Omega
|
||||
</Badge>
|
||||
<span>GPT-4.1 Mini</span>
|
||||
{conversation && (
|
||||
<span className="text-xs">
|
||||
{conversation.inputTokensTotal + conversation.outputTokensTotal} tokens
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={startNewConversation}>
|
||||
<Icon icon="plus" size="xs" className="mr-2" />
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* View Mode Tabs */}
|
||||
<div className="flex gap-1 mt-3 max-w-4xl mx-auto">
|
||||
<Button
|
||||
variant={viewMode === 'chat' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('chat')}
|
||||
>
|
||||
Chat
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'debug' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('debug')}
|
||||
>
|
||||
Debug JSON
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debug JSON View */}
|
||||
{viewMode === 'debug' && (
|
||||
<div className="flex-1 overflow-auto p-4 bg-background">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-foreground mb-2">
|
||||
Raw Messages Array ({messages.length} messages)
|
||||
</h2>
|
||||
<p className="text-xs text-foreground-tertiary">
|
||||
Messages are ordered by createdAt. Each message includes role, content, and tool
|
||||
call data.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(JSON.stringify(messages, null, 2));
|
||||
}}
|
||||
>
|
||||
<Icon icon="copy" size="xs" className="mr-2" />
|
||||
Copy JSON
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs font-mono bg-surface-secondary border border-border rounded-lg p-4 overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(messages, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat View */}
|
||||
{viewMode === 'chat' && (
|
||||
<>
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{messages.length === 0 && !streamingContent ? (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
|
||||
<Icon icon="star" size="lg" className="text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
Start a conversation with Omega
|
||||
</h3>
|
||||
<p className="text-foreground-secondary max-w-sm">
|
||||
Describe what you need, and Omega will find and use the right tools from the
|
||||
TPMJS registry.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
className="h-full"
|
||||
data={messages}
|
||||
firstItemIndex={firstItemIndex}
|
||||
initialTopMostItemIndex={messages.length - 1}
|
||||
followOutput="smooth"
|
||||
components={{
|
||||
Footer: () => (
|
||||
<div className="px-4 pb-4 space-y-4 max-w-4xl mx-auto">
|
||||
{/* Live tool calls during streaming */}
|
||||
{toolCalls.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{toolCalls.map((tc) => (
|
||||
<div key={tc.toolCallId} className="flex justify-start">
|
||||
<div className="max-w-[80%]">
|
||||
<ToolCallCard
|
||||
toolCall={tc}
|
||||
isExpanded={expandedToolCalls.has(tc.toolCallId)}
|
||||
onToggle={() => toggleToolCall(tc.toolCallId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streamingContent && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-lg p-4 bg-surface-secondary">
|
||||
<div className="text-sm prose prose-sm dark:prose-invert max-w-none">
|
||||
<Streamdown>{streamingContent}</Streamdown>
|
||||
</div>
|
||||
<span className="inline-block w-2 h-4 bg-primary animate-pulse ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSending && !streamingContent && toolCalls.length === 0 && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-lg p-4 bg-surface-secondary">
|
||||
<div className="flex items-center gap-2 text-foreground-secondary">
|
||||
<Icon icon="loader" size="sm" className="animate-spin" />
|
||||
<span className="text-sm">Omega is thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
itemContent={(_index, message) => (
|
||||
<div className="px-4 py-2 max-w-4xl mx-auto">
|
||||
{/* USER message */}
|
||||
{message.role === 'USER' && (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[80%] rounded-lg p-4 bg-primary text-primary-foreground">
|
||||
<div className="text-sm whitespace-pre-wrap">{message.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ASSISTANT message */}
|
||||
{message.role === 'ASSISTANT' && message.content && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-lg p-4 bg-surface-secondary">
|
||||
<div className="text-sm prose prose-sm dark:prose-invert max-w-none">
|
||||
<Streamdown>{message.content}</Streamdown>
|
||||
</div>
|
||||
{/* Token usage for debugging */}
|
||||
{(message.inputTokens || message.outputTokens) && (
|
||||
<div className="mt-2 pt-2 border-t border-border/50 text-[10px] text-foreground-tertiary font-mono">
|
||||
{message.inputTokens && <span>In: {message.inputTokens}</span>}
|
||||
{message.inputTokens && message.outputTokens && <span> | </span>}
|
||||
{message.outputTokens && <span>Out: {message.outputTokens}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TOOL message - show as collapsed tool result */}
|
||||
{message.role === 'TOOL' && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%]">
|
||||
<div className="text-xs text-foreground-tertiary mb-1">
|
||||
Tool Results
|
||||
</div>
|
||||
<pre className="text-xs font-mono bg-surface-secondary border border-border rounded p-2 overflow-x-auto max-h-32 overflow-y-auto">
|
||||
{message.content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="px-4 py-2 bg-error/10 border-t border-error/20">
|
||||
<p className="text-sm text-error max-w-4xl mx-auto">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="border-t border-border p-4">
|
||||
<div className="flex items-end gap-2 max-w-4xl mx-auto">
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask Omega anything..."
|
||||
rows={1}
|
||||
resize="none"
|
||||
className="flex-1 min-h-[48px] max-h-[200px]"
|
||||
style={{
|
||||
height: 'auto',
|
||||
minHeight: '48px',
|
||||
}}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = 'auto';
|
||||
target.style.height = `${Math.min(target.scrollHeight, 200)}px`;
|
||||
}}
|
||||
/>
|
||||
<Button onClick={handleSend} disabled={isSending || !input.trim()}>
|
||||
<Icon icon="send" size="sm" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-tertiary mt-2 max-w-4xl mx-auto">
|
||||
Press Enter to send, Shift+Enter for new line
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
apps/web/src/app/omega/layout.tsx
Normal file
21
apps/web/src/app/omega/layout.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Omega - AI Agent with 1M+ Tools | TPMJS',
|
||||
description:
|
||||
'Chat with Omega, an AI assistant that can discover and use any tool from the TPMJS registry. Dynamic tool discovery and execution at your fingertips.',
|
||||
openGraph: {
|
||||
title: 'Omega - AI Agent with 1M+ Tools | TPMJS',
|
||||
description:
|
||||
'Chat with Omega, an AI assistant that can discover and use any tool from the TPMJS registry.',
|
||||
images: [{ url: '/api/og/omega', width: 1200, height: 630 }],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
images: ['/api/og/omega'],
|
||||
},
|
||||
};
|
||||
|
||||
export default function OmegaLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
221
apps/web/src/app/omega/page.tsx
Normal file
221
apps/web/src/app/omega/page.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
interface SamplePrompt {
|
||||
title: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
icon: 'globe' | 'search' | 'terminal' | 'box' | 'star' | 'folder';
|
||||
}
|
||||
|
||||
const SAMPLE_PROMPTS: SamplePrompt[] = [
|
||||
{
|
||||
title: 'Web Scraping',
|
||||
description: 'Extract content from any website',
|
||||
prompt: 'Scrape https://news.ycombinator.com and summarize the top 5 stories',
|
||||
icon: 'globe',
|
||||
},
|
||||
{
|
||||
title: 'Search & Research',
|
||||
description: 'Search the web for information',
|
||||
prompt: 'Search for the latest news about AI agents and summarize the key developments',
|
||||
icon: 'search',
|
||||
},
|
||||
{
|
||||
title: 'Code Generation',
|
||||
description: 'Generate code for various tasks',
|
||||
prompt: 'Find a tool that can generate QR codes and create one for https://tpmjs.com',
|
||||
icon: 'terminal',
|
||||
},
|
||||
{
|
||||
title: 'Image Processing',
|
||||
description: 'Work with images and files',
|
||||
prompt: 'Find image processing tools and tell me what they can do',
|
||||
icon: 'box',
|
||||
},
|
||||
{
|
||||
title: 'Data Analysis',
|
||||
description: 'Analyze and transform data',
|
||||
prompt: 'Search for data processing tools that can help me analyze JSON data',
|
||||
icon: 'folder',
|
||||
},
|
||||
{
|
||||
title: 'Creative Tasks',
|
||||
description: 'Generate content and ideas',
|
||||
prompt: 'Find a blog post creation tool and write a short post about the future of AI',
|
||||
icon: 'star',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Omega Landing Page
|
||||
* Shows sample prompts and starts new conversations
|
||||
*/
|
||||
export default function OmegaLandingPage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createConversation = useCallback(
|
||||
async (initialPrompt?: string) => {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/omega/conversations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to create conversation');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const conversationId = data.data.id;
|
||||
|
||||
// Navigate to chat, optionally with initial prompt
|
||||
if (initialPrompt) {
|
||||
sessionStorage.setItem(`omega_prompt_${conversationId}`, initialPrompt);
|
||||
}
|
||||
router.push(`/omega/${conversationId}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to create conversation:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to create conversation');
|
||||
setIsCreating(false);
|
||||
}
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// Check for auth on mount
|
||||
useEffect(() => {
|
||||
// Pre-warm the API by checking auth status
|
||||
fetch('/api/auth/session').catch(() => {
|
||||
// Silently fail - we'll handle auth errors when creating conversation
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<AppHeader />
|
||||
|
||||
<main className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-4xl">
|
||||
{/* Hero Section */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-gradient-to-br from-primary/20 to-accent/20 mb-6">
|
||||
<Icon icon="star" size="lg" className="text-primary" />
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-foreground mb-4">Meet Omega</h1>
|
||||
<p className="text-lg text-foreground-secondary max-w-2xl mx-auto">
|
||||
An AI assistant with access to over 1 million tools. Just describe what you need, and
|
||||
Omega will find and use the right tools to help you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Start New Conversation */}
|
||||
<div className="flex justify-center mb-12">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => createConversation()}
|
||||
disabled={isCreating}
|
||||
className="px-8"
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Icon icon="loader" size="sm" className="mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="plus" size="sm" className="mr-2" />
|
||||
Start New Conversation
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="text-center mb-8">
|
||||
<p className="text-sm text-error">{error}</p>
|
||||
<p className="text-xs text-foreground-tertiary mt-1">
|
||||
Make sure you're signed in to use Omega
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sample Prompts */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-sm font-medium text-foreground-tertiary text-center mb-6">
|
||||
Or try one of these examples
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{SAMPLE_PROMPTS.map((prompt) => (
|
||||
<button
|
||||
key={prompt.title}
|
||||
type="button"
|
||||
onClick={() => createConversation(prompt.prompt)}
|
||||
disabled={isCreating}
|
||||
className="p-4 bg-surface border border-border rounded-lg hover:border-foreground/20 hover:bg-surface-secondary/50 transition-all text-left group disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10 text-primary group-hover:bg-primary/20 transition-colors">
|
||||
<Icon icon={prompt.icon} size="sm" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-foreground mb-1">{prompt.title}</h3>
|
||||
<p className="text-sm text-foreground-secondary line-clamp-2">
|
||||
{prompt.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="mt-16 pt-8 border-t border-border">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
|
||||
<div>
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-success/10 mb-4">
|
||||
<Icon icon="search" size="sm" className="text-success" />
|
||||
</div>
|
||||
<h3 className="font-medium text-foreground mb-2">Dynamic Discovery</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Omega searches the entire TPMJS registry to find the perfect tools for your task
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-info/10 mb-4">
|
||||
<Icon icon="box" size="sm" className="text-info" />
|
||||
</div>
|
||||
<h3 className="font-medium text-foreground mb-2">Sandboxed Execution</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
All tools run in a secure sandbox environment for safe execution
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-warning/10 mb-4">
|
||||
<Icon icon="star" size="sm" className="text-warning" />
|
||||
</div>
|
||||
<h3 className="font-medium text-foreground mb-2">Intelligent Synthesis</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Results are combined and presented in a clear, helpful response
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
apps/web/src/lib/omega/system-prompt.ts
Normal file
78
apps/web/src/lib/omega/system-prompt.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Omega System Prompt
|
||||
*
|
||||
* Defines the default behavior for the Omega AI agent.
|
||||
*/
|
||||
|
||||
export const OMEGA_SYSTEM_PROMPT = `You are Omega, an AI assistant with access to tools from the TPMJS registry - a collection of AI-ready tools that can help you complete tasks.
|
||||
|
||||
## Your Capabilities
|
||||
|
||||
You have access to two special tools:
|
||||
|
||||
1. **registrySearch** - Search the TPMJS tool registry to find tools that can help complete a task. Returns tool metadata including toolId for use with registryExecute.
|
||||
2. **registryExecute** - Execute a tool from the registry by its toolId with the required parameters. Tools run in a secure sandbox.
|
||||
|
||||
## How to Help Users
|
||||
|
||||
When a user asks you to complete a task:
|
||||
|
||||
1. **Analyze the request** - Understand what the user wants to accomplish
|
||||
2. **Search for tools** - Use registrySearch to find relevant tools that can help
|
||||
3. **Select the best tool(s)** - Choose the most appropriate tool(s) based on the search results and their toolIds
|
||||
4. **Execute tools** - Use registryExecute to run the selected tools with the correct parameters (toolId and params)
|
||||
5. **Synthesize results** - Combine tool outputs into a helpful, clear response
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Be transparent** - Always explain which tools you're using and why
|
||||
- **Handle errors gracefully** - If a tool fails, explain what went wrong and try alternatives
|
||||
- **Validate inputs** - Make sure you have all required parameters before executing a tool
|
||||
- **Iterate when needed** - Some tasks may require multiple tool executions
|
||||
- **Ask for clarification** - If the user's request is ambiguous, ask questions before proceeding
|
||||
|
||||
## Tool Search Tips
|
||||
|
||||
When searching for tools:
|
||||
- Use descriptive keywords related to the task (e.g., "web scraping", "image processing", "API call")
|
||||
- If you don't find the right tool, try different search terms
|
||||
- Consider the tool's input schema to ensure you can provide the required parameters
|
||||
|
||||
## Response Format
|
||||
|
||||
When presenting results:
|
||||
- Summarize what you did and what tools you used
|
||||
- Present the output in a clear, readable format
|
||||
- If the output is large, highlight the most relevant parts
|
||||
- Offer to do more or explain further if needed
|
||||
|
||||
Remember: You're here to help users accomplish tasks efficiently by leveraging the vast TPMJS tool ecosystem. Be helpful, be clear, and be thorough.`;
|
||||
|
||||
/**
|
||||
* Generate a custom system prompt with user preferences
|
||||
*/
|
||||
export function buildSystemPrompt(options?: {
|
||||
customSystemPrompt?: string | null;
|
||||
pinnedToolIds?: string[];
|
||||
}): string {
|
||||
const parts: string[] = [OMEGA_SYSTEM_PROMPT];
|
||||
|
||||
if (options?.pinnedToolIds && options.pinnedToolIds.length > 0) {
|
||||
parts.push(`
|
||||
## Pinned Tools
|
||||
|
||||
The user has pinned the following tools as favorites. Consider using these first when they match the task:
|
||||
${options.pinnedToolIds.map((id) => `- Tool ID: ${id}`).join('\n')}`);
|
||||
}
|
||||
|
||||
if (options?.customSystemPrompt) {
|
||||
parts.push(`
|
||||
## User Instructions
|
||||
|
||||
The user has provided the following custom instructions:
|
||||
|
||||
${options.customSystemPrompt}`);
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
|
@ -1470,3 +1470,148 @@ model SkillsGenerationJob {
|
|||
@@index([status])
|
||||
@@map("skills_generation_jobs")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Omega Models (AI Agent Chat with Full Registry Access)
|
||||
// ============================================================================
|
||||
|
||||
/// OmegaConversation - chat session for Omega AI agent
|
||||
model OmegaConversation {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Owner relationship
|
||||
ownerId String @map("owner_id")
|
||||
|
||||
// Conversation metadata
|
||||
title String? @db.VarChar(200)
|
||||
|
||||
// Execution state
|
||||
executionState String @default("idle") @map("execution_state") @db.VarChar(20) // idle, running, paused, cancelled
|
||||
|
||||
// Token tracking (aggregate across all messages)
|
||||
inputTokensTotal Int @default(0) @map("input_tokens_total")
|
||||
outputTokensTotal Int @default(0) @map("output_tokens_total")
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
messages OmegaMessage[]
|
||||
participants OmegaParticipant[]
|
||||
toolRuns OmegaToolRun[]
|
||||
|
||||
@@index([ownerId])
|
||||
@@index([createdAt])
|
||||
@@map("omega_conversations")
|
||||
}
|
||||
|
||||
/// OmegaMessage - individual message in an Omega conversation
|
||||
model OmegaMessage {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Conversation relationship
|
||||
conversationId String @map("conversation_id")
|
||||
conversation OmegaConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Message content
|
||||
role MessageRole
|
||||
content String @db.Text
|
||||
|
||||
// Author info (for multi-user conversations)
|
||||
authorId String? @map("author_id")
|
||||
authorEmail String? @map("author_email")
|
||||
authorName String? @map("author_name") @db.VarChar(100)
|
||||
|
||||
// Tool calls (for ASSISTANT messages)
|
||||
toolCalls Json? @map("tool_calls") @db.JsonB
|
||||
|
||||
// Token tracking
|
||||
inputTokens Int? @map("input_tokens")
|
||||
outputTokens Int? @map("output_tokens")
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([conversationId])
|
||||
@@index([createdAt])
|
||||
@@map("omega_messages")
|
||||
}
|
||||
|
||||
/// OmegaParticipant - tracks users in an Omega conversation
|
||||
model OmegaParticipant {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Conversation relationship
|
||||
conversationId String @map("conversation_id")
|
||||
conversation OmegaConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
|
||||
// User info
|
||||
userId String? @map("user_id")
|
||||
displayName String @map("display_name") @db.VarChar(100)
|
||||
email String?
|
||||
|
||||
// Role in conversation
|
||||
role String @default("collaborator") @db.VarChar(20) // owner, collaborator
|
||||
|
||||
// Timestamps
|
||||
joinedAt DateTime @default(now()) @map("joined_at")
|
||||
|
||||
@@unique([conversationId, userId])
|
||||
@@index([conversationId])
|
||||
@@map("omega_participants")
|
||||
}
|
||||
|
||||
/// OmegaToolRun - tracks tool executions within Omega conversations
|
||||
model OmegaToolRun {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Conversation relationship
|
||||
conversationId String @map("conversation_id")
|
||||
conversation OmegaConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Message relationship (optional - for linking to specific message)
|
||||
messageId String? @map("message_id")
|
||||
|
||||
// Tool execution details
|
||||
toolName String @map("tool_name") @db.VarChar(200)
|
||||
input Json @db.JsonB
|
||||
output Json? @db.JsonB
|
||||
error String? @db.Text
|
||||
|
||||
// Status tracking
|
||||
status String @db.VarChar(20) // pending, running, success, error
|
||||
|
||||
// Timing
|
||||
startedAt DateTime @default(now()) @map("started_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
executionTimeMs Int? @map("execution_time_ms")
|
||||
|
||||
@@index([conversationId])
|
||||
@@index([status])
|
||||
@@map("omega_tool_runs")
|
||||
}
|
||||
|
||||
/// OmegaUserSettings - user preferences for Omega
|
||||
model OmegaUserSettings {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// User relationship
|
||||
userId String @unique @map("user_id")
|
||||
|
||||
// Tool preferences
|
||||
pinnedToolIds String[] @default([]) @map("pinned_tool_ids")
|
||||
blockedToolIds String[] @default([]) @map("blocked_tool_ids")
|
||||
|
||||
// Customization
|
||||
customSystemPrompt String? @map("custom_system_prompt") @db.Text
|
||||
|
||||
// UI preferences
|
||||
showDebugMode Boolean @default(false) @map("show_debug_mode")
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("omega_user_settings")
|
||||
}
|
||||
|
|
|
|||
1667
pnpm-lock.yaml
generated
1667
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue