feat(omega): add environment variable management for tool API keys

- Add /api/omega/settings/env-vars API endpoints for CRUD operations
- Add /omega/settings page with env var management UI
- Pass user env vars to tool execution in messages route
- Detect and warn about missing required env vars via SSE events
- Add EnvVarWarningBanner component to chat UI
- Update system prompt to document registry tools usage
- Improve auth flow on landing page with sign-in redirect
This commit is contained in:
Ajax Davis 2026-01-23 17:43:22 +10:00
parent 19a905d9d1
commit 0d3395b32f
8 changed files with 1323 additions and 161 deletions

View file

@ -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<Record<string, string>> {
try {
const keys = await prisma.userApiKey.findMany({ where: { userId } });
const result: Record<string, string> = {};
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<string, string>
): 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<string>();
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<string, { loadedTools: Record<string, any> }>();
/**
* 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<string, string>
) {
// 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<string, string>
): Promise<string[]> {
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<string, any> = {};
// 🔍 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<string, any> = {
// 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,

View file

@ -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<NextResponse> {
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 }
);
}
}

View file

@ -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<NextResponse> {
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<NextResponse> {
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 });
}
}

View file

@ -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<ToolCall[]>([]);
const [expandedToolCalls, setExpandedToolCalls] = useState<Set<string>>(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<Map<string, ToolDiscoveryInfo>>(
new Map()
);
// Environment variable warnings for tools that need API keys
const [envWarnings, setEnvWarnings] = useState<EnvVarWarning[]>([]);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(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 {
</div>
</div>
<div className="flex items-center gap-2">
<Link href="/omega/settings">
<Button variant="ghost" size="sm">
<Icon icon="key" size="xs" className="mr-2" />
Settings
</Button>
</Link>
<Button variant="ghost" size="sm" onClick={startNewConversation}>
<Icon icon="plus" size="xs" className="mr-2" />
New Chat
@ -496,80 +552,185 @@ export default function OmegaChatPage(): React.ReactElement {
{viewMode === 'debug' && (
<div className="flex-1 overflow-auto p-4 bg-background">
<div className="max-w-4xl mx-auto space-y-6">
{/* BM25 Search Results */}
{lastRunInfo?.bm25Results && lastRunInfo.bm25Results.length > 0 && (
{/* Header with Copy Button */}
<div className="flex items-start justify-between">
<div>
<h2 className="text-sm font-medium text-foreground mb-2">
BM25 Search Results ({lastRunInfo.bm25Results.length} tools found)
Messages with Tool Discovery ({messages.length} messages)
</h2>
<p className="text-xs text-foreground-tertiary mb-3">
Tools discovered from the registry based on the user&apos;s message.
<p className="text-xs text-foreground-tertiary">
Each message shows the tools discovered and loaded during that response.
</p>
<div className="bg-surface-secondary border border-border rounded-lg p-4 space-y-2">
{lastRunInfo.bm25Results.map((tool, i) => (
<div key={tool.toolId} className="text-xs font-mono">
<span className="text-foreground-tertiary">{i + 1}.</span>{' '}
<span className="text-primary">{tool.packageName}</span>
<span className="text-foreground-tertiary">::</span>
<span className="text-foreground">{tool.name}</span>
<p className="ml-4 text-foreground-secondary">{tool.description}</p>
</div>
))}
</div>
</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => {
// Merge tool discovery info into messages for export
const messagesWithTools = messages.map((m) => ({
...m,
toolDiscovery: messageToolDiscovery.get(m.id) || null,
}));
navigator.clipboard.writeText(JSON.stringify(messagesWithTools, null, 2));
}}
>
<Icon icon="copy" size="xs" className="mr-2" />
Copy All JSON
</Button>
</div>
{/* Loaded Tool Names */}
{lastRunInfo?.loadedToolNames && lastRunInfo.loadedToolNames.length > 0 && (
<div>
<h2 className="text-sm font-medium text-foreground mb-2">
Loaded Tools ({lastRunInfo.loadedToolNames.length} tools)
</h2>
<p className="text-xs text-foreground-tertiary mb-3">
Sanitized tool names available to the AI for this request.
</p>
<div className="bg-surface-secondary border border-border rounded-lg p-4">
<div className="flex flex-wrap gap-2">
{lastRunInfo.loadedToolNames.map((name) => (
<span
key={name}
className="px-2 py-1 bg-primary/10 text-primary text-xs font-mono rounded"
>
{name}
{/* Per-Message View */}
{messages.map((message, index) => {
const discovery = messageToolDiscovery.get(message.id);
return (
<div
key={message.id}
className="bg-surface-secondary border border-border rounded-lg overflow-hidden"
>
{/* Message Header */}
<div className="px-4 py-3 border-b border-border/50 bg-surface/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-foreground-tertiary">#{index + 1}</span>
<Badge
variant={
message.role === 'USER'
? 'secondary'
: message.role === 'ASSISTANT'
? 'default'
: 'outline'
}
size="sm"
>
{message.role}
</Badge>
<span className="text-xs text-foreground-tertiary font-mono">
{message.id.slice(0, 8)}...
</span>
</div>
<span className="text-xs text-foreground-tertiary">
{new Date(message.createdAt).toLocaleTimeString()}
</span>
))}
</div>
</div>
{/* Message Content */}
<div className="p-4 space-y-4">
{/* Content */}
<div>
<div className="text-[10px] uppercase tracking-wider text-foreground-tertiary mb-2">
Content
</div>
<pre className="text-xs font-mono bg-background/50 rounded p-3 overflow-x-auto whitespace-pre-wrap max-h-48 overflow-y-auto">
{message.content || '(empty)'}
</pre>
</div>
{/* Tool Discovery (only for assistant messages) */}
{message.role === 'ASSISTANT' && discovery && (
<div className="space-y-3 pt-3 border-t border-border/50">
<div className="text-[10px] uppercase tracking-wider text-foreground-tertiary">
Tool Discovery for this Response
</div>
{/* Static Tools */}
{discovery.staticTools && discovery.staticTools.length > 0 && (
<div>
<div className="text-xs text-foreground-secondary mb-1">
Static Tools ({discovery.staticTools.length})
</div>
<div className="flex flex-wrap gap-1">
{discovery.staticTools.map((name) => (
<span
key={name}
className="px-2 py-0.5 bg-blue-500/10 text-blue-500 text-[10px] font-mono rounded"
>
{name}
</span>
))}
</div>
</div>
)}
{/* Auto-Discovered Tools */}
{discovery.autoDiscoveredTools &&
discovery.autoDiscoveredTools.length > 0 && (
<div>
<div className="text-xs text-foreground-secondary mb-1">
Auto-Discovered via BM25 ({discovery.autoDiscoveredTools.length})
</div>
<div className="space-y-1">
{discovery.autoDiscoveredTools.map((tool) => (
<div
key={tool.toolId}
className="text-[10px] font-mono bg-background/50 rounded px-2 py-1"
>
<span className="text-primary">{tool.packageName}</span>
<span className="text-foreground-tertiary">::</span>
<span className="text-foreground">{tool.name}</span>
<span className="text-foreground-tertiary ml-2">
- {tool.description?.slice(0, 60)}
{(tool.description?.length || 0) > 60 ? '...' : ''}
</span>
</div>
))}
</div>
</div>
)}
{/* Dynamically Loaded Tools */}
{discovery.dynamicToolsLoaded &&
discovery.dynamicToolsLoaded.length > 0 && (
<div>
<div className="text-xs text-foreground-secondary mb-1">
Dynamic Tools Loaded ({discovery.dynamicToolsLoaded.length})
</div>
<div className="flex flex-wrap gap-1">
{discovery.dynamicToolsLoaded.map((name) => (
<span
key={name}
className="px-2 py-0.5 bg-primary/10 text-primary text-[10px] font-mono rounded"
>
{name}
</span>
))}
</div>
</div>
)}
</div>
)}
{/* Tool Calls */}
{message.toolCalls && message.toolCalls.length > 0 && (
<div className="pt-3 border-t border-border/50">
<div className="text-[10px] uppercase tracking-wider text-foreground-tertiary mb-2">
Tool Calls ({message.toolCalls.length})
</div>
<pre className="text-xs font-mono bg-background/50 rounded p-3 overflow-x-auto whitespace-pre-wrap max-h-48 overflow-y-auto">
{JSON.stringify(message.toolCalls, null, 2)}
</pre>
</div>
)}
{/* Token Usage */}
{(message.inputTokens || message.outputTokens) && (
<div className="text-[10px] text-foreground-tertiary font-mono pt-2 border-t border-border/50">
{message.inputTokens && <span>Input: {message.inputTokens}</span>}
{message.inputTokens && message.outputTokens && <span> | </span>}
{message.outputTokens && <span>Output: {message.outputTokens}</span>}
</div>
)}
</div>
</div>
);
})}
{/* Empty state */}
{messages.length === 0 && (
<div className="text-center py-8 text-foreground-tertiary">
No messages yet. Start a conversation to see debug data.
</div>
)}
{/* Messages Array */}
<div>
<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>
</div>
)}
@ -693,6 +854,13 @@ export default function OmegaChatPage(): React.ReactElement {
)}
</div>
{/* Environment Variable Warnings */}
{envWarnings.length > 0 && (
<div className="max-w-4xl mx-auto">
<EnvVarWarningBanner warnings={envWarnings} onDismiss={() => setEnvWarnings([])} />
</div>
)}
{/* Error Message */}
{error && (
<div className="px-4 py-2 bg-error/10 border-t border-error/20">

View file

@ -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<string | null>(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 (
<div className="min-h-screen bg-background flex flex-col">
@ -122,40 +147,55 @@ export default function OmegaLandingPage(): React.ReactElement {
{/* 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>
{isSessionLoading ? (
<Button size="lg" disabled className="px-8">
<Icon icon="loader" size="sm" className="mr-2 animate-spin" />
Loading...
</Button>
) : isAuthenticated ? (
<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 className="flex flex-col items-center gap-4">
<Link
href={`/sign-in?returnTo=${encodeURIComponent('/omega')}`}
className="inline-flex items-center justify-center px-8 py-3 text-base font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Icon icon="user" size="sm" className="mr-2" />
Sign In to Start
</Link>
<p className="text-sm text-foreground-tertiary">Sign in to start using Omega</p>
</div>
)}
</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
{isAuthenticated ? 'Or try one of these examples' : 'Explore what Omega can do'}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{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"
>
<div className="flex items-start gap-3">

View file

@ -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<EnvVar[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(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 (
<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 (!isAuthenticated) {
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="key" size="lg" className="mx-auto text-foreground-secondary mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">Sign In Required</h2>
<p className="text-foreground-secondary mb-4">
Please sign in to manage your Omega settings.
</p>
<Link href="/sign-in?returnTo=/omega/settings">
<Button>Sign In</Button>
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background flex flex-col">
<AppHeader />
<main className="flex-1 p-4 md:p-8">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<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-2xl font-bold text-foreground">Omega Settings</h1>
<p className="text-foreground-secondary">Configure API keys for tool execution</p>
</div>
</div>
{/* Success/Error Messages */}
{success && (
<div className="mb-6 p-4 rounded-lg bg-success/10 border border-success/30">
<div className="flex items-center gap-2 text-success">
<Icon icon="check" size="sm" />
<span>{success}</span>
</div>
</div>
)}
{error && (
<div className="mb-6 p-4 rounded-lg bg-error/10 border border-error/30">
<div className="flex items-center gap-2 text-error">
<Icon icon="alertCircle" size="sm" />
<span>{error}</span>
</div>
</div>
)}
{/* API Keys Section */}
<div className="bg-surface border border-border rounded-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-foreground">
API Keys & Environment Variables
</h2>
<p className="text-sm text-foreground-tertiary mt-1">
Store API keys that tools need to execute. These are encrypted and only used
during tool execution.
</p>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => setShowPasteModal(!showPasteModal)}
disabled={isSaving}
>
<Icon icon="copy" size="xs" className="mr-1" />
Paste .env
</Button>
</div>
{/* Paste .env modal */}
{showPasteModal && (
<div className="mb-6 p-4 bg-surface-secondary rounded-lg border border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">Paste .env snippet</span>
<Button size="sm" variant="ghost" onClick={() => setShowPasteModal(false)}>
<Icon icon="x" size="xs" />
</Button>
</div>
<Textarea
value={pasteContent}
onChange={(e) => setPasteContent(e.target.value)}
placeholder={`# Paste your .env content here\nOPENAI_API_KEY=sk-...\nANTHROPIC_API_KEY=sk-ant-...\n`}
className="h-32 text-xs font-mono"
resize="none"
disabled={isSaving}
/>
{parsedPreview.length > 0 && (
<div className="mt-2 text-xs text-foreground-secondary">
<span className="font-medium">Preview:</span> {parsedPreview.length} variable
{parsedPreview.length !== 1 ? 's' : ''} found
<span className="text-foreground-tertiary ml-1">
({parsedPreview.map((e) => e.key).join(', ')})
</span>
</div>
)}
<div className="flex justify-end mt-3">
<Button
size="sm"
onClick={handlePasteEnv}
disabled={parsedPreview.length === 0 || isSaving}
>
{isSaving ? (
<>
<Icon icon="loader" size="xs" className="mr-1 animate-spin" />
Importing...
</>
) : (
<>
<Icon icon="plus" size="xs" className="mr-1" />
Add {parsedPreview.length} Variable{parsedPreview.length !== 1 ? 's' : ''}
</>
)}
</Button>
</div>
</div>
)}
{/* Existing env vars */}
{envVars.length > 0 && (
<div className="mb-6">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="text-left text-xs font-medium text-foreground-tertiary uppercase tracking-wider py-2">
Key Name
</th>
<th className="text-left text-xs font-medium text-foreground-tertiary uppercase tracking-wider py-2">
Hint
</th>
<th className="text-left text-xs font-medium text-foreground-tertiary uppercase tracking-wider py-2">
Added
</th>
<th className="text-right text-xs font-medium text-foreground-tertiary uppercase tracking-wider py-2">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{envVars.map((envVar) => (
<tr key={envVar.id} className="group">
<td className="py-3">
<code className="text-sm font-mono text-foreground">
{envVar.keyName}
</code>
</td>
<td className="py-3">
<span className="text-sm text-foreground-secondary font-mono">
****{envVar.keyHint || '****'}
</span>
</td>
<td className="py-3">
<span className="text-sm text-foreground-tertiary">
{new Date(envVar.createdAt).toLocaleDateString()}
</span>
</td>
<td className="py-3 text-right">
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteEnvVar(envVar.keyName)}
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
<Icon icon="trash" size="xs" className="text-error" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Empty state */}
{envVars.length === 0 && (
<div className="mb-6 p-6 rounded-lg border border-dashed border-border text-center">
<Icon icon="key" size="lg" className="mx-auto text-foreground-tertiary mb-3" />
<p className="text-foreground-secondary">No API keys configured yet</p>
<p className="text-sm text-foreground-tertiary mt-1">
Add API keys that tools need for execution
</p>
</div>
)}
{/* Add new env var form */}
<div className="flex items-center gap-2">
<Input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value.toUpperCase())}
placeholder="KEY_NAME"
size="sm"
className="flex-1 font-mono"
disabled={isSaving}
onKeyDown={(e) => {
if (e.key === 'Enter' && newKeyName.trim() && newKeyValue.trim()) {
handleAddEnvVar();
}
}}
/>
<Input
type="password"
value={newKeyValue}
onChange={(e) => setNewKeyValue(e.target.value)}
placeholder="Value"
size="sm"
className="flex-1"
disabled={isSaving}
onKeyDown={(e) => {
if (e.key === 'Enter' && newKeyName.trim() && newKeyValue.trim()) {
handleAddEnvVar();
}
}}
/>
<Button
size="sm"
onClick={handleAddEnvVar}
disabled={!newKeyName.trim() || !newKeyValue.trim() || isSaving}
>
{isSaving ? (
<Icon icon="loader" size="xs" className="animate-spin" />
) : (
<>
<Icon icon="plus" size="xs" className="mr-1" />
Add
</>
)}
</Button>
</div>
</div>
{/* Common API Keys Section */}
<div className="bg-surface border border-border rounded-lg p-6">
<h2 className="text-lg font-semibold text-foreground mb-3">Common API Keys</h2>
<p className="text-sm text-foreground-tertiary mb-4">
Here are some common API keys that tools might need:
</p>
<div className="space-y-2">
<div className="flex items-center gap-3 p-3 rounded-lg bg-surface-secondary">
<code className="text-xs font-mono text-foreground flex-1">OPENAI_API_KEY</code>
<span className="text-xs text-foreground-tertiary">OpenAI API tools</span>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-surface-secondary">
<code className="text-xs font-mono text-foreground flex-1">ANTHROPIC_API_KEY</code>
<span className="text-xs text-foreground-tertiary">Anthropic Claude tools</span>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-surface-secondary">
<code className="text-xs font-mono text-foreground flex-1">SERPAPI_API_KEY</code>
<span className="text-xs text-foreground-tertiary">Search tools</span>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-surface-secondary">
<code className="text-xs font-mono text-foreground flex-1">HLLM_API_KEY</code>
<span className="text-xs text-foreground-tertiary">HLLM tools</span>
</div>
</div>
</div>
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,81 @@
'use client';
import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
interface EnvVarWarning {
toolId: string;
toolName: string;
packageName: string;
envVar: {
name: string;
description: string;
required: boolean;
};
}
interface EnvVarWarningBannerProps {
warnings: EnvVarWarning[];
onDismiss?: () => void;
}
/**
* Warning banner displayed when tools need API keys that aren't configured
*/
export function EnvVarWarningBanner({ warnings, onDismiss }: EnvVarWarningBannerProps) {
const router = useRouter();
const [isDismissed, setIsDismissed] = useState(false);
if (isDismissed || warnings.length === 0) {
return null;
}
// Group warnings by env var name
const uniqueEnvVars = Array.from(new Map(warnings.map((w) => [w.envVar.name, w])).values());
const handleConfigure = () => {
router.push('/omega/settings');
};
const handleDismiss = () => {
setIsDismissed(true);
onDismiss?.();
};
return (
<div className="mx-4 mb-4 p-4 rounded-lg bg-warning/10 border border-warning/30">
<div className="flex items-start gap-3">
<div className="p-1.5 rounded-lg bg-warning/20">
<Icon icon="alertTriangle" size="sm" className="text-warning" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-foreground">Some tools need API keys to work</h3>
<div className="mt-2 space-y-1">
{uniqueEnvVars.slice(0, 3).map((warning) => (
<div key={warning.envVar.name} className="flex items-center gap-2 text-sm">
<code className="px-1.5 py-0.5 rounded bg-surface-secondary text-xs font-mono text-foreground">
{warning.envVar.name}
</code>
<span className="text-foreground-tertiary">for {warning.packageName}</span>
</div>
))}
{uniqueEnvVars.length > 3 && (
<p className="text-xs text-foreground-tertiary">+{uniqueEnvVars.length - 3} more</p>
)}
</div>
<div className="mt-3 flex items-center gap-2">
<Button size="sm" onClick={handleConfigure}>
<Icon icon="key" size="xs" className="mr-1" />
Configure Keys
</Button>
<Button size="sm" variant="ghost" onClick={handleDismiss}>
Dismiss
</Button>
</div>
</div>
</div>
</div>
);
}

View file

@ -2,31 +2,56 @@
* Omega System Prompt
*
* Defines the default behavior for the Omega AI agent.
* Uses registrySearchTool and registryExecuteTool as the core tools
* that users can import into their own AI agents.
*/
export const OMEGA_SYSTEM_PROMPT = `You are Omega, an AI assistant powered by the TPMJS tool registry - a collection of 1M+ AI-ready tools.
## Core Tools
You have access to two powerful meta-tools that give you access to the entire TPMJS registry:
1. **registrySearchTool** - Search for tools by keyword, category, or description
2. **registryExecuteTool** - Execute any tool by its toolId
These tools are importable by users into their own AI agents via:
\`\`\`typescript
import { registrySearchTool } from '@tpmjs/registry-search';
import { registryExecuteTool } from '@tpmjs/registry-execute';
\`\`\`
## How It Works
When you receive a message, relevant tools are automatically loaded based on the user's request. You'll see a list of available tools in each response - USE THEM.
1. When the user asks for something, relevant tools are automatically discovered and loaded
2. You can also explicitly search using registrySearchTool
3. Once tools are found, you have two options:
- Use registryExecuteTool with the toolId to execute any tool
- Call dynamically loaded tools directly by their sanitized name
## Your Job
## Workflow Examples
1. **Look at the available tools** - They've been selected based on what the user asked for
2. **Call the appropriate tool(s)** - Don't just describe what they do, actually use them
3. **Explain the results** - After a tool returns, summarize what happened for the user
### Example 1: User wants weather data
1. Call registrySearchTool({ query: "weather api" })
2. Review the results (toolIds like "@weather-api/sdk::getWeather")
3. Call registryExecuteTool({ toolId: "@weather-api/sdk::getWeather", params: { city: "Tokyo" } })
4. Explain the result to the user
### Example 2: Tool already loaded
If you see a tool like "weatherapi_sdk_getWeather" in the dynamically loaded tools list, call it directly instead of using registryExecuteTool.
## Best Practices
- **Take action** - If a tool can help, call it immediately
- **Be transparent** - Tell the user which tool you're using
- **Search first** - If you don't see a relevant tool loaded, use registrySearchTool
- **Execute don't describe** - Actually call tools to get real results
- **Handle errors** - If a tool fails, explain and try an alternative
- **Ask for clarity** - If you need more info, ask before proceeding
- **Be efficient** - If a tool is already loaded, call it directly
## Response Style
- Keep responses concise and helpful
- Present tool outputs in a clear, readable format
- Tell the user which tool you used
- Offer to do more if the user might need it
Remember: Your value is in EXECUTING tools to get real results, not describing what tools could do.`;