Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ ENCRYPTION_SECRET_HEX=3143246f44def075d40141fb849faffcf409fbbeb7a282a3a7c2f4396f
# text-embedding-3-large).
OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_API_KEY=your-openrouter-api-key
OPENAI_MODEL=x-ai/grok-4.1-fast
OPENAI_CODING_MODEL=x-ai/grok-4.1-fast
OPENAI_MODEL=x-ai/grok-4-fast
OPENAI_CODING_MODEL=openai/gpt-5-mini
OPENAI_EMBEDDING_MODEL=openai/text-embedding-3-large
# EMBEDDING_DIMENSIONS=3072

Expand Down
18 changes: 14 additions & 4 deletions services/crawler/app/converter_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,15 +233,16 @@ async def html_to_image(
html: str,
wrap_in_template: bool = True,
image_type: Literal["png", "jpeg"] = "png",
quality: int = 90,
quality: int = 100,
full_page: bool = True,
width: int = 800,
width: int = 1200,
extra_css: Optional[str] = None,
scale: float = 2.0,
Comment thread
larryro marked this conversation as resolved.
) -> bytes:
"""Convert HTML to image (PNG or JPEG)."""
page = await self._get_page()
try:
# Set viewport width
# Set viewport with device scale factor for high-quality rendering
await page.set_viewport_size({"width": width, "height": 600})
Comment thread
larryro marked this conversation as resolved.

# Wrap in template if requested
Expand All @@ -264,10 +265,14 @@ async def html_to_image(
screenshot_options = {
"type": image_type,
"full_page": full_page,
"scale": "device" if scale > 1.0 else "css",
}
if image_type == "jpeg":
screenshot_options["quality"] = quality

# Set device scale factor for high-quality output
await page.evaluate(f"() => {{ window.devicePixelRatio = {scale}; }}")

image_bytes = await page.screenshot(**screenshot_options)
return image_bytes
finally:
Expand Down Expand Up @@ -325,10 +330,11 @@ async def url_to_image(
url: str,
wait_until: WaitUntilType = "networkidle",
image_type: Literal["png", "jpeg"] = "png",
quality: int = 90,
quality: int = 100,
full_page: bool = True,
width: int = 1280,
height: int = 800,
scale: float = 2.0,
Comment thread
larryro marked this conversation as resolved.
) -> bytes:
"""Capture a URL as image (screenshot)."""
page = await self._get_page()
Expand All @@ -339,10 +345,14 @@ async def url_to_image(
screenshot_options = {
"type": image_type,
"full_page": full_page,
"scale": "device" if scale > 1.0 else "css",
}
if image_type == "jpeg":
screenshot_options["quality"] = quality

# Set device scale factor for high-quality output
await page.evaluate(f"() => {{ window.devicePixelRatio = {scale}; }}")

image_bytes = await page.screenshot(**screenshot_options)
return image_bytes
finally:
Expand Down
3 changes: 3 additions & 0 deletions services/crawler/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,7 @@ async def convert_markdown_to_image(request: MarkdownToImageRequest):
full_page=request.options.full_page,
width=request.options.width,
extra_css=request.extra_css,
scale=request.options.scale,
)

media_type = "image/png" if request.options.image_type == "png" else "image/jpeg"
Expand Down Expand Up @@ -493,6 +494,7 @@ async def convert_html_to_image(request: HtmlToImageRequest):
full_page=request.options.full_page,
width=request.options.width,
extra_css=request.extra_css,
scale=request.options.scale,
)

media_type = "image/png" if request.options.image_type == "png" else "image/jpeg"
Expand Down Expand Up @@ -578,6 +580,7 @@ async def convert_url_to_image(request: UrlToImageRequest):
full_page=request.options.full_page,
width=request.options.width,
height=request.height,
scale=request.options.scale,
)

media_type = "image/png" if request.options.image_type == "png" else "image/jpeg"
Expand Down
5 changes: 3 additions & 2 deletions services/crawler/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,10 @@ class ImageOptions(BaseModel):
"""Options for image generation."""

image_type: str = Field("png", description="Image type (png or jpeg)")
quality: int = Field(90, description="JPEG quality (1-100)", ge=1, le=100)
quality: int = Field(100, description="JPEG quality (1-100)", ge=1, le=100)
full_page: bool = Field(True, description="Capture full page or viewport only")
width: int = Field(800, description="Viewport width", ge=100, le=4096)
width: int = Field(1200, description="Viewport width", ge=100, le=4096)
scale: float = Field(2.0, description="Device scale factor for high-quality images (2.0 = Retina)", ge=1.0, le=4.0)


