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:
parent
19a905d9d1
commit
0d3395b32f
8 changed files with 1323 additions and 161 deletions
|
|
@ -4,19 +4,102 @@
|
||||||
* POST: Send a message and stream the AI response via SSE
|
* POST: Send a message and stream the AI response via SSE
|
||||||
*
|
*
|
||||||
* This endpoint implements the core Omega chat functionality with:
|
* This endpoint implements the core Omega chat functionality with:
|
||||||
* - Automatic BM25 search to find relevant tools based on user message
|
* - Static tools: registrySearchTool and registryExecuteTool for external users
|
||||||
* - Dynamic tool loading - top 15 matching tools become available to the AI
|
* - Dynamic tool loading: tools found via search are injected as callable tools
|
||||||
* - SSE streaming for real-time updates
|
* - SSE streaming for real-time updates
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Prisma, prisma } from '@tpmjs/db';
|
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 { jsonSchema, type ModelMessage } from 'ai';
|
||||||
import { type NextRequest, NextResponse } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { authenticateRequest } from '~/lib/api-keys/middleware';
|
import { authenticateRequest } from '~/lib/api-keys/middleware';
|
||||||
|
import { decryptApiKey } from '~/lib/crypto/api-keys';
|
||||||
import { buildSystemPrompt } from '~/lib/omega/system-prompt';
|
import { buildSystemPrompt } from '~/lib/omega/system-prompt';
|
||||||
import { checkRateLimit, type RateLimitConfig } from '~/lib/rate-limit';
|
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
|
* Rate limit for Omega chat: 20 requests per minute
|
||||||
* Stricter limit because this involves multiple tool executions
|
* Stricter limit because this involves multiple tool executions
|
||||||
|
|
@ -41,6 +124,10 @@ const SendMessageSchema = z.object({
|
||||||
// Executor service URL
|
// Executor service URL
|
||||||
const EXECUTOR_URL = process.env.TPMJS_EXECUTOR_URL || 'https://executor.tpmjs.com';
|
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
|
* Search for relevant tools based on user query
|
||||||
*/
|
*/
|
||||||
|
|
@ -56,6 +143,7 @@ async function searchRelevantTools(
|
||||||
version: string;
|
version: string;
|
||||||
importUrl: string;
|
importUrl: string;
|
||||||
inputSchema?: unknown;
|
inputSchema?: unknown;
|
||||||
|
env?: Array<{ name: string; description?: string; required?: boolean }>;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
|
|
@ -88,23 +176,27 @@ async function searchRelevantTools(
|
||||||
version: tool.package.npmVersion,
|
version: tool.package.npmVersion,
|
||||||
importUrl: `https://esm.sh/${tool.package.npmPackageName}@${tool.package.npmVersion}`,
|
importUrl: `https://esm.sh/${tool.package.npmPackageName}@${tool.package.npmVersion}`,
|
||||||
inputSchema: tool.inputSchema,
|
inputSchema: tool.inputSchema,
|
||||||
|
env: tool.package.env || [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a dynamic tool wrapper that executes via the sandbox executor
|
* Create a dynamic tool wrapper that executes via the sandbox executor
|
||||||
*/
|
*/
|
||||||
function createDynamicTool(toolMeta: {
|
async function createDynamicTool(
|
||||||
toolId: string;
|
toolMeta: {
|
||||||
packageName: string;
|
toolId: string;
|
||||||
name: string;
|
packageName: string;
|
||||||
description: string;
|
name: string;
|
||||||
version: string;
|
description: string;
|
||||||
importUrl: string;
|
version: string;
|
||||||
inputSchema?: unknown;
|
importUrl: string;
|
||||||
}) {
|
inputSchema?: unknown;
|
||||||
|
},
|
||||||
|
userEnvVars: Record<string, string>
|
||||||
|
) {
|
||||||
// Import tool() dynamically to avoid top-level await
|
// Import tool() dynamically to avoid top-level await
|
||||||
const { tool } = require('ai');
|
const { tool } = await import('ai');
|
||||||
|
|
||||||
return tool({
|
return tool({
|
||||||
description: toolMeta.description,
|
description: toolMeta.description,
|
||||||
|
|
@ -128,7 +220,7 @@ function createDynamicTool(toolMeta: {
|
||||||
version: toolMeta.version,
|
version: toolMeta.version,
|
||||||
importUrl: toolMeta.importUrl,
|
importUrl: toolMeta.importUrl,
|
||||||
params,
|
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, '');
|
.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
|
* POST /api/omega/conversations/[id]/messages
|
||||||
* Send a message and stream the AI response via SSE
|
* 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 },
|
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
|
// Update conversation state to running
|
||||||
await prisma.omegaConversation.update({
|
await prisma.omegaConversation.update({
|
||||||
where: { id: conversationId },
|
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
|
// Initialize or get conversation state
|
||||||
console.log(`🔍 Searching for tools matching: "${parsed.data.message}"`);
|
if (!conversationStates.has(conversationId)) {
|
||||||
const relevantTools = await searchRelevantTools(parsed.data.message, 15);
|
conversationStates.set(conversationId, { loadedTools: {} });
|
||||||
console.log(`📦 Found ${relevantTools.length} relevant tools`);
|
}
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: We just ensured it exists
|
||||||
|
const state = conversationStates.get(conversationId)!;
|
||||||
|
|
||||||
// Create dynamic tool wrappers for each found tool
|
// 🔍 Auto-search for relevant tools based on user's message (BM25)
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: Dynamic tool types
|
console.log(`🔍 Auto-searching for tools matching: "${parsed.data.message}"`);
|
||||||
const tools: Record<string, any> = {};
|
const relevantTools = await searchRelevantTools(parsed.data.message, 10);
|
||||||
|
console.log(`📦 Found ${relevantTools.length} relevant tools via BM25`);
|
||||||
|
|
||||||
for (const toolMeta of relevantTools) {
|
// Add auto-discovered tools to conversation state
|
||||||
const sanitizedName = sanitizeToolName(toolMeta.toolId);
|
const autoAddedTools = await addToolsToConversation(conversationId, relevantTools, userEnvVars);
|
||||||
try {
|
console.log(`✨ Auto-added ${autoAddedTools.length} new tools`);
|
||||||
tools[sanitizedName] = createDynamicTool(toolMeta);
|
|
||||||
console.log(`✅ Loaded tool: ${sanitizedName}`);
|
// Detect missing required environment variables
|
||||||
} catch (error) {
|
const missingEnvVars = detectMissingEnvVars(relevantTools, userEnvVars);
|
||||||
console.error(`❌ Failed to create tool wrapper for ${toolMeta.toolId}:`, error);
|
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
|
// Fetch recent messages for context
|
||||||
const recentMessages = await prisma.omegaMessage.findMany({
|
const recentMessages = await prisma.omegaMessage.findMany({
|
||||||
|
|
@ -277,7 +431,12 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
|
||||||
const messages: ModelMessage[] = [];
|
const messages: ModelMessage[] = [];
|
||||||
|
|
||||||
// Build tool list for system prompt
|
// 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]) => {
|
.map(([name, t]) => {
|
||||||
const tool = t as { description?: string };
|
const tool = t as { description?: string };
|
||||||
return `- ${name}: ${tool.description || 'No description'}`;
|
return `- ${name}: ${tool.description || 'No description'}`;
|
||||||
|
|
@ -292,18 +451,32 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
|
||||||
|
|
||||||
const systemPrompt = `${baseSystemPrompt}
|
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
|
These tools have been discovered and loaded for this conversation. Call them directly:
|
||||||
2. Don't describe what tools could do - actually call them
|
|
||||||
3. After calling a tool, explain the results to the user
|
${dynamicToolsList || 'No tools loaded yet. Use registrySearchTool to find tools, or they will be auto-loaded based on your requests.'}
|
||||||
4. If no tools match, help the user with general knowledge`;
|
|
||||||
|
## 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 });
|
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));
|
controller.enqueue(encoder.encode(message));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Emit env var warnings at the start of the stream
|
||||||
|
if (missingEnvVars.length > 0) {
|
||||||
|
sendEvent('env.warning', { missingEnvVars });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { streamText, stepCountIs } = await import('ai');
|
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,
|
model,
|
||||||
messages,
|
messages,
|
||||||
tools: Object.keys(tools).length > 0 ? tools : undefined,
|
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) => {
|
onChunk: async (chunk) => {
|
||||||
// Handle tool call (complete tool call with args)
|
// Handle tool call (complete tool call with args)
|
||||||
if (chunk.chunk.type === 'tool-call') {
|
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 =
|
const isError =
|
||||||
tr.output && typeof tr.output === 'object' && 'error' in tr.output;
|
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
|
// Build update data for tool run
|
||||||
const toolRunUpdateData: Prisma.OmegaToolRunUpdateManyMutationInput = {
|
const toolRunUpdateData: Prisma.OmegaToolRunUpdateManyMutationInput = {
|
||||||
output: tr.output as Prisma.InputJsonValue,
|
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,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
toolCallCount: allToolCalls.length,
|
toolCallCount: allToolCalls.length,
|
||||||
toolsLoaded: Object.keys(tools).length,
|
staticTools: ['registrySearchTool', 'registryExecuteTool'],
|
||||||
loadedToolNames: Object.keys(tools),
|
dynamicToolsLoaded: Object.keys(state.loadedTools),
|
||||||
bm25Results: relevantTools.map((t) => ({
|
autoDiscoveredTools: relevantTools.map((t) => ({
|
||||||
toolId: t.toolId,
|
toolId: t.toolId,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
packageName: t.packageName,
|
packageName: t.packageName,
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
apps/web/src/app/api/omega/settings/env-vars/route.ts
Normal file
120
apps/web/src/app/api/omega/settings/env-vars/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,18 @@ import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Streamdown } from 'streamdown';
|
import { Streamdown } from 'streamdown';
|
||||||
import { AppHeader } from '~/components/AppHeader';
|
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 {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -22,6 +34,8 @@ interface Message {
|
||||||
inputTokens?: number;
|
inputTokens?: number;
|
||||||
outputTokens?: number;
|
outputTokens?: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
// Tool discovery info (populated from SSE events, per message)
|
||||||
|
toolDiscovery?: ToolDiscoveryInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolCall {
|
interface ToolCall {
|
||||||
|
|
@ -33,6 +47,17 @@ interface ToolCall {
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EnvVarWarning {
|
||||||
|
toolId: string;
|
||||||
|
toolName: string;
|
||||||
|
packageName: string;
|
||||||
|
envVar: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface Conversation {
|
interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
|
@ -174,15 +199,12 @@ export default function OmegaChatPage(): React.ReactElement {
|
||||||
const [toolCalls, setToolCalls] = useState<ToolCall[]>([]);
|
const [toolCalls, setToolCalls] = useState<ToolCall[]>([]);
|
||||||
const [expandedToolCalls, setExpandedToolCalls] = useState<Set<string>>(new Set());
|
const [expandedToolCalls, setExpandedToolCalls] = useState<Set<string>>(new Set());
|
||||||
const [viewMode, setViewMode] = useState<'chat' | 'debug'>('chat');
|
const [viewMode, setViewMode] = useState<'chat' | 'debug'>('chat');
|
||||||
const [lastRunInfo, setLastRunInfo] = useState<{
|
// Map of message IDs to their tool discovery info (persisted in frontend state)
|
||||||
loadedToolNames?: string[];
|
const [messageToolDiscovery, setMessageToolDiscovery] = useState<Map<string, ToolDiscoveryInfo>>(
|
||||||
bm25Results?: Array<{
|
new Map()
|
||||||
toolId: string;
|
);
|
||||||
name: string;
|
// Environment variable warnings for tools that need API keys
|
||||||
packageName: string;
|
const [envWarnings, setEnvWarnings] = useState<EnvVarWarning[]>([]);
|
||||||
description: string;
|
|
||||||
}>;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
@ -311,6 +333,12 @@ export default function OmegaChatPage(): React.ReactElement {
|
||||||
const data = JSON.parse(line.slice(6));
|
const data = JSON.parse(line.slice(6));
|
||||||
|
|
||||||
switch (eventType) {
|
switch (eventType) {
|
||||||
|
case 'env.warning':
|
||||||
|
// Set environment variable warnings
|
||||||
|
if (data.missingEnvVars && Array.isArray(data.missingEnvVars)) {
|
||||||
|
setEnvWarnings(data.missingEnvVars);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'message.delta':
|
case 'message.delta':
|
||||||
setStreamingContent((prev) => prev + data.content);
|
setStreamingContent((prev) => prev + data.content);
|
||||||
break;
|
break;
|
||||||
|
|
@ -344,17 +372,39 @@ export default function OmegaChatPage(): React.ReactElement {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'run.completed':
|
case 'run.completed': {
|
||||||
// Capture tool info for debug view
|
// Capture tool info for this message
|
||||||
setLastRunInfo({
|
const toolDiscovery: ToolDiscoveryInfo = {
|
||||||
loadedToolNames: data.loadedToolNames,
|
staticTools: data.staticTools,
|
||||||
bm25Results: data.bm25Results,
|
dynamicToolsLoaded: data.dynamicToolsLoaded,
|
||||||
});
|
autoDiscoveredTools: data.autoDiscoveredTools,
|
||||||
// Refresh messages
|
};
|
||||||
await fetchConversation();
|
|
||||||
|
// 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('');
|
setStreamingContent('');
|
||||||
setToolCalls([]);
|
setToolCalls([]);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'run.failed':
|
case 'run.failed':
|
||||||
throw new Error(data.error);
|
throw new Error(data.error);
|
||||||
}
|
}
|
||||||
|
|
@ -467,6 +517,12 @@ export default function OmegaChatPage(): React.ReactElement {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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}>
|
<Button variant="ghost" size="sm" onClick={startNewConversation}>
|
||||||
<Icon icon="plus" size="xs" className="mr-2" />
|
<Icon icon="plus" size="xs" className="mr-2" />
|
||||||
New Chat
|
New Chat
|
||||||
|
|
@ -496,80 +552,185 @@ export default function OmegaChatPage(): React.ReactElement {
|
||||||
{viewMode === 'debug' && (
|
{viewMode === 'debug' && (
|
||||||
<div className="flex-1 overflow-auto p-4 bg-background">
|
<div className="flex-1 overflow-auto p-4 bg-background">
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
{/* BM25 Search Results */}
|
{/* Header with Copy Button */}
|
||||||
{lastRunInfo?.bm25Results && lastRunInfo.bm25Results.length > 0 && (
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-medium text-foreground mb-2">
|
<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>
|
</h2>
|
||||||
<p className="text-xs text-foreground-tertiary mb-3">
|
<p className="text-xs text-foreground-tertiary">
|
||||||
Tools discovered from the registry based on the user's message.
|
Each message shows the tools discovered and loaded during that response.
|
||||||
</p>
|
</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>
|
</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 */}
|
{/* Per-Message View */}
|
||||||
{lastRunInfo?.loadedToolNames && lastRunInfo.loadedToolNames.length > 0 && (
|
{messages.map((message, index) => {
|
||||||
<div>
|
const discovery = messageToolDiscovery.get(message.id);
|
||||||
<h2 className="text-sm font-medium text-foreground mb-2">
|
return (
|
||||||
Loaded Tools ({lastRunInfo.loadedToolNames.length} tools)
|
<div
|
||||||
</h2>
|
key={message.id}
|
||||||
<p className="text-xs text-foreground-tertiary mb-3">
|
className="bg-surface-secondary border border-border rounded-lg overflow-hidden"
|
||||||
Sanitized tool names available to the AI for this request.
|
>
|
||||||
</p>
|
{/* Message Header */}
|
||||||
<div className="bg-surface-secondary border border-border rounded-lg p-4">
|
<div className="px-4 py-3 border-b border-border/50 bg-surface/50">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex items-center justify-between">
|
||||||
{lastRunInfo.loadedToolNames.map((name) => (
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span className="text-xs text-foreground-tertiary">#{index + 1}</span>
|
||||||
key={name}
|
<Badge
|
||||||
className="px-2 py-1 bg-primary/10 text-primary text-xs font-mono rounded"
|
variant={
|
||||||
>
|
message.role === 'USER'
|
||||||
{name}
|
? '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>
|
</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>
|
||||||
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -693,6 +854,13 @@ export default function OmegaChatPage(): React.ReactElement {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Environment Variable Warnings */}
|
||||||
|
{envWarnings.length > 0 && (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<EnvVarWarningBanner warnings={envWarnings} onDismiss={() => setEnvWarnings([])} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-4 py-2 bg-error/10 border-t border-error/20">
|
<div className="px-4 py-2 bg-error/10 border-t border-error/20">
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@
|
||||||
|
|
||||||
import { Button } from '@tpmjs/ui/Button/Button';
|
import { Button } from '@tpmjs/ui/Button/Button';
|
||||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { AppHeader } from '~/components/AppHeader';
|
import { AppHeader } from '~/components/AppHeader';
|
||||||
|
import { useSession } from '~/lib/auth-client';
|
||||||
|
|
||||||
interface SamplePrompt {
|
interface SamplePrompt {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -58,11 +60,23 @@ const SAMPLE_PROMPTS: SamplePrompt[] = [
|
||||||
*/
|
*/
|
||||||
export default function OmegaLandingPage(): React.ReactElement {
|
export default function OmegaLandingPage(): React.ReactElement {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { data: session, isPending: isSessionLoading } = useSession();
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isAuthenticated = !!session?.user;
|
||||||
|
|
||||||
const createConversation = useCallback(
|
const createConversation = useCallback(
|
||||||
async (initialPrompt?: string) => {
|
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);
|
setIsCreating(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
|
@ -74,7 +88,12 @@ export default function OmegaLandingPage(): React.ReactElement {
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
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();
|
const data = await response.json();
|
||||||
|
|
@ -91,16 +110,22 @@ export default function OmegaLandingPage(): React.ReactElement {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router]
|
[router, isAuthenticated]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check for auth on mount
|
// Handle prompt query parameter (redirect from sign-in)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Pre-warm the API by checking auth status
|
if (isAuthenticated && typeof window !== 'undefined') {
|
||||||
fetch('/api/auth/session').catch(() => {
|
const params = new URLSearchParams(window.location.search);
|
||||||
// Silently fail - we'll handle auth errors when creating conversation
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-background flex flex-col">
|
<div className="min-h-screen bg-background flex flex-col">
|
||||||
|
|
@ -122,40 +147,55 @@ export default function OmegaLandingPage(): React.ReactElement {
|
||||||
|
|
||||||
{/* Start New Conversation */}
|
{/* Start New Conversation */}
|
||||||
<div className="flex justify-center mb-12">
|
<div className="flex justify-center mb-12">
|
||||||
<Button
|
{isSessionLoading ? (
|
||||||
size="lg"
|
<Button size="lg" disabled className="px-8">
|
||||||
onClick={() => createConversation()}
|
<Icon icon="loader" size="sm" className="mr-2 animate-spin" />
|
||||||
disabled={isCreating}
|
Loading...
|
||||||
className="px-8"
|
</Button>
|
||||||
>
|
) : isAuthenticated ? (
|
||||||
{isCreating ? (
|
<Button
|
||||||
<>
|
size="lg"
|
||||||
<Icon icon="loader" size="sm" className="mr-2 animate-spin" />
|
onClick={() => createConversation()}
|
||||||
Creating...
|
disabled={isCreating}
|
||||||
</>
|
className="px-8"
|
||||||
) : (
|
>
|
||||||
<>
|
{isCreating ? (
|
||||||
<Icon icon="plus" size="sm" className="mr-2" />
|
<>
|
||||||
Start New Conversation
|
<Icon icon="loader" size="sm" className="mr-2 animate-spin" />
|
||||||
</>
|
Creating...
|
||||||
)}
|
</>
|
||||||
</Button>
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<p className="text-sm text-error">{error}</p>
|
<p className="text-sm text-error">{error}</p>
|
||||||
<p className="text-xs text-foreground-tertiary mt-1">
|
|
||||||
Make sure you're signed in to use Omega
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sample Prompts */}
|
{/* Sample Prompts */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-sm font-medium text-foreground-tertiary text-center mb-6">
|
<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>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{SAMPLE_PROMPTS.map((prompt) => (
|
{SAMPLE_PROMPTS.map((prompt) => (
|
||||||
|
|
@ -163,7 +203,7 @@ export default function OmegaLandingPage(): React.ReactElement {
|
||||||
key={prompt.title}
|
key={prompt.title}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => createConversation(prompt.prompt)}
|
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"
|
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="flex items-start gap-3">
|
||||||
|
|
|
||||||
457
apps/web/src/app/omega/settings/page.tsx
Normal file
457
apps/web/src/app/omega/settings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
apps/web/src/components/omega/EnvVarWarningBanner.tsx
Normal file
81
apps/web/src/components/omega/EnvVarWarningBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,31 +2,56 @@
|
||||||
* Omega System Prompt
|
* Omega System Prompt
|
||||||
*
|
*
|
||||||
* Defines the default behavior for the Omega AI agent.
|
* 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.
|
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
|
## 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
|
### Example 1: User wants weather data
|
||||||
2. **Call the appropriate tool(s)** - Don't just describe what they do, actually use them
|
1. Call registrySearchTool({ query: "weather api" })
|
||||||
3. **Explain the results** - After a tool returns, summarize what happened for the user
|
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
|
## Best Practices
|
||||||
|
|
||||||
- **Take action** - If a tool can help, call it immediately
|
- **Search first** - If you don't see a relevant tool loaded, use registrySearchTool
|
||||||
- **Be transparent** - Tell the user which tool you're using
|
- **Execute don't describe** - Actually call tools to get real results
|
||||||
- **Handle errors** - If a tool fails, explain and try an alternative
|
- **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
|
## Response Style
|
||||||
|
|
||||||
- Keep responses concise and helpful
|
- Keep responses concise and helpful
|
||||||
- Present tool outputs in a clear, readable format
|
- 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
|
- 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.`;
|
Remember: Your value is in EXECUTING tools to get real results, not describing what tools could do.`;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue