thread types, utils

This commit is contained in:
marko-kraemer 2025-04-17 15:26:06 +01:00
parent a8f0da8ae8
commit d6461c5961
3 changed files with 259 additions and 239 deletions

View File

@ -5,10 +5,8 @@ import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import {
ArrowDown, FileText, Terminal, ExternalLink, User, CheckCircle, CircleDashed,
FileEdit, Search, Globe, Code, MessageSquare, Folder, FileX, CloudUpload, Wrench, Cog
ArrowDown, CheckCircle, CircleDashed,
} from 'lucide-react';
import type { ElementType } from 'react';
import { addUserMessage, getMessages, startAgent, stopAgent, getAgentStatus, streamAgent, getAgentRuns, getProject, getThread, updateProject, Project } from '@/lib/api';
import { toast } from 'sonner';
import { Skeleton } from "@/components/ui/skeleton";
@ -19,243 +17,10 @@ import { ToolCallSidePanel, SidePanelContent, ToolCallData } from "@/components/
import { useSidebar } from "@/components/ui/sidebar";
import { TodoPanel } from '@/components/thread/todo-panel';
// Define a type for the params to make React.use() work properly
type ThreadParams = {
threadId: string;
};
// Import types and utils
import { ApiMessage, ThreadParams, isToolSequence } from '@/components/thread/types';
import { getToolIcon, extractPrimaryParam, groupMessages, SHOULD_RENDER_TOOL_RESULTS } from '@/components/thread/utils';
interface ApiMessage {
role: string;
content: string;
type?: string;
name?: string;
arguments?: string;
tool_call?: {
id: string;
function: {
name: string;
arguments: string;
};
type: string;
index: number;
};
}
// Define structure for grouped tool call/result sequences
type ToolSequence = {
type: 'tool_sequence';
items: ApiMessage[];
};
// Type for items that will be rendered
type RenderItem = ApiMessage | ToolSequence;
// Type guard to check if an item is a ToolSequence
function isToolSequence(item: RenderItem): item is ToolSequence {
return (item as ToolSequence).type === 'tool_sequence';
}
// Helper function to get an icon based on tool name
const getToolIcon = (toolName: string): ElementType => {
// Ensure we handle null/undefined toolName gracefully
if (!toolName) return Cog;
// Convert to lowercase for case-insensitive matching
const normalizedName = toolName.toLowerCase();
// Check for browser-related tools with a prefix check
if (normalizedName.startsWith('browser-')) {
return Globe;
}
switch (normalizedName) {
// File operations
case 'create-file':
case 'str-replace':
case 'full-file-rewrite':
case 'read-file':
return FileEdit;
// Shell commands
case 'execute-command':
return Terminal;
// Web operations
case 'web-search':
return Search;
// API and data operations
case 'call-data-provider':
case 'get-data-provider-endpoints':
return ExternalLink; // Using ExternalLink instead of Database which isn't imported
// Code operations
case 'delete-file':
return FileX;
// Deployment
case 'deploy-site':
return CloudUpload;
// Tools and utilities
case 'execute-code':
return Code;
// Default case
default:
// Add logging for debugging unhandled tool types
console.log(`[PAGE] Using default icon for unknown tool type: ${toolName}`);
return Wrench; // Default icon for tools
}
};
// Helper function to extract a primary parameter from XML/arguments
const extractPrimaryParam = (toolName: string, content: string | undefined): string | null => {
if (!content) return null;
try {
// Handle browser tools with a prefix check
if (toolName?.toLowerCase().startsWith('browser-')) {
// Try to extract URL for navigation
const urlMatch = content.match(/url=(?:"|')([^"|']+)(?:"|')/);
if (urlMatch) return urlMatch[1];
// For other browser operations, extract the goal or action
const goalMatch = content.match(/goal=(?:"|')([^"|']+)(?:"|')/);
if (goalMatch) {
const goal = goalMatch[1];
return goal.length > 30 ? goal.substring(0, 27) + '...' : goal;
}
return null;
}
// Simple regex for common parameters - adjust as needed
let match: RegExpMatchArray | null = null;
switch (toolName?.toLowerCase()) {
// File operations
case 'create-file':
case 'full-file-rewrite':
case 'read-file':
case 'delete-file':
case 'str-replace':
// Try to match file_path attribute
match = content.match(/file_path=(?:"|')([^"|']+)(?:"|')/);
// Return just the filename part
return match ? match[1].split('/').pop() || match[1] : null;
// Shell commands
case 'execute-command':
// Extract command content
match = content.match(/command=(?:"|')([^"|']+)(?:"|')/);
if (match) {
const cmd = match[1];
return cmd.length > 30 ? cmd.substring(0, 27) + '...' : cmd;
}
return null;
// Web search
case 'web-search':
match = content.match(/query=(?:"|')([^"|']+)(?:"|')/);
return match ? (match[1].length > 30 ? match[1].substring(0, 27) + '...' : match[1]) : null;
// Data provider operations
case 'call-data-provider':
match = content.match(/service_name=(?:"|')([^"|']+)(?:"|')/);
const route = content.match(/route=(?:"|')([^"|']+)(?:"|')/);
return match && route ? `${match[1]}/${route[1]}` : (match ? match[1] : null);
// Deployment
case 'deploy-site':
match = content.match(/site_name=(?:"|')([^"|']+)(?:"|')/);
return match ? match[1] : null;
}
return null;
} catch (e) {
console.warn("Error parsing tool parameters:", e);
return null;
}
};
// Flag to control whether tool result messages are rendered
const SHOULD_RENDER_TOOL_RESULTS = false;
// Function to group consecutive assistant tool call / user tool result pairs
function groupMessages(messages: ApiMessage[]): RenderItem[] {
const grouped: RenderItem[] = [];
let i = 0;
while (i < messages.length) {
const currentMsg = messages[i];
const nextMsg = i + 1 < messages.length ? messages[i + 1] : null;
let currentSequence: ApiMessage[] = [];
// Check if current message is the start of a potential sequence
if (currentMsg.role === 'assistant') {
// Regex to find the first XML-like tag: <tagname ...> or <tagname> or self-closing tags
const toolTagMatch = currentMsg.content?.match(/<(?!inform\b)([a-zA-Z\-_]+)(?:\s+[^>]*)?(?:\/)?>/);
if (toolTagMatch && nextMsg && nextMsg.role === 'user') {
const expectedTag = toolTagMatch[1];
// Regex to check for <tool_result><tagname>...</tagname></tool_result>
// Also handle self-closing tags in the response
const toolResultRegex = new RegExp(`^<tool_result>\\s*<(${expectedTag})(?:\\s+[^>]*)?(?:/>|>[\\s\\S]*?</\\1>)\\s*</tool_result>`);
if (nextMsg.content?.match(toolResultRegex)) {
// Found a pair, start a sequence
currentSequence.push(currentMsg);
currentSequence.push(nextMsg);
i += 2; // Move past this pair
// Check for continuation
while (i < messages.length) {
const potentialAssistant = messages[i];
const potentialUser = i + 1 < messages.length ? messages[i + 1] : null;
if (potentialAssistant.role === 'assistant') {
const nextToolTagMatch = potentialAssistant.content?.match(/<(?!inform\b)([a-zA-Z\-_]+)(?:\s+[^>]*)?(?:\/)?>/);
if (nextToolTagMatch && potentialUser && potentialUser.role === 'user') {
const nextExpectedTag = nextToolTagMatch[1];
// Also handle self-closing tags in the response
const nextToolResultRegex = new RegExp(`^<tool_result>\\s*<(${nextExpectedTag})(?:\\s+[^>]*)?(?:/>|>[\\s\\S]*?</\\1>)\\s*</tool_result>`);
if (potentialUser.content?.match(nextToolResultRegex)) {
// Sequence continues
currentSequence.push(potentialAssistant);
currentSequence.push(potentialUser);
i += 2; // Move past the added pair
} else {
// Assistant/User message, but not a matching tool result pair - break sequence
break;
}
} else {
// Assistant message without tool tag, or no following user message - break sequence
break;
}
} else {
// Not an assistant message - break sequence
break;
}
}
// Add the completed sequence to grouped results
grouped.push({ type: 'tool_sequence', items: currentSequence });
continue; // Continue the outer loop from the new 'i'
}
}
}
// If no sequence was started or continued, add the current message normally
if (currentSequence.length === 0) {
grouped.push(currentMsg);
i++; // Move to the next message
}
}
return grouped;
}
export default function ThreadPage({ params }: { params: Promise<ThreadParams> }) {
const unwrappedParams = React.use(params);

View File

@ -0,0 +1,41 @@
import type { ElementType } from 'react';
import type { Project } from '@/lib/api';
// Define a type for the params to make React.use() work properly
export type ThreadParams = {
threadId: string;
};
export interface ApiMessage {
role: string;
content: string;
type?: string;
name?: string;
arguments?: string;
tool_call?: {
id: string;
function: {
name: string;
arguments: string;
};
type: string;
index: number;
};
}
// Define structure for grouped tool call/result sequences
export type ToolSequence = {
type: 'tool_sequence';
items: ApiMessage[];
};
// Type for items that will be rendered
export type RenderItem = ApiMessage | ToolSequence;
// Type guard to check if an item is a ToolSequence
export function isToolSequence(item: RenderItem): item is ToolSequence {
return (item as ToolSequence).type === 'tool_sequence';
}
// Re-export existing types
export type { Project };

View File

@ -0,0 +1,214 @@
import type { ElementType } from 'react';
import {
ArrowDown, FileText, Terminal, ExternalLink, User, CheckCircle, CircleDashed,
FileEdit, Search, Globe, Code, MessageSquare, Folder, FileX, CloudUpload, Wrench, Cog,
Network, FileSearch, FilePlus
} from 'lucide-react';
import { ApiMessage, RenderItem, ToolSequence, isToolSequence } from './types';
// Flag to control whether tool result messages are rendered
export const SHOULD_RENDER_TOOL_RESULTS = false;
// Helper function to get an icon based on tool name
export const getToolIcon = (toolName: string): ElementType => {
// Ensure we handle null/undefined toolName gracefully
if (!toolName) return Cog;
// Convert to lowercase for case-insensitive matching
const normalizedName = toolName.toLowerCase();
// Check for browser-related tools with a prefix check
if (normalizedName.startsWith('browser-')) {
return Globe;
}
switch (normalizedName) {
// File operations
case 'create-file':
return FileEdit;
case 'str-replace':
return FileSearch;
case 'full-file-rewrite':
return FilePlus;
case 'read-file':
return FileText;
// Shell commands
case 'execute-command':
return Terminal;
// Web operations
case 'web-search':
return Search;
case 'crawl-webpage':
return Globe;
// API and data operations
case 'call-data-provider':
return ExternalLink;
case 'get-data-provider-endpoints':
return Network;
// Code operations
case 'delete-file':
return FileX;
// Deployment
case 'deploy-site':
return CloudUpload;
// Tools and utilities
case 'execute-code':
return Code;
// Default case
default:
// Add logging for debugging unhandled tool types
console.log(`[PAGE] Using default icon for unknown tool type: ${toolName}`);
return Wrench; // Default icon for tools
}
};
// Helper function to extract a primary parameter from XML/arguments
export const extractPrimaryParam = (toolName: string, content: string | undefined): string | null => {
if (!content) return null;
try {
// Handle browser tools with a prefix check
if (toolName?.toLowerCase().startsWith('browser-')) {
// Try to extract URL for navigation
const urlMatch = content.match(/url=(?:"|')([^"|']+)(?:"|')/);
if (urlMatch) return urlMatch[1];
// For other browser operations, extract the goal or action
const goalMatch = content.match(/goal=(?:"|')([^"|']+)(?:"|')/);
if (goalMatch) {
const goal = goalMatch[1];
return goal.length > 30 ? goal.substring(0, 27) + '...' : goal;
}
return null;
}
// Simple regex for common parameters - adjust as needed
let match: RegExpMatchArray | null = null;
switch (toolName?.toLowerCase()) {
// File operations
case 'create-file':
case 'full-file-rewrite':
case 'read-file':
case 'delete-file':
case 'str-replace':
// Try to match file_path attribute
match = content.match(/file_path=(?:"|')([^"|']+)(?:"|')/);
// Return just the filename part
return match ? match[1].split('/').pop() || match[1] : null;
// Shell commands
case 'execute-command':
// Extract command content
match = content.match(/command=(?:"|')([^"|']+)(?:"|')/);
if (match) {
const cmd = match[1];
return cmd.length > 30 ? cmd.substring(0, 27) + '...' : cmd;
}
return null;
// Web search
case 'web-search':
match = content.match(/query=(?:"|')([^"|']+)(?:"|')/);
return match ? (match[1].length > 30 ? match[1].substring(0, 27) + '...' : match[1]) : null;
// Data provider operations
case 'call-data-provider':
match = content.match(/service_name=(?:"|')([^"|']+)(?:"|')/);
const route = content.match(/route=(?:"|')([^"|']+)(?:"|')/);
return match && route ? `${match[1]}/${route[1]}` : (match ? match[1] : null);
// Deployment
case 'deploy-site':
match = content.match(/site_name=(?:"|')([^"|']+)(?:"|')/);
return match ? match[1] : null;
}
return null;
} catch (e) {
console.warn("Error parsing tool parameters:", e);
return null;
}
};
// Function to group consecutive assistant tool call / user tool result pairs
export function groupMessages(messages: ApiMessage[]): RenderItem[] {
const grouped: RenderItem[] = [];
let i = 0;
while (i < messages.length) {
const currentMsg = messages[i];
const nextMsg = i + 1 < messages.length ? messages[i + 1] : null;
let currentSequence: ApiMessage[] = [];
// Check if current message is the start of a potential sequence
if (currentMsg.role === 'assistant') {
// Regex to find the first XML-like tag: <tagname ...> or <tagname> or self-closing tags
const toolTagMatch = currentMsg.content?.match(/<(?!inform\b)([a-zA-Z\-_]+)(?:\s+[^>]*)?(?:\/)?>/);
if (toolTagMatch && nextMsg && nextMsg.role === 'user') {
const expectedTag = toolTagMatch[1];
// Regex to check for <tool_result><tagname>...</tagname></tool_result>
// Also handle self-closing tags in the response
const toolResultRegex = new RegExp(`^<tool_result>\\s*<(${expectedTag})(?:\\s+[^>]*)?(?:/>|>[\\s\\S]*?</\\1>)\\s*</tool_result>`);
if (nextMsg.content?.match(toolResultRegex)) {
// Found a pair, start a sequence
currentSequence.push(currentMsg);
currentSequence.push(nextMsg);
i += 2; // Move past this pair
// Check for continuation
while (i < messages.length) {
const potentialAssistant = messages[i];
const potentialUser = i + 1 < messages.length ? messages[i + 1] : null;
if (potentialAssistant.role === 'assistant') {
const nextToolTagMatch = potentialAssistant.content?.match(/<(?!inform\b)([a-zA-Z\-_]+)(?:\s+[^>]*)?(?:\/)?>/);
if (nextToolTagMatch && potentialUser && potentialUser.role === 'user') {
const nextExpectedTag = nextToolTagMatch[1];
// Also handle self-closing tags in the response
const nextToolResultRegex = new RegExp(`^<tool_result>\\s*<(${nextExpectedTag})(?:\\s+[^>]*)?(?:/>|>[\\s\\S]*?</\\1>)\\s*</tool_result>`);
if (potentialUser.content?.match(nextToolResultRegex)) {
// Sequence continues
currentSequence.push(potentialAssistant);
currentSequence.push(potentialUser);
i += 2; // Move past the added pair
} else {
// Assistant/User message, but not a matching tool result pair - break sequence
break;
}
} else {
// Assistant message without tool tag, or no following user message - break sequence
break;
}
} else {
// Not an assistant message - break sequence
break;
}
}
// Add the completed sequence to grouped results
grouped.push({ type: 'tool_sequence', items: currentSequence });
continue; // Continue the outer loop from the new 'i'
}
}
}
// If no sequence was started or continued, add the current message normally
if (currentSequence.length === 0) {
grouped.push(currentMsg);
i++; // Move to the next message
}
}
return grouped;
}