class MarkdownToPdfRequest(BaseModel):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { cn } from '@/lib/utils/cn';
import { uuidv7 } from 'uuidv7';
import { useThrottledScroll } from '@/hooks/use-throttled-scroll';
import { useQuery, useMutation } from 'convex/react';
import { useUIMessages, type UIMessage } from '@convex-dev/agent/react';
import { api } from '@/convex/_generated/api';
import type { Id } from '@/convex/_generated/dataModel';
import { Button } from '@/components/ui/button';
Expand All @@ -38,33 +39,169 @@ interface ChatMessage {
attachments?: FileAttachment[];
}

function ThinkingAnimation() {
const [currentStep, setCurrentStep] = useState(0);
/**
* Represents a tool invocation with its details extracted from streaming parts.
*/
interface ToolDetail {
toolName: string;
displayText: string;
}

const thinkingSteps = [
'Thinking',
'Searching for related topics',
'Compiling an answer',
];
/**
* Extracts a hostname from a URL for display purposes.
*/
function extractHostname(url: string): string {
try {
const parsed = new URL(url);
return parsed.hostname.replace(/^www\./, '');
} catch {
return url;
}
}

useEffect(() => {
let interval: NodeJS.Timeout;
/**
* Truncates a string to a maximum length, adding ellipsis if needed.
*/
function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 1) + '…';
}

if (currentStep < thinkingSteps.length - 1) {
interval = setInterval(() => {
setCurrentStep((prev) => prev + 1);
}, 2500);
/**
* Formats a tool invocation into a human-readable display text with context.
* Extracts relevant details from the tool's input arguments.
*/
function formatToolDetail(
toolName: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
input?: Record<string, any>,
): ToolDetail {
// Handle web_read tool with operation-specific display
if (toolName === 'web_read' && input) {
if (input.operation === 'search' && input.query) {
return {
toolName,
displayText: `Searching "${truncate(input.query, 30)}"`,
};
}
if (input.operation === 'fetch_url' && input.url) {
return {
toolName,
displayText: `Reading ${extractHostname(input.url)}`,
};
}
}

return () => {
if (interval) clearInterval(interval);
// Handle rag_search with query
if (toolName === 'rag_search' && input?.query) {
return {
toolName,
displayText: `Searching knowledge base for "${truncate(input.query, 25)}"`,
};
}, [currentStep, thinkingSteps.length]);
}

// Default fallback display names for tools without detailed input
const defaultDisplayNames: Record<string, string> = {
customer_read: 'Reading customer data',
product_read: 'Reading product catalog',
rag_search: 'Searching knowledge base',
rag_write: 'Updating knowledge base',
web_read: 'Fetching web content',
pdf: 'Processing PDF',
image: 'Analyzing image',
pptx: 'Processing presentation',
docx: 'Processing document',
resource_check: 'Checking resources',
workflow_read: 'Reading workflow',
update_workflow_step: 'Updating workflow step',
generate_workflow_from_description: 'Generating workflow',
save_workflow_definition: 'Saving workflow',
validate_workflow_definition: 'Validating workflow',
generate_excel: 'Generating Excel file',
context_search: 'Searching for related topics',
};

const displayText =
defaultDisplayNames[toolName] ||
toolName
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');

return { toolName, displayText };
}

interface ThinkingAnimationProps {
threadId?: string;
streamingMessage?: UIMessage;
}

function ThinkingAnimation({
threadId,
streamingMessage,
}: ThinkingAnimationProps) {
// Extract tool details from streaming message parts
// Parts with tool info have type format 'tool-{toolName}' (e.g., 'tool-web_read')
// and may contain 'input' with the tool's arguments
const toolDetails: ToolDetail[] = [];

if (streamingMessage?.parts) {
for (const part of streamingMessage.parts) {
// The type format is 'tool-{toolName}', e.g., 'tool-web_read', 'tool-rag_search'
if (part.type.startsWith('tool-')) {
// Extract tool name from type (remove 'tool-' prefix)
const toolName = part.type.slice(5); // 'tool-'.length === 5
if (toolName && toolName !== 'invocation') {
// Extract input if available (cast part to access input property)
const toolPart = part as { input?: Record<string, unknown> };
const detail = formatToolDetail(toolName, toolPart.input);
toolDetails.push(detail);
}
}
}
}

// Determine what text to display - show tool details or default "Thinking"
let displayText = 'Thinking';

if (toolDetails.length === 1) {
// Single tool - show its detailed display text
displayText = toolDetails[0].displayText;
} else if (toolDetails.length > 1) {
// Multiple tools - deduplicate by display text and join them
const uniqueDisplayTexts = [...new Set(toolDetails.map((d) => d.displayText))];

// Check if all display texts start with the same verb (e.g., "Searching", "Reading")
// to create a more natural grouped message
const searchPrefix = 'Searching "';
const allSearches = uniqueDisplayTexts.every((t) => t.startsWith(searchPrefix));

if (allSearches && uniqueDisplayTexts.length > 1) {
// Extract just the query parts (remove "Searching " prefix and closing quote)
const queries = uniqueDisplayTexts.map((t) =>
t.slice(searchPrefix.length - 1, t.endsWith('"') ? t.length : t.length)
);
if (queries.length <= 2) {
displayText = `Searching ${queries.join(' and ')}`;
} else {
displayText = `Searching ${queries[0]}, ${queries[1]} and ${queries.length - 2} more`;
}
} else if (uniqueDisplayTexts.length <= 2) {
Comment thread
larryro marked this conversation as resolved.
displayText = uniqueDisplayTexts.join(' and ');
} else {
// For 3+ different tool calls, show first two and count
displayText = `${uniqueDisplayTexts[0]}, ${uniqueDisplayTexts[1]} and ${uniqueDisplayTexts.length - 2} more`;
}
}

// Use tool details as key for animation
const animationKey =
toolDetails.length > 0 ? toolDetails.map((d) => d.displayText).join('-') : 'thinking';

return (
<div className="flex justify-start">
<motion.div
key={currentStep}
key={animationKey}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
Expand All @@ -75,7 +212,7 @@ function ThinkingAnimation() {
className="text-sm text-muted-foreground flex items-center gap-2 px-4 py-3"
>
<motion.span
key={currentStep}
key={animationKey}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
Expand All @@ -85,7 +222,7 @@ function ThinkingAnimation() {
}}
className="inline-block"
>
{thinkingSteps[currentStep]}
{displayText}
</motion.span>
<div className="flex space-x-1">
<div className="w-1 h-1 bg-muted-foreground rounded-full animate-bounce" />
Expand Down Expand Up @@ -123,18 +260,26 @@ export default function ChatInterface({
// Optimistic user message content
const userDraftMessage = optimisticMessage?.content || '';

// Fetch thread messages
const rawThreadMessages = useQuery(
api.threads.getThreadMessages,
// Fetch thread messages with streaming support
const { results: uiMessages } = useUIMessages(
api.threads.getThreadMessagesStreaming,
threadId ? { threadId } : 'skip',
{ initialNumItems: 50, stream: true },
);
const threadMessages: ChatMessage[] = (rawThreadMessages?.messages || []).map(
(m) => ({
id: m._id,
content: m.content,
role: m.role,

// Convert UIMessage to ChatMessage format for compatibility
const threadMessages: ChatMessage[] = (uiMessages || [])
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({
id: m.key,
content: m.text,
role: m.role as 'user' | 'assistant',
timestamp: m._creationTime,
}),
}));

// Find if there's currently a streaming assistant message
const streamingMessage = uiMessages?.find(
(m) => m.role === 'assistant' && m.status === 'streaming',
);

// Query for active runId from thread (for recovery on page refresh)
Expand Down Expand Up @@ -201,7 +346,7 @@ export default function ChatInterface({
useEffect(() => {
if (
optimisticMessage?.content &&
rawThreadMessages !== undefined &&
uiMessages !== undefined &&
threadMessages?.some((m) => {
if (m.role !== 'user') return false;
// Check for exact match OR if the message starts with the optimistic content
Expand All @@ -214,7 +359,7 @@ export default function ChatInterface({
) {
setOptimisticMessage(null);
}
}, [rawThreadMessages, threadMessages, optimisticMessage?.content, setOptimisticMessage]);
}, [uiMessages, threadMessages, optimisticMessage?.content, setOptimisticMessage]);

// Scroll handling
const containerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -375,7 +520,12 @@ export default function ChatInterface({
}}
/>
)}
{isLoading && <ThinkingAnimation />}
{isLoading && !streamingMessage?.text && (
<ThinkingAnimation
threadId={threadId}
streamingMessage={streamingMessage}
/>
)}
</div>
)}
</div>
Expand Down
Loading