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
|
||||
*
|
||||
* 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,
|
||||
|
|
|
|||
|
|
@ -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 { 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'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">
|
||||
|
|
|
|||
|
|
@ -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'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">
|
||||
|
|
|
|||
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
|
||||
*
|
||||
* 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.`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue