diff --git a/apps/web/src/app/api/omega/conversations/[id]/messages/route.ts b/apps/web/src/app/api/omega/conversations/[id]/messages/route.ts index 437e240..81c7c46 100644 --- a/apps/web/src/app/api/omega/conversations/[id]/messages/route.ts +++ b/apps/web/src/app/api/omega/conversations/[id]/messages/route.ts @@ -4,19 +4,102 @@ * POST: Send a message and stream the AI response via SSE * * This endpoint implements the core Omega chat functionality with: - * - Automatic BM25 search to find relevant tools based on user message - * - Dynamic tool loading - top 15 matching tools become available to the AI + * - Static tools: registrySearchTool and registryExecuteTool for external users + * - Dynamic tool loading: tools found via search are injected as callable 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 { jsonSchema, type ModelMessage } from 'ai'; import { type NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { authenticateRequest } from '~/lib/api-keys/middleware'; +import { decryptApiKey } from '~/lib/crypto/api-keys'; import { buildSystemPrompt } from '~/lib/omega/system-prompt'; import { checkRateLimit, type RateLimitConfig } from '~/lib/rate-limit'; +/** + * Warning about missing environment variables + */ +interface EnvVarWarning { + toolId: string; + toolName: string; + packageName: string; + envVar: { + name: string; + description: string; + required: boolean; + }; +} + +/** + * Fetch and decrypt user's environment variables + */ +async function getUserEnvVarsDecrypted(userId: string): Promise> { + try { + const keys = await prisma.userApiKey.findMany({ where: { userId } }); + const result: Record = {}; + for (const key of keys) { + try { + result[key.keyName] = decryptApiKey(key.encryptedKey, key.keyIv); + } catch (error) { + console.error(`Failed to decrypt env var ${key.keyName}:`, error); + // Skip this key if decryption fails + } + } + return result; + } catch (error) { + console.error('Failed to fetch user env vars:', error); + return {}; + } +} + +/** + * Detect missing required environment variables for tools + */ +function detectMissingEnvVars( + tools: Array<{ + toolId: string; + packageName: string; + name: string; + env?: Array<{ name: string; description?: string; required?: boolean }> | null; + }>, + userEnvVars: Record +): EnvVarWarning[] { + const warnings: EnvVarWarning[] = []; + const userEnvKeys = new Set(Object.keys(userEnvVars)); + + for (const tool of tools) { + if (!tool.env || !Array.isArray(tool.env)) continue; + + for (const envVar of tool.env) { + // Only warn about required env vars that are not set + if (envVar.required && !userEnvKeys.has(envVar.name)) { + warnings.push({ + toolId: tool.toolId, + toolName: tool.name, + packageName: tool.packageName, + envVar: { + name: envVar.name, + description: envVar.description || '', + required: true, + }, + }); + } + } + } + + // Deduplicate by env var name (same env var might be needed by multiple tools) + const seen = new Set(); + return warnings.filter((w) => { + if (seen.has(w.envVar.name)) return false; + seen.add(w.envVar.name); + return true; + }); +} + /** * Rate limit for Omega chat: 20 requests per minute * Stricter limit because this involves multiple tool executions @@ -41,6 +124,10 @@ const SendMessageSchema = z.object({ // Executor service URL const EXECUTOR_URL = process.env.TPMJS_EXECUTOR_URL || 'https://executor.tpmjs.com'; +// In-memory conversation state for dynamically loaded tools +// biome-ignore lint/suspicious/noExplicitAny: Tool types from AI SDK are complex +const conversationStates = new Map }>(); + /** * Search for relevant tools based on user query */ @@ -56,6 +143,7 @@ async function searchRelevantTools( version: string; importUrl: string; inputSchema?: unknown; + env?: Array<{ name: string; description?: string; required?: boolean }>; }> > { const params = new URLSearchParams({ @@ -88,23 +176,27 @@ async function searchRelevantTools( version: tool.package.npmVersion, importUrl: `https://esm.sh/${tool.package.npmPackageName}@${tool.package.npmVersion}`, inputSchema: tool.inputSchema, + env: tool.package.env || [], })); } /** * Create a dynamic tool wrapper that executes via the sandbox executor */ -function createDynamicTool(toolMeta: { - toolId: string; - packageName: string; - name: string; - description: string; - version: string; - importUrl: string; - inputSchema?: unknown; -}) { +async function createDynamicTool( + toolMeta: { + toolId: string; + packageName: string; + name: string; + description: string; + version: string; + importUrl: string; + inputSchema?: unknown; + }, + userEnvVars: Record +) { // Import tool() dynamically to avoid top-level await - const { tool } = require('ai'); + const { tool } = await import('ai'); return tool({ description: toolMeta.description, @@ -128,7 +220,7 @@ function createDynamicTool(toolMeta: { version: toolMeta.version, importUrl: toolMeta.importUrl, params, - env: {}, // TODO: Pass user's env vars if needed + env: userEnvVars, }), }); @@ -158,6 +250,46 @@ function sanitizeToolName(name: string): string { .replace(/[^a-zA-Z0-9_]/g, ''); } +/** + * Add dynamically found tools to the conversation state + */ +async function addToolsToConversation( + conversationId: string, + toolMetas: Array<{ + toolId: string; + packageName: string; + name: string; + description: string; + version: string; + importUrl: string; + inputSchema?: unknown; + }>, + userEnvVars: Record +): Promise { + if (!conversationStates.has(conversationId)) { + conversationStates.set(conversationId, { loadedTools: {} }); + } + // biome-ignore lint/style/noNonNullAssertion: We just ensured it exists + const state = conversationStates.get(conversationId)!; + + const addedTools: string[] = []; + + for (const toolMeta of toolMetas) { + const sanitizedName = sanitizeToolName(toolMeta.toolId); + if (!state.loadedTools[sanitizedName]) { + try { + state.loadedTools[sanitizedName] = await createDynamicTool(toolMeta, userEnvVars); + addedTools.push(sanitizedName); + console.log(`✅ Added dynamic tool: ${sanitizedName}`); + } catch (error) { + console.error(`❌ Failed to create tool wrapper for ${toolMeta.toolId}:`, error); + } + } + } + + return addedTools; +} + /** * POST /api/omega/conversations/[id]/messages * Send a message and stream the AI response via SSE @@ -226,6 +358,10 @@ export async function POST(request: NextRequest, context: RouteContext): Promise where: { userId: authResult.userId }, }); + // Fetch user's environment variables (decrypted) for tool execution + const userEnvVars = await getUserEnvVarsDecrypted(authResult.userId); + console.log(`🔑 Loaded ${Object.keys(userEnvVars).length} user env vars`); + // Update conversation state to running await prisma.omegaConversation.update({ where: { id: conversationId }, @@ -244,26 +380,44 @@ export async function POST(request: NextRequest, context: RouteContext): Promise }, }); - // 🔍 Search for relevant tools based on user's message - console.log(`🔍 Searching for tools matching: "${parsed.data.message}"`); - const relevantTools = await searchRelevantTools(parsed.data.message, 15); - console.log(`📦 Found ${relevantTools.length} relevant tools`); + // Initialize or get conversation state + if (!conversationStates.has(conversationId)) { + conversationStates.set(conversationId, { loadedTools: {} }); + } + // biome-ignore lint/style/noNonNullAssertion: We just ensured it exists + const state = conversationStates.get(conversationId)!; - // Create dynamic tool wrappers for each found tool - // biome-ignore lint/suspicious/noExplicitAny: Dynamic tool types - const tools: Record = {}; + // 🔍 Auto-search for relevant tools based on user's message (BM25) + console.log(`🔍 Auto-searching for tools matching: "${parsed.data.message}"`); + const relevantTools = await searchRelevantTools(parsed.data.message, 10); + console.log(`📦 Found ${relevantTools.length} relevant tools via BM25`); - for (const toolMeta of relevantTools) { - const sanitizedName = sanitizeToolName(toolMeta.toolId); - try { - tools[sanitizedName] = createDynamicTool(toolMeta); - console.log(`✅ Loaded tool: ${sanitizedName}`); - } catch (error) { - console.error(`❌ Failed to create tool wrapper for ${toolMeta.toolId}:`, error); - } + // Add auto-discovered tools to conversation state + const autoAddedTools = await addToolsToConversation(conversationId, relevantTools, userEnvVars); + console.log(`✨ Auto-added ${autoAddedTools.length} new tools`); + + // Detect missing required environment variables + const missingEnvVars = detectMissingEnvVars(relevantTools, userEnvVars); + if (missingEnvVars.length > 0) { + console.log( + `⚠️ Missing ${missingEnvVars.length} required env vars:`, + missingEnvVars.map((w) => w.envVar.name) + ); } - console.log(`🔧 ${Object.keys(tools).length} tools available for this request`); + // Build final tools object: static tools + dynamically loaded tools + // biome-ignore lint/suspicious/noExplicitAny: Dynamic tool types + const tools: Record = { + // Static tools - these are the ones users can import into their own agents + registrySearchTool, + registryExecuteTool, + // Dynamically loaded tools from this conversation + ...state.loadedTools, + }; + + console.log( + `🔧 ${Object.keys(tools).length} total tools available (2 static + ${Object.keys(state.loadedTools).length} dynamic)` + ); // Fetch recent messages for context const recentMessages = await prisma.omegaMessage.findMany({ @@ -277,7 +431,12 @@ export async function POST(request: NextRequest, context: RouteContext): Promise const messages: ModelMessage[] = []; // Build tool list for system prompt - const toolsList = Object.entries(tools) + const staticToolsList = [ + '- registrySearchTool: Search the TPMJS registry to find AI SDK tools by keyword. Returns toolIds for registryExecuteTool.', + '- registryExecuteTool: Execute any tool from the TPMJS registry by toolId. Use registrySearchTool first to find tools.', + ].join('\n'); + + const dynamicToolsList = Object.entries(state.loadedTools) .map(([name, t]) => { const tool = t as { description?: string }; return `- ${name}: ${tool.description || 'No description'}`; @@ -292,18 +451,32 @@ export async function POST(request: NextRequest, context: RouteContext): Promise const systemPrompt = `${baseSystemPrompt} -## Available Tools +## Static Tools (Always Available) -The following tools have been automatically loaded based on the user's request. Use them directly to accomplish the task: +These tools let you access the entire TPMJS registry of 1M+ tools: -${toolsList || 'No tools matched this query. Try to help the user with general knowledge.'} +${staticToolsList} -## Instructions +## Dynamically Loaded Tools -1. If a tool is available that can help, USE IT immediately -2. Don't describe what tools could do - actually call them -3. After calling a tool, explain the results to the user -4. If no tools match, help the user with general knowledge`; +These tools have been discovered and loaded for this conversation. Call them directly: + +${dynamicToolsList || 'No tools loaded yet. Use registrySearchTool to find tools, or they will be auto-loaded based on your requests.'} + +## How to Use Tools + +1. **To find a tool**: Use registrySearchTool with a keyword (e.g., "weather", "web scraping", "database") +2. **To execute a found tool**: Use registryExecuteTool with the toolId returned from search +3. **Direct execution**: If a tool is already loaded above, call it directly by name + +## Example Workflow + +User: "Get the weather in Tokyo" +1. Use registrySearchTool to find weather tools +2. Use registryExecuteTool to execute the found tool + OR call a loaded tool directly if available + +Remember: Your value is in EXECUTING tools to get real results, not just describing what tools could do.`; messages.push({ role: 'system', content: systemPrompt }); @@ -396,6 +569,11 @@ ${toolsList || 'No tools matched this query. Try to help the user with general k controller.enqueue(encoder.encode(message)); }; + // Emit env var warnings at the start of the stream + if (missingEnvVars.length > 0) { + sendEvent('env.warning', { missingEnvVars }); + } + try { const { streamText, stepCountIs } = await import('ai'); @@ -414,7 +592,7 @@ ${toolsList || 'No tools matched this query. Try to help the user with general k model, messages, tools: Object.keys(tools).length > 0 ? tools : undefined, - stopWhen: stepCountIs(5), // Allow up to 5 tool calls + stopWhen: stepCountIs(10), // Allow up to 10 tool calls for search + execute patterns onChunk: async (chunk) => { // Handle tool call (complete tool call with args) if (chunk.chunk.type === 'tool-call') { @@ -465,6 +643,54 @@ ${toolsList || 'No tools matched this query. Try to help the user with general k const isError = tr.output && typeof tr.output === 'object' && 'error' in tr.output; + // Check if this is a registrySearchTool result - inject found tools + if ( + tr.toolName === 'registrySearchTool' && + tr.output && + typeof tr.output === 'object' + ) { + const searchOutput = tr.output as { + tools?: Array<{ + toolId: string; + package: string; + name: string; + description: string; + }>; + }; + if (searchOutput.tools && Array.isArray(searchOutput.tools)) { + console.log( + `🔍 registrySearchTool found ${searchOutput.tools.length} tools - injecting dynamically` + ); + + // Convert search results to tool metadata format and add to conversation + const toolMetas = searchOutput.tools.map((t) => { + const parts = t.toolId.split('::'); + const pkg = t.package || parts[0] || t.toolId; + const toolName = t.name || parts[1] || 'unknown'; + return { + toolId: t.toolId, + packageName: pkg, + name: toolName, + description: t.description || `Tool: ${toolName}`, + version: 'latest', + importUrl: `https://esm.sh/${pkg}`, + }; + }); + + const newlyAdded = await addToolsToConversation( + conversationId, + toolMetas, + userEnvVars + ); + if (newlyAdded.length > 0) { + sendEvent('tools.loaded', { + newTools: newlyAdded, + totalDynamicTools: Object.keys(state.loadedTools).length, + }); + } + } + } + // Build update data for tool run const toolRunUpdateData: Prisma.OmegaToolRunUpdateManyMutationInput = { output: tr.output as Prisma.InputJsonValue, @@ -582,9 +808,9 @@ ${toolsList || 'No tools matched this query. Try to help the user with general k inputTokens, outputTokens, toolCallCount: allToolCalls.length, - toolsLoaded: Object.keys(tools).length, - loadedToolNames: Object.keys(tools), - bm25Results: relevantTools.map((t) => ({ + staticTools: ['registrySearchTool', 'registryExecuteTool'], + dynamicToolsLoaded: Object.keys(state.loadedTools), + autoDiscoveredTools: relevantTools.map((t) => ({ toolId: t.toolId, name: t.name, packageName: t.packageName, diff --git a/apps/web/src/app/api/omega/settings/env-vars/[keyName]/route.ts b/apps/web/src/app/api/omega/settings/env-vars/[keyName]/route.ts new file mode 100644 index 0000000..58c52c3 --- /dev/null +++ b/apps/web/src/app/api/omega/settings/env-vars/[keyName]/route.ts @@ -0,0 +1,45 @@ +import { prisma } from '@tpmjs/db'; +import { headers } from 'next/headers'; +import { type NextRequest, NextResponse } from 'next/server'; + +import { auth } from '~/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type RouteContext = { + params: Promise<{ keyName: string }>; +}; + +/** + * DELETE /api/omega/settings/env-vars/[keyName] + * Remove an environment variable by name + */ +export async function DELETE(_request: NextRequest, context: RouteContext): Promise { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }); + } + + const { keyName } = await context.params; + + await prisma.userApiKey.deleteMany({ + where: { + userId: session.user.id, + keyName, + }, + }); + + return NextResponse.json({ + success: true, + data: { deleted: true }, + }); + } catch (error) { + console.error('Failed to delete env var:', error); + return NextResponse.json( + { success: false, error: 'Failed to delete env var' }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/omega/settings/env-vars/route.ts b/apps/web/src/app/api/omega/settings/env-vars/route.ts new file mode 100644 index 0000000..6746a8f --- /dev/null +++ b/apps/web/src/app/api/omega/settings/env-vars/route.ts @@ -0,0 +1,120 @@ +import { prisma } from '@tpmjs/db'; +import { headers } from 'next/headers'; +import { type NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { auth } from '~/lib/auth'; +import { encryptApiKey, getKeyHint } from '~/lib/crypto/api-keys'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const AddEnvVarSchema = z.object({ + keyName: z.string().min(1).max(100), + keyValue: z.string().min(1), +}); + +/** + * GET /api/omega/settings/env-vars + * List user's stored environment variables (masked) + */ +export async function GET(): Promise { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }); + } + + const envVars = await prisma.userApiKey.findMany({ + where: { userId: session.user.id }, + select: { + id: true, + keyName: true, + keyHint: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { keyName: 'asc' }, + }); + + return NextResponse.json({ + success: true, + data: envVars, + }); + } catch (error) { + console.error('Failed to list env vars:', error); + return NextResponse.json({ success: false, error: 'Failed to list env vars' }, { status: 500 }); + } +} + +/** + * POST /api/omega/settings/env-vars + * Add or update an environment variable + */ +export async function POST(request: NextRequest): Promise { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const parsed = AddEnvVarSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { success: false, error: 'Invalid request', details: parsed.error.flatten() }, + { status: 400 } + ); + } + + const { keyName, keyValue } = parsed.data; + + // Check encryption secret + if (!process.env.API_KEY_ENCRYPTION_SECRET) { + console.error('[omega-env-vars] API_KEY_ENCRYPTION_SECRET not set'); + return NextResponse.json( + { success: false, error: 'Server configuration error' }, + { status: 500 } + ); + } + + const { encrypted, iv } = encryptApiKey(keyValue); + const keyHint = getKeyHint(keyValue); + + const result = await prisma.userApiKey.upsert({ + where: { + userId_keyName: { + userId: session.user.id, + keyName, + }, + }, + create: { + userId: session.user.id, + keyName, + encryptedKey: encrypted, + keyIv: iv, + keyHint, + }, + update: { + encryptedKey: encrypted, + keyIv: iv, + keyHint, + }, + select: { + id: true, + keyName: true, + keyHint: true, + createdAt: true, + updatedAt: true, + }, + }); + + return NextResponse.json({ + success: true, + data: result, + }); + } catch (error) { + console.error('Failed to save env var:', error); + return NextResponse.json({ success: false, error: 'Failed to save env var' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/omega/[conversationId]/page.tsx b/apps/web/src/app/omega/[conversationId]/page.tsx index 3f76f09..18b60ef 100644 --- a/apps/web/src/app/omega/[conversationId]/page.tsx +++ b/apps/web/src/app/omega/[conversationId]/page.tsx @@ -9,6 +9,18 @@ import { useParams, useRouter } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Streamdown } from 'streamdown'; import { AppHeader } from '~/components/AppHeader'; +import { EnvVarWarningBanner } from '~/components/omega/EnvVarWarningBanner'; + +interface ToolDiscoveryInfo { + staticTools?: string[]; + dynamicToolsLoaded?: string[]; + autoDiscoveredTools?: Array<{ + toolId: string; + name: string; + packageName: string; + description: string; + }>; +} interface Message { id: string; @@ -22,6 +34,8 @@ interface Message { inputTokens?: number; outputTokens?: number; createdAt: string; + // Tool discovery info (populated from SSE events, per message) + toolDiscovery?: ToolDiscoveryInfo; } interface ToolCall { @@ -33,6 +47,17 @@ interface ToolCall { isError?: boolean; } +interface EnvVarWarning { + toolId: string; + toolName: string; + packageName: string; + envVar: { + name: string; + description: string; + required: boolean; + }; +} + interface Conversation { id: string; title: string | null; @@ -174,15 +199,12 @@ export default function OmegaChatPage(): React.ReactElement { const [toolCalls, setToolCalls] = useState([]); const [expandedToolCalls, setExpandedToolCalls] = useState>(new Set()); const [viewMode, setViewMode] = useState<'chat' | 'debug'>('chat'); - const [lastRunInfo, setLastRunInfo] = useState<{ - loadedToolNames?: string[]; - bm25Results?: Array<{ - toolId: string; - name: string; - packageName: string; - description: string; - }>; - } | null>(null); + // Map of message IDs to their tool discovery info (persisted in frontend state) + const [messageToolDiscovery, setMessageToolDiscovery] = useState>( + new Map() + ); + // Environment variable warnings for tools that need API keys + const [envWarnings, setEnvWarnings] = useState([]); const messagesContainerRef = useRef(null); const inputRef = useRef(null); @@ -311,6 +333,12 @@ export default function OmegaChatPage(): React.ReactElement { const data = JSON.parse(line.slice(6)); switch (eventType) { + case 'env.warning': + // Set environment variable warnings + if (data.missingEnvVars && Array.isArray(data.missingEnvVars)) { + setEnvWarnings(data.missingEnvVars); + } + break; case 'message.delta': setStreamingContent((prev) => prev + data.content); break; @@ -344,17 +372,39 @@ export default function OmegaChatPage(): React.ReactElement { ) ); break; - case 'run.completed': - // Capture tool info for debug view - setLastRunInfo({ - loadedToolNames: data.loadedToolNames, - bm25Results: data.bm25Results, - }); - // Refresh messages - await fetchConversation(); + case 'run.completed': { + // Capture tool info for this message + const toolDiscovery: ToolDiscoveryInfo = { + staticTools: data.staticTools, + dynamicToolsLoaded: data.dynamicToolsLoaded, + autoDiscoveredTools: data.autoDiscoveredTools, + }; + + // Refresh messages then attach tool discovery to the new assistant message + const refreshResponse = await fetch(`/api/omega/conversations/${conversationId}`); + if (refreshResponse.ok) { + const respData = await refreshResponse.json(); + const { messages: messageList, ...conversationData } = respData.data; + setConversation(conversationData); + setMessages(messageList || []); + + // Find the newest assistant message and associate tool discovery with it + const assistantMessages = (messageList || []).filter( + (m: Message) => m.role === 'ASSISTANT' + ); + if (assistantMessages.length > 0) { + const newestAssistant = assistantMessages[assistantMessages.length - 1]; + setMessageToolDiscovery((prev) => { + const next = new Map(prev); + next.set(newestAssistant.id, toolDiscovery); + return next; + }); + } + } setStreamingContent(''); setToolCalls([]); break; + } case 'run.failed': throw new Error(data.error); } @@ -467,6 +517,12 @@ export default function OmegaChatPage(): React.ReactElement {
+ + + +
- {/* Loaded Tool Names */} - {lastRunInfo?.loadedToolNames && lastRunInfo.loadedToolNames.length > 0 && ( -
-

- Loaded Tools ({lastRunInfo.loadedToolNames.length} tools) -

-

- Sanitized tool names available to the AI for this request. -

-
-
- {lastRunInfo.loadedToolNames.map((name) => ( - - {name} + {/* Per-Message View */} + {messages.map((message, index) => { + const discovery = messageToolDiscovery.get(message.id); + return ( +
+ {/* Message Header */} +
+
+
+ #{index + 1} + + {message.role} + + + {message.id.slice(0, 8)}... + +
+ + {new Date(message.createdAt).toLocaleTimeString()} - ))} +
+
+ + {/* Message Content */} +
+ {/* Content */} +
+
+ Content +
+
+                          {message.content || '(empty)'}
+                        
+
+ + {/* Tool Discovery (only for assistant messages) */} + {message.role === 'ASSISTANT' && discovery && ( +
+
+ Tool Discovery for this Response +
+ + {/* Static Tools */} + {discovery.staticTools && discovery.staticTools.length > 0 && ( +
+
+ Static Tools ({discovery.staticTools.length}) +
+
+ {discovery.staticTools.map((name) => ( + + {name} + + ))} +
+
+ )} + + {/* Auto-Discovered Tools */} + {discovery.autoDiscoveredTools && + discovery.autoDiscoveredTools.length > 0 && ( +
+
+ Auto-Discovered via BM25 ({discovery.autoDiscoveredTools.length}) +
+
+ {discovery.autoDiscoveredTools.map((tool) => ( +
+ {tool.packageName} + :: + {tool.name} + + - {tool.description?.slice(0, 60)} + {(tool.description?.length || 0) > 60 ? '...' : ''} + +
+ ))} +
+
+ )} + + {/* Dynamically Loaded Tools */} + {discovery.dynamicToolsLoaded && + discovery.dynamicToolsLoaded.length > 0 && ( +
+
+ Dynamic Tools Loaded ({discovery.dynamicToolsLoaded.length}) +
+
+ {discovery.dynamicToolsLoaded.map((name) => ( + + {name} + + ))} +
+
+ )} +
+ )} + + {/* Tool Calls */} + {message.toolCalls && message.toolCalls.length > 0 && ( +
+
+ Tool Calls ({message.toolCalls.length}) +
+
+                            {JSON.stringify(message.toolCalls, null, 2)}
+                          
+
+ )} + + {/* Token Usage */} + {(message.inputTokens || message.outputTokens) && ( +
+ {message.inputTokens && Input: {message.inputTokens}} + {message.inputTokens && message.outputTokens && | } + {message.outputTokens && Output: {message.outputTokens}} +
+ )}
+ ); + })} + + {/* Empty state */} + {messages.length === 0 && ( +
+ No messages yet. Start a conversation to see debug data.
)} - - {/* Messages Array */} -
-
-
-

- Raw Messages Array ({messages.length} messages) -

-

- Messages are ordered by createdAt. Each message includes role, content, and - tool call data. -

-
- -
-
-                  {JSON.stringify(messages, null, 2)}
-                
-
)} @@ -693,6 +854,13 @@ export default function OmegaChatPage(): React.ReactElement { )}
+ {/* Environment Variable Warnings */} + {envWarnings.length > 0 && ( +
+ setEnvWarnings([])} /> +
+ )} + {/* Error Message */} {error && (
diff --git a/apps/web/src/app/omega/page.tsx b/apps/web/src/app/omega/page.tsx index 53b500a..1883bc0 100644 --- a/apps/web/src/app/omega/page.tsx +++ b/apps/web/src/app/omega/page.tsx @@ -2,9 +2,11 @@ import { Button } from '@tpmjs/ui/Button/Button'; import { Icon } from '@tpmjs/ui/Icon/Icon'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; import { AppHeader } from '~/components/AppHeader'; +import { useSession } from '~/lib/auth-client'; interface SamplePrompt { title: string; @@ -58,11 +60,23 @@ const SAMPLE_PROMPTS: SamplePrompt[] = [ */ export default function OmegaLandingPage(): React.ReactElement { const router = useRouter(); + const { data: session, isPending: isSessionLoading } = useSession(); const [isCreating, setIsCreating] = useState(false); const [error, setError] = useState(null); + const isAuthenticated = !!session?.user; + const createConversation = useCallback( async (initialPrompt?: string) => { + if (!isAuthenticated) { + // Redirect to sign-in if not authenticated + const returnUrl = initialPrompt + ? `/omega?prompt=${encodeURIComponent(initialPrompt)}` + : '/omega'; + router.push(`/sign-in?returnTo=${encodeURIComponent(returnUrl)}`); + return; + } + setIsCreating(true); setError(null); @@ -74,7 +88,12 @@ export default function OmegaLandingPage(): React.ReactElement { if (!response.ok) { const data = await response.json(); - throw new Error(data.error || 'Failed to create conversation'); + if (response.status === 401) { + // Session expired, redirect to sign-in + router.push(`/sign-in?returnTo=${encodeURIComponent('/omega')}`); + return; + } + throw new Error(data.error?.message || data.error || 'Failed to create conversation'); } const data = await response.json(); @@ -91,16 +110,22 @@ export default function OmegaLandingPage(): React.ReactElement { setIsCreating(false); } }, - [router] + [router, isAuthenticated] ); - // Check for auth on mount + // Handle prompt query parameter (redirect from sign-in) useEffect(() => { - // Pre-warm the API by checking auth status - fetch('/api/auth/session').catch(() => { - // Silently fail - we'll handle auth errors when creating conversation - }); - }, []); + if (isAuthenticated && typeof window !== 'undefined') { + const params = new URLSearchParams(window.location.search); + const prompt = params.get('prompt'); + if (prompt) { + // Clear the query parameter + window.history.replaceState({}, '', '/omega'); + // Create conversation with the prompt + createConversation(prompt); + } + } + }, [isAuthenticated, createConversation]); return (
@@ -122,40 +147,55 @@ export default function OmegaLandingPage(): React.ReactElement { {/* Start New Conversation */}
- + {isSessionLoading ? ( + + ) : isAuthenticated ? ( + + ) : ( +
+ + + Sign In to Start + +

Sign in to start using Omega

+
+ )}
{/* Error Message */} {error && (

{error}

-

- Make sure you're signed in to use Omega -

)} {/* Sample Prompts */}

- Or try one of these examples + {isAuthenticated ? 'Or try one of these examples' : 'Explore what Omega can do'}

{SAMPLE_PROMPTS.map((prompt) => ( @@ -163,7 +203,7 @@ export default function OmegaLandingPage(): React.ReactElement { key={prompt.title} type="button" onClick={() => createConversation(prompt.prompt)} - disabled={isCreating} + disabled={isCreating || isSessionLoading} 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" >
diff --git a/apps/web/src/app/omega/settings/page.tsx b/apps/web/src/app/omega/settings/page.tsx new file mode 100644 index 0000000..7f2ca5d --- /dev/null +++ b/apps/web/src/app/omega/settings/page.tsx @@ -0,0 +1,457 @@ +'use client'; + +import { Button } from '@tpmjs/ui/Button/Button'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import { Input } from '@tpmjs/ui/Input/Input'; +import { Textarea } from '@tpmjs/ui/Textarea/Textarea'; +import Link from 'next/link'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { AppHeader } from '~/components/AppHeader'; +import { useSession } from '~/lib/auth-client'; +import { parseEnvString } from '~/lib/utils/env-parser'; + +interface EnvVar { + id: string; + keyName: string; + keyHint: string | null; + createdAt: string; + updatedAt: string; +} + +/** + * Omega Settings Page + * Manage API keys and environment variables for tool execution + */ +export default function OmegaSettingsPage(): React.ReactElement { + const { data: session, isPending: isSessionLoading } = useSession(); + const [envVars, setEnvVars] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // New env var form state + const [newKeyName, setNewKeyName] = useState(''); + const [newKeyValue, setNewKeyValue] = useState(''); + + // Paste .env modal state + const [showPasteModal, setShowPasteModal] = useState(false); + const [pasteContent, setPasteContent] = useState(''); + + const isAuthenticated = !!session?.user; + + // Fetch env vars + const fetchEnvVars = useCallback(async () => { + try { + const response = await fetch('/api/omega/settings/env-vars'); + if (!response.ok) { + if (response.status === 401) { + setEnvVars([]); + return; + } + throw new Error('Failed to fetch env vars'); + } + const data = await response.json(); + setEnvVars(data.data || []); + } catch (err) { + console.error('Failed to fetch env vars:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch env vars'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (isAuthenticated) { + fetchEnvVars(); + } else if (!isSessionLoading) { + setIsLoading(false); + } + }, [isAuthenticated, isSessionLoading, fetchEnvVars]); + + // Add new env var + const handleAddEnvVar = async () => { + if (!newKeyName.trim() || !newKeyValue.trim()) return; + + setIsSaving(true); + setError(null); + setSuccess(null); + + try { + const response = await fetch('/api/omega/settings/env-vars', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + keyName: newKeyName.trim().toUpperCase(), + keyValue: newKeyValue, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to save env var'); + } + + setNewKeyName(''); + setNewKeyValue(''); + setSuccess(`Added ${newKeyName.trim().toUpperCase()}`); + await fetchEnvVars(); + } catch (err) { + console.error('Failed to add env var:', err); + setError(err instanceof Error ? err.message : 'Failed to add env var'); + } finally { + setIsSaving(false); + } + }; + + // Delete env var + const handleDeleteEnvVar = async (keyName: string) => { + if (!confirm(`Delete ${keyName}?`)) return; + + setError(null); + setSuccess(null); + + try { + const response = await fetch(`/api/omega/settings/env-vars/${encodeURIComponent(keyName)}`, { + method: 'DELETE', + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to delete env var'); + } + + setSuccess(`Deleted ${keyName}`); + await fetchEnvVars(); + } catch (err) { + console.error('Failed to delete env var:', err); + setError(err instanceof Error ? err.message : 'Failed to delete env var'); + } + }; + + // Parse pasted content preview + const parsedPreview = useMemo(() => { + if (!pasteContent.trim()) return []; + return parseEnvString(pasteContent); + }, [pasteContent]); + + // Handle pasting .env content + const handlePasteEnv = async () => { + const parsed = parseEnvString(pasteContent); + if (parsed.length === 0) return; + + setIsSaving(true); + setError(null); + setSuccess(null); + + try { + // Add each env var + for (const { key, value } of parsed) { + const response = await fetch('/api/omega/settings/env-vars', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keyName: key, keyValue: value }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || `Failed to save ${key}`); + } + } + + setPasteContent(''); + setShowPasteModal(false); + setSuccess(`Added ${parsed.length} variable${parsed.length !== 1 ? 's' : ''}`); + await fetchEnvVars(); + } catch (err) { + console.error('Failed to import env vars:', err); + setError(err instanceof Error ? err.message : 'Failed to import env vars'); + } finally { + setIsSaving(false); + } + }; + + if (isSessionLoading || isLoading) { + return ( +
+ +
+ +
+
+ ); + } + + if (!isAuthenticated) { + return ( +
+ +
+
+ +

Sign In Required

+

+ Please sign in to manage your Omega settings. +

+ + + +
+
+
+ ); + } + + return ( +
+ + +
+
+ {/* Header */} +
+ + + +
+

Omega Settings

+

Configure API keys for tool execution

+
+
+ + {/* Success/Error Messages */} + {success && ( +
+
+ + {success} +
+
+ )} + + {error && ( +
+
+ + {error} +
+
+ )} + + {/* API Keys Section */} +
+
+
+

+ API Keys & Environment Variables +

+

+ Store API keys that tools need to execute. These are encrypted and only used + during tool execution. +

+
+ +
+ + {/* Paste .env modal */} + {showPasteModal && ( +
+
+ Paste .env snippet + +
+