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:
Ajax Davis 2026-01-23 09:03:10 +10:00
parent d2740c3f69
commit 3259e038fb
11 changed files with 2074 additions and 1650 deletions

View file

@ -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:*",

View 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);
}
}

View 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 }
);
}
}

View 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);
}
}

View 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);
}
}

View 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>
);
}

View 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;
}

View 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&apos;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>
);
}

View 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');
}

View file

@ -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

File diff suppressed because it is too large Load diff