Compare commits

10 Commits

Author SHA1 Message Date
Anibal Angulo
77a11ef32e add agent context 2025-11-09 08:35:01 -06:00
Sebastian
a23f45ca6d Agente de web search construido e implementado(Tavily ejemplo de .env en readme) 2025-11-08 10:18:32 +00:00
Sebastian
70f2a42502 Bug solucionado de Qdrant y subida a de datos extraidos a Redis con referencia al documento 2025-11-07 23:30:10 +00:00
Anibal Angulo
c9a63e129d initial agent 2025-11-07 11:19:43 -06:00
Anibal Angulo
af9b5fed01 wip chat 2025-11-07 09:41:18 -06:00
Anibal Angulo
cafe0bf5f3 add other tab views 2025-11-06 17:29:07 -06:00
Anibal Angulo
314a876744 improve colors 2025-11-06 17:16:30 -06:00
Anibal Angulo
c5e0a451c0 add redis backend 2025-11-06 16:24:05 -06:00
Anibal Angulo
86e5c955c5 add section tabs 2025-11-06 12:15:00 -06:00
Anibal Angulo
90f32b2508 Remove __pycache__ files from git tracking
These files should be ignored by .gitignore but were committed
before the .gitignore rules were in place.
2025-11-06 08:50:21 -06:00
106 changed files with 19641 additions and 1525 deletions

View File

@@ -0,0 +1,3 @@
Asi debe estar en el .env para el agente de web search
TAVILY_API_KEY=

384
ai-elements-tools.md Normal file
View File

@@ -0,0 +1,384 @@
# Generative User Interfaces
Generative user interfaces (generative UI) is the process of allowing a large language model (LLM) to go beyond text and "generate UI". This creates a more engaging and AI-native experience for users.
<WeatherSearch />
At the core of generative UI are [ tools ](/docs/ai-sdk-core/tools-and-tool-calling), which are functions you provide to the model to perform specialized tasks like getting the weather in a location. The model can decide when and how to use these tools based on the context of the conversation.
Generative UI is the process of connecting the results of a tool call to a React component. Here's how it works:
1. You provide the model with a prompt or conversation history, along with a set of tools.
2. Based on the context, the model may decide to call a tool.
3. If a tool is called, it will execute and return data.
4. This data can then be passed to a React component for rendering.
By passing the tool results to React components, you can create a generative UI experience that's more engaging and adaptive to your needs.
## Build a Generative UI Chat Interface
Let's create a chat interface that handles text-based conversations and incorporates dynamic UI elements based on model responses.
### Basic Chat Implementation
Start with a basic chat implementation using the `useChat` hook:
```tsx filename="app/page.tsx"
'use client';
import { useChat } from '@ai-sdk/react';
import { useState } from 'react';
export default function Page() {
const [input, setInput] = useState('');
const { messages, sendMessage } = useChat();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
sendMessage({ text: input });
setInput('');
};
return (
<div>
{messages.map(message => (
<div key={message.id}>
<div>{message.role === 'user' ? 'User: ' : 'AI: '}</div>
<div>
{message.parts.map((part, index) => {
if (part.type === 'text') {
return <span key={index}>{part.text}</span>;
}
return null;
})}
</div>
</div>
))}
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Type a message..."
/>
<button type="submit">Send</button>
</form>
</div>
);
}
```
To handle the chat requests and model responses, set up an API route:
```ts filename="app/api/chat/route.ts"
import { openai } from '@ai-sdk/openai';
import { streamText, convertToModelMessages, UIMessage, stepCountIs } from 'ai';
export async function POST(request: Request) {
const { messages }: { messages: UIMessage[] } = await request.json();
const result = streamText({
model: openai('gpt-4o'),
system: 'You are a friendly assistant!',
messages: convertToModelMessages(messages),
stopWhen: stepCountIs(5),
});
return result.toUIMessageStreamResponse();
}
```
This API route uses the `streamText` function to process chat messages and stream the model's responses back to the client.
### Create a Tool
Before enhancing your chat interface with dynamic UI elements, you need to create a tool and corresponding React component. A tool will allow the model to perform a specific action, such as fetching weather information.
Create a new file called `ai/tools.ts` with the following content:
```ts filename="ai/tools.ts"
import { tool as createTool } from 'ai';
import { z } from 'zod';
export const weatherTool = createTool({
description: 'Display the weather for a location',
inputSchema: z.object({
location: z.string().describe('The location to get the weather for'),
}),
execute: async function ({ location }) {
await new Promise(resolve => setTimeout(resolve, 2000));
return { weather: 'Sunny', temperature: 75, location };
},
});
export const tools = {
displayWeather: weatherTool,
};
```
In this file, you've created a tool called `weatherTool`. This tool simulates fetching weather information for a given location. This tool will return simulated data after a 2-second delay. In a real-world application, you would replace this simulation with an actual API call to a weather service.
### Update the API Route
Update the API route to include the tool you've defined:
```ts filename="app/api/chat/route.ts" highlight="3,8,14"
import { openai } from '@ai-sdk/openai';
import { streamText, convertToModelMessages, UIMessage, stepCountIs } from 'ai';
import { tools } from '@/ai/tools';
export async function POST(request: Request) {
const { messages }: { messages: UIMessage[] } = await request.json();
const result = streamText({
model: openai('gpt-4o'),
system: 'You are a friendly assistant!',
messages: convertToModelMessages(messages),
stopWhen: stepCountIs(5),
tools,
});
return result.toUIMessageStreamResponse();
}
```
Now that you've defined the tool and added it to your `streamText` call, let's build a React component to display the weather information it returns.
### Create UI Components
Create a new file called `components/weather.tsx`:
```tsx filename="components/weather.tsx"
type WeatherProps = {
temperature: number;
weather: string;
location: string;
};
export const Weather = ({ temperature, weather, location }: WeatherProps) => {
return (
<div>
<h2>Current Weather for {location}</h2>
<p>Condition: {weather}</p>
<p>Temperature: {temperature}°C</p>
</div>
);
};
```
This component will display the weather information for a given location. It takes three props: `temperature`, `weather`, and `location` (exactly what the `weatherTool` returns).
### Render the Weather Component
Now that you have your tool and corresponding React component, let's integrate them into your chat interface. You'll render the Weather component when the model calls the weather tool.
To check if the model has called a tool, you can check the `parts` array of the UIMessage object for tool-specific parts. In AI SDK 5.0, tool parts use typed naming: `tool-${toolName}` instead of generic types.
Update your `page.tsx` file:
```tsx filename="app/page.tsx" highlight="4,9,14-15,19-46"
'use client';
import { useChat } from '@ai-sdk/react';
import { useState } from 'react';
import { Weather } from '@/components/weather';
export default function Page() {
const [input, setInput] = useState('');
const { messages, sendMessage } = useChat();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
sendMessage({ text: input });
setInput('');
};
return (
<div>
{messages.map(message => (
<div key={message.id}>
<div>{message.role === 'user' ? 'User: ' : 'AI: '}</div>
<div>
{message.parts.map((part, index) => {
if (part.type === 'text') {
return <span key={index}>{part.text}</span>;
}
if (part.type === 'tool-displayWeather') {
switch (part.state) {
case 'input-available':
return <div key={index}>Loading weather...</div>;
case 'output-available':
return (
<div key={index}>
<Weather {...part.output} />
</div>
);
case 'output-error':
return <div key={index}>Error: {part.errorText}</div>;
default:
return null;
}
}
return null;
})}
</div>
</div>
))}
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Type a message..."
/>
<button type="submit">Send</button>
</form>
</div>
);
}
```
In this updated code snippet, you:
1. Use manual input state management with `useState` instead of the built-in `input` and `handleInputChange`.
2. Use `sendMessage` instead of `handleSubmit` to send messages.
3. Check the `parts` array of each message for different content types.
4. Handle tool parts with type `tool-displayWeather` and their different states (`input-available`, `output-available`, `output-error`).
This approach allows you to dynamically render UI components based on the model's responses, creating a more interactive and context-aware chat experience.
## Expanding Your Generative UI Application
You can enhance your chat application by adding more tools and components, creating a richer and more versatile user experience. Here's how you can expand your application:
### Adding More Tools
To add more tools, simply define them in your `ai/tools.ts` file:
```ts
// Add a new stock tool
export const stockTool = createTool({
description: 'Get price for a stock',
inputSchema: z.object({
symbol: z.string().describe('The stock symbol to get the price for'),
}),
execute: async function ({ symbol }) {
// Simulated API call
await new Promise(resolve => setTimeout(resolve, 2000));
return { symbol, price: 100 };
},
});
// Update the tools object
export const tools = {
displayWeather: weatherTool,
getStockPrice: stockTool,
};
```
Now, create a new file called `components/stock.tsx`:
```tsx
type StockProps = {
price: number;
symbol: string;
};
export const Stock = ({ price, symbol }: StockProps) => {
return (
<div>
<h2>Stock Information</h2>
<p>Symbol: {symbol}</p>
<p>Price: ${price}</p>
</div>
);
};
```
Finally, update your `page.tsx` file to include the new Stock component:
```tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { useState } from 'react';
import { Weather } from '@/components/weather';
import { Stock } from '@/components/stock';
export default function Page() {
const [input, setInput] = useState('');
const { messages, sendMessage } = useChat();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
sendMessage({ text: input });
setInput('');
};
return (
<div>
{messages.map(message => (
<div key={message.id}>
<div>{message.role}</div>
<div>
{message.parts.map((part, index) => {
if (part.type === 'text') {
return <span key={index}>{part.text}</span>;
}
if (part.type === 'tool-displayWeather') {
switch (part.state) {
case 'input-available':
return <div key={index}>Loading weather...</div>;
case 'output-available':
return (
<div key={index}>
<Weather {...part.output} />
</div>
);
case 'output-error':
return <div key={index}>Error: {part.errorText}</div>;
default:
return null;
}
}
if (part.type === 'tool-getStockPrice') {
switch (part.state) {
case 'input-available':
return <div key={index}>Loading stock price...</div>;
case 'output-available':
return (
<div key={index}>
<Stock {...part.output} />
</div>
);
case 'output-error':
return <div key={index}>Error: {part.errorText}</div>;
default:
return null;
}
}
return null;
})}
</div>
</div>
))}
<form onSubmit={handleSubmit}>
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
/>
<button type="submit">Send</button>
</form>
</div>
);
}
```
By following this pattern, you can continue to add more tools and components, expanding the capabilities of your Generative UI application.

105
backend/RATE_LIMITING.md Normal file
View File

@@ -0,0 +1,105 @@
# Configuración de Rate Limiting para Azure OpenAI
Este documento explica cómo configurar el rate limiting para evitar errores `429 RateLimitReached` en Azure OpenAI.
## Variables de Entorno
Agrega estas variables en tu archivo `.env`:
```bash
# Rate limiting para embeddings
EMBEDDING_BATCH_SIZE=16
EMBEDDING_DELAY_BETWEEN_BATCHES=1.0
EMBEDDING_MAX_RETRIES=5
```
## Configuración según Azure OpenAI Tier
### **S0 Tier (Gratis/Básico)**
- **Límite**: ~1-3 requests/minuto, ~1,000 tokens/minuto
- **Configuración recomendada**:
```bash
EMBEDDING_BATCH_SIZE=16
EMBEDDING_DELAY_BETWEEN_BATCHES=1.0
EMBEDDING_MAX_RETRIES=5
```
### **Standard Tier**
- **Límite**: ~10-20 requests/segundo, ~100,000 tokens/minuto
- **Configuración recomendada**:
```bash
EMBEDDING_BATCH_SIZE=50
EMBEDDING_DELAY_BETWEEN_BATCHES=0.5
EMBEDDING_MAX_RETRIES=3
```
### **Premium Tier**
- **Límite**: ~100+ requests/segundo, ~500,000+ tokens/minuto
- **Configuración recomendada**:
```bash
EMBEDDING_BATCH_SIZE=100
EMBEDDING_DELAY_BETWEEN_BATCHES=0.1
EMBEDDING_MAX_RETRIES=3
```
## Cómo Funciona el Rate Limiting
### 1. **Batching**
Los textos se dividen en lotes de tamaño `EMBEDDING_BATCH_SIZE`. Un lote más pequeño reduce la probabilidad de exceder el rate limit.
### 2. **Delays entre Batches**
Después de procesar cada lote, el sistema espera `EMBEDDING_DELAY_BETWEEN_BATCHES` segundos antes de procesar el siguiente.
### 3. **Retry con Exponential Backoff**
Si ocurre un error 429 (rate limit):
- **Reintento 1**: espera 2 segundos
- **Reintento 2**: espera 4 segundos
- **Reintento 3**: espera 8 segundos
- **Reintento 4**: espera 16 segundos
- **Reintento 5**: espera 32 segundos
Después de `EMBEDDING_MAX_RETRIES` reintentos, el proceso falla.
## Monitoreo de Logs
Cuando procesas documentos, verás logs como:
```
📊 Procesando batch 1/10 (16 textos)...
✓ Batch 1/10 completado exitosamente
📊 Procesando batch 2/10 (16 textos)...
⚠️ Rate limit alcanzado en batch 2/10. Reintento 1/5 en 2s...
✓ Batch 2/10 completado exitosamente
...
✅ Embeddings generados exitosamente: 150 vectores de 3072D
```
## Cálculo de Tiempo de Procesamiento
Para estimar cuánto tardará el procesamiento:
```
Tiempo estimado = (total_chunks / EMBEDDING_BATCH_SIZE) * EMBEDDING_DELAY_BETWEEN_BATCHES
```
**Ejemplos**:
- 100 chunks con S0 config: `(100/16) * 1.0 = ~6.25 segundos` (sin contar reintentos)
- 1000 chunks con S0 config: `(1000/16) * 1.0 = ~62.5 segundos` (sin contar reintentos)
## Ajuste Dinámico
Si experimentas muchos errores 429:
1. **Reduce** `EMBEDDING_BATCH_SIZE` (ej: de 16 a 8)
2. **Aumenta** `EMBEDDING_DELAY_BETWEEN_BATCHES` (ej: de 1.0 a 2.0)
3. **Aumenta** `EMBEDDING_MAX_RETRIES` (ej: de 5 a 10)
Si el procesamiento es muy lento y NO tienes errores 429:
1. **Aumenta** `EMBEDDING_BATCH_SIZE` (ej: de 16 a 32)
2. **Reduce** `EMBEDDING_DELAY_BETWEEN_BATCHES` (ej: de 1.0 a 0.5)
## Upgrade de Azure OpenAI Tier
Para aumentar tu límite, visita:
https://aka.ms/oai/quotaincrease
Después del upgrade, ajusta las variables de entorno según tu nuevo tier.

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from typing import Any
from .agent import agent, prepare_initial_findings
from .models import (
AuditReport,
ExtractedIrsForm990PfDataSchema,
ValidatorState,
)
async def build_audit_report(payload: dict[str, Any]) -> AuditReport:
metadata_raw: Any = None
extraction_payload: Any = None
if isinstance(payload, dict) and "extraction" in payload:
extraction_payload = payload.get("extraction")
metadata_raw = payload.get("metadata")
else:
extraction_payload = payload
if extraction_payload is None:
raise ValueError("Payload missing extraction data.")
extraction = ExtractedIrsForm990PfDataSchema.model_validate(extraction_payload)
initial_findings = prepare_initial_findings(extraction)
metadata: dict[str, Any] = {}
if isinstance(metadata_raw, dict):
metadata = {str(k): v for k, v in metadata_raw.items()}
state = ValidatorState(
extraction=extraction,
initial_findings=initial_findings,
metadata=metadata,
)
prompt = (
"Review the Form 990 extraction and deterministic checks. Validate or adjust "
"the findings, add any additional issues or mitigations, and craft narrative "
"section summaries that highlight the most material points. Focus on concrete "
"evidence; do not fabricate figures."
)
result = await agent.run(prompt, deps=state)
return result.output

View File

@@ -0,0 +1,155 @@
from __future__ import annotations
from collections.abc import Iterable
from pydantic_ai import Agent, RunContext
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.azure import AzureProvider
from app.core.config import settings
from .checks import (
aggregate_findings,
build_section_summaries,
check_balance_sheet_presence,
check_board_engagement,
check_expense_totals,
check_fundraising_alignment,
check_governance_policies,
check_missing_operational_details,
check_revenue_totals,
compose_overall_summary,
irs_ein_lookup,
)
from .models import (
AuditFinding,
AuditReport,
ExtractedIrsForm990PfDataSchema,
Severity,
ValidatorState,
)
provider = AzureProvider(
azure_endpoint=settings.AZURE_OPENAI_ENDPOINT,
api_version=settings.AZURE_OPENAI_API_VERSION,
api_key=settings.AZURE_OPENAI_API_KEY,
)
model = OpenAIChatModel(model_name="gpt-4o", provider=provider)
agent = Agent(model=model)
def prepare_initial_findings(
extraction: ExtractedIrsForm990PfDataSchema,
) -> list[AuditFinding]:
findings = [
check_revenue_totals(extraction),
check_expense_totals(extraction),
check_fundraising_alignment(extraction),
check_balance_sheet_presence(extraction),
check_board_engagement(extraction),
check_missing_operational_details(extraction),
]
findings.extend(check_governance_policies(extraction))
return findings
def _merge_findings(
findings: Iterable[AuditFinding],
added: Iterable[AuditFinding],
) -> list[AuditFinding]:
existing = {finding.check_id: finding for finding in findings}
for finding in added:
existing[finding.check_id] = finding
return list(existing.values())
agent = Agent(
model=model,
name="FormValidator",
deps_type=ValidatorState,
output_type=AuditReport,
system_prompt=(
"You are a Form 990 auditor. Review the extraction data and deterministic "
"checks provided in deps. Use tools to confirm calculations, add or adjust "
"findings, supply mitigation guidance, and craft concise section summaries. "
"The AuditReport must include severity (`Pass`, `Warning`, `Error`), "
"confidence scores, mitigation advice, section summaries, and an overall summary. "
"Ground every statement in supplied data; do not invent financial figures."
),
)
@agent.tool
def revenue_check(ctx: RunContext[ValidatorState]) -> AuditFinding:
return check_revenue_totals(ctx.deps.extraction)
@agent.tool
def expense_check(ctx: RunContext[ValidatorState]) -> AuditFinding:
return check_expense_totals(ctx.deps.extraction)
@agent.tool
def fundraising_alignment_check(ctx: RunContext[ValidatorState]) -> AuditFinding:
return check_fundraising_alignment(ctx.deps.extraction)
@agent.tool
async def verify_ein(ctx: RunContext[ValidatorState]) -> AuditFinding:
ein = ctx.deps.extraction.core_organization_metadata.ein
exists, confidence, note = await irs_ein_lookup(ein)
if exists:
return AuditFinding(
check_id="irs_ein_match",
category="Compliance",
severity=Severity.PASS,
message="EIN confirmed against IRS index.",
mitigation="Document verification in the filing workpapers.",
confidence=confidence,
)
return AuditFinding(
check_id="irs_ein_match",
category="Compliance",
severity=Severity.WARNING,
message=f"EIN {ein} could not be confirmed. {note}",
mitigation="Verify the EIN against the IRS EO BMF or IRS determination letter.",
confidence=confidence,
)
@agent.output_validator
def finalize_report(
ctx: RunContext[ValidatorState],
report: AuditReport,
) -> AuditReport:
merged_findings = _merge_findings(ctx.deps.initial_findings, report.findings)
overall = aggregate_findings(merged_findings)
sections = build_section_summaries(merged_findings)
overall_summary = compose_overall_summary(merged_findings)
metadata = ctx.deps.metadata
notes = report.notes
if notes is None and isinstance(metadata, dict) and metadata.get("source"):
notes = f"Reviewed data source: {metadata['source']}."
year: int | None = None
if isinstance(metadata, dict):
metadata_year = metadata.get("return_year")
if metadata_year is not None:
try:
year = int(metadata_year)
except (TypeError, ValueError):
pass
core = ctx.deps.extraction.core_organization_metadata
organisation_name = core.legal_name or report.organisation_name
organisation_ein = core.ein or report.organisation_ein
return report.model_copy(
update={
"organisation_ein": organisation_ein,
"organisation_name": organisation_name,
"year": year,
"findings": merged_findings,
"overall_severity": overall,
"sections": sections,
"overall_summary": overall_summary,
"notes": notes,
}
)

View File

@@ -0,0 +1,282 @@
from __future__ import annotations
from collections import Counter, defaultdict
from .models import (
AuditFinding,
AuditSectionSummary,
ExtractedIrsForm990PfDataSchema,
Severity,
)
def aggregate_findings(findings: list[AuditFinding]) -> Severity:
order = {Severity.ERROR: 3, Severity.WARNING: 2, Severity.PASS: 1}
overall = Severity.PASS
for finding in findings:
if order[finding.severity] > order[overall]:
overall = finding.severity
return overall
def check_revenue_totals(data: ExtractedIrsForm990PfDataSchema) -> AuditFinding:
subtotal = sum(
value
for key, value in data.revenue_breakdown.model_dump().items()
if key != "total_revenue"
)
if abs(subtotal - data.revenue_breakdown.total_revenue) <= 1:
return AuditFinding(
check_id="revenue_totals",
category="Revenue",
severity=Severity.PASS,
message=f"Revenue categories sum (${subtotal:,.2f}) matches total revenue.",
mitigation="Maintain detailed support for each revenue source to preserve reconciliation trail.",
confidence=0.95,
)
return AuditFinding(
check_id="revenue_totals",
category="Revenue",
severity=Severity.ERROR,
message=(
f"Revenue categories sum (${subtotal:,.2f}) does not equal reported total "
f"(${data.revenue_breakdown.total_revenue:,.2f})."
),
mitigation="Recalculate revenue totals and correct line items or Schedule A before filing.",
confidence=0.95,
)
def check_expense_totals(data: ExtractedIrsForm990PfDataSchema) -> AuditFinding:
subtotal = (
data.expenses_breakdown.program_services_expenses
+ data.expenses_breakdown.management_general_expenses
+ data.expenses_breakdown.fundraising_expenses
)
if abs(subtotal - data.expenses_breakdown.total_expenses) <= 1:
return AuditFinding(
check_id="expense_totals",
category="Expenses",
severity=Severity.PASS,
message="Functional expenses match total expenses.",
mitigation="Keep functional allocation workpapers to support the reconciliation.",
confidence=0.95,
)
return AuditFinding(
check_id="expense_totals",
category="Expenses",
severity=Severity.ERROR,
message=(
f"Functional expenses (${subtotal:,.2f}) do not reconcile to total expenses "
f"(${data.expenses_breakdown.total_expenses:,.2f})."
),
mitigation="Review Part I, lines 2327 and reclassify functional expenses to tie to Part II totals.",
confidence=0.95,
)
def check_fundraising_alignment(
data: ExtractedIrsForm990PfDataSchema,
) -> AuditFinding:
reported_fundraising = data.expenses_breakdown.fundraising_expenses
event_expenses = data.fundraising_grantmaking.total_fundraising_event_expenses
difference = abs(reported_fundraising - event_expenses)
if difference <= 1:
return AuditFinding(
check_id="fundraising_alignment",
category="Fundraising",
severity=Severity.PASS,
message="Fundraising functional expenses align with reported event expenses.",
mitigation="Retain event ledgers and allocations to support matching totals.",
confidence=0.9,
)
severity = (
Severity.WARNING
if reported_fundraising and difference <= reported_fundraising * 0.1
else Severity.ERROR
)
return AuditFinding(
check_id="fundraising_alignment",
category="Fundraising",
severity=severity,
message=(
f"Fundraising functional expenses (${reported_fundraising:,.2f}) differ from "
f"reported event expenses (${event_expenses:,.2f}) by ${difference:,.2f}."
),
mitigation="Reconcile Schedule G and Part I allocations to eliminate the variance.",
confidence=0.85,
)
def check_balance_sheet_presence(
data: ExtractedIrsForm990PfDataSchema,
) -> AuditFinding:
if data.balance_sheet:
return AuditFinding(
check_id="balance_sheet_present",
category="Balance Sheet",
severity=Severity.PASS,
message="Balance sheet data is present.",
mitigation="Ensure ending net assets tie to Part I, line 30.",
confidence=0.7,
)
return AuditFinding(
check_id="balance_sheet_absent",
category="Balance Sheet",
severity=Severity.WARNING,
message="Balance sheet section is empty; confirm Part II filing requirements.",
mitigation="Populate assets, liabilities, and net assets or attach supporting schedules.",
confidence=0.6,
)
def check_governance_policies(
data: ExtractedIrsForm990PfDataSchema,
) -> list[AuditFinding]:
gm = data.governance_management_disclosure
findings: list[AuditFinding] = []
policy_fields = {
"conflict_of_interest_policy": "Document the policy in Part VI or adopt one prior to filing.",
"whistleblower_policy": "Document whistleblower protections for staff and volunteers.",
"document_retention_policy": "Adopt and document a record retention policy.",
}
affirmative_fields = {
"financial_statements_reviewed": "Capture whether the board reviewed or audited year-end financials.",
"form_990_provided_to_governing_body": "Provide Form 990 to the board before submission and note the date of review.",
}
for field, mitigation in policy_fields.items():
value = (getattr(gm, field) or "").strip()
if not value or value.lower() in {"no", "n", "false"}:
findings.append(
AuditFinding(
check_id=f"{field}_missing",
category="Governance",
severity=Severity.WARNING,
message=f"{field.replace('_', ' ').title()} not reported or marked 'No'.",
mitigation=mitigation,
confidence=0.55,
)
)
for field, mitigation in affirmative_fields.items():
value = (getattr(gm, field) or "").strip()
if not value:
findings.append(
AuditFinding(
check_id=f"{field}_blank",
category="Governance",
severity=Severity.WARNING,
message=f"{field.replace('_', ' ').title()} left blank.",
mitigation=mitigation,
confidence=0.5,
)
)
return findings
def check_board_engagement(data: ExtractedIrsForm990PfDataSchema) -> AuditFinding:
hours = [
member.average_hours_per_week
for member in data.officers_directors_trustees_key_employees
if member.average_hours_per_week is not None
]
total_hours = sum(hours)
if total_hours >= 5:
return AuditFinding(
check_id="board_hours",
category="Governance",
severity=Severity.PASS,
message="Officer and director time commitments appear reasonable.",
mitigation="Continue documenting board attendance and oversight responsibilities.",
confidence=0.7,
)
return AuditFinding(
check_id="board_hours",
category="Governance",
severity=Severity.WARNING,
message=(
f"Aggregate reported board hours ({total_hours:.1f} per week) are low; "
"confirm entries reflect actual governance involvement."
),
mitigation="Verify hours in Part VII; update if officers volunteer significant time.",
confidence=0.6,
)
def check_missing_operational_details(
data: ExtractedIrsForm990PfDataSchema,
) -> AuditFinding:
descriptors = (
data.functional_operational_data.fundraising_method_descriptions or ""
).strip()
if descriptors:
return AuditFinding(
check_id="fundraising_methods_documented",
category="Operations",
severity=Severity.PASS,
message="Fundraising method descriptions provided.",
mitigation="Update narratives annually to reflect any new campaigns or joint ventures.",
confidence=0.65,
)
return AuditFinding(
check_id="fundraising_methods_missing",
category="Operations",
severity=Severity.WARNING,
message="Fundraising method descriptions are blank.",
mitigation="Add a brief Schedule G narrative describing major fundraising approaches.",
confidence=0.55,
)
def build_section_summaries(findings: list[AuditFinding]) -> list[AuditSectionSummary]:
grouped: defaultdict[str, list[AuditFinding]] = defaultdict(list)
for finding in findings:
grouped[finding.category].append(finding)
summaries: list[AuditSectionSummary] = []
severity_order = {Severity.ERROR: 3, Severity.WARNING: 2, Severity.PASS: 1}
for category, category_findings in grouped.items():
counter = Counter(f.severity for f in category_findings)
severity = aggregate_findings(category_findings)
summary = ", ".join(
f"{count} {label}"
for label, count in (
("passes", counter.get(Severity.PASS, 0)),
("warnings", counter.get(Severity.WARNING, 0)),
("errors", counter.get(Severity.ERROR, 0)),
)
)
summary_text = f"{category} review: {summary}."
confidence = sum(f.confidence for f in category_findings) / len(
category_findings
)
summaries.append(
AuditSectionSummary(
section=category,
severity=severity,
summary=summary_text,
confidence=confidence,
)
)
summaries.sort(key=lambda s: (-severity_order[s.severity], s.section.lower()))
return summaries
def compose_overall_summary(findings: list[AuditFinding]) -> str:
if not findings:
return "No automated findings generated."
counter = Counter(f.severity for f in findings)
parts = []
if counter.get(Severity.ERROR):
parts.append(f"{counter[Severity.ERROR]} error(s)")
if counter.get(Severity.WARNING):
parts.append(f"{counter[Severity.WARNING]} warning(s)")
if counter.get(Severity.PASS):
parts.append(f"{counter[Severity.PASS]} check(s) passed")
summary = "Overall results: " + ", ".join(parts) + "."
return summary
async def irs_ein_lookup(_ein: str) -> tuple[bool, float, str]:
return False, 0.2, "IRS verification unavailable in current environment."

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
import argparse
import asyncio
import json
from pathlib import Path
from . import build_audit_report
__all__ = ["build_audit_report", "main"]
def _load_payload(path: Path) -> dict:
text = path.read_text(encoding="utf-8")
return json.loads(text)
def _print_report(report: dict) -> None:
print(json.dumps(report, indent=2))
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(
description="Validate a Form 990 extraction payload using the Form Auditor agent."
)
parser.add_argument(
"payload",
nargs="?",
default="example_data.json",
help="Path to a JSON file containing the extraction payload.",
)
args = parser.parse_args(argv)
payload_path = Path(args.payload).expanduser()
payload = _load_payload(payload_path)
report = asyncio.run(build_audit_report(payload))
_print_report(report.model_dump())

View File

@@ -0,0 +1,791 @@
from __future__ import annotations
import re
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field, model_validator
class Severity(str, Enum):
PASS = "Pass"
WARNING = "Warning"
ERROR = "Error"
class AuditFinding(BaseModel):
check_id: str
category: str
severity: Severity
message: str
mitigation: str | None = None
confidence: float = Field(ge=0.0, le=1.0)
class AuditSectionSummary(BaseModel):
section: str
severity: Severity
summary: str
confidence: float = Field(ge=0.0, le=1.0)
class AuditReport(BaseModel):
organisation_ein: str
organisation_name: str
year: int | None
overall_severity: Severity
findings: list[AuditFinding]
sections: list[AuditSectionSummary] = Field(default_factory=list)
overall_summary: str | None = None
notes: str | None = None
class CoreOrgMetadata(BaseModel):
ein: str
legal_name: str
return_type: str
accounting_method: str
incorporation_state: str | None = None
class CoreOrganizationMetadata(BaseModel):
ein: str = Field(
...,
description="Unique IRS identifier for the organization.",
title="Employer Identification Number (EIN)",
)
legal_name: str = Field(
...,
description="Official registered name of the organization.",
title="Legal Name of Organization",
)
phone_number: str = Field(
..., description="Primary contact phone number.", title="Phone Number"
)
website_url: str = Field(
..., description="Organization's website address.", title="Website URL"
)
return_type: str = Field(
...,
description="Type of IRS return filed (e.g., 990, 990-EZ, 990-PF).",
title="Return Type",
)
amended_return: str = Field(
...,
description="Indicates if the return is amended.",
title="Amended Return Flag",
)
group_exemption_number: str = Field(
...,
description="IRS group exemption number, if applicable.",
title="Group Exemption Number",
)
subsection_code: str = Field(
...,
description="IRS subsection code (e.g., 501(c)(3)).",
title="Subsection Code",
)
ruling_date: str = Field(
...,
description="Date of IRS ruling or determination letter.",
title="Ruling/Determination Letter Date",
)
accounting_method: str = Field(
...,
description="Accounting method used (cash, accrual, other).",
title="Accounting Method",
)
organization_type: str = Field(
...,
description="Legal structure (corporation, trust, association, etc.).",
title="Organization Type",
)
year_of_formation: str = Field(
..., description="Year the organization was formed.", title="Year of Formation"
)
incorporation_state: str = Field(
..., description="State of incorporation.", title="Incorporation State"
)
class RevenueBreakdown(BaseModel):
total_revenue: float = Field(
..., description="Sum of all revenue sources.", title="Total Revenue"
)
contributions_gifts_grants: float = Field(
...,
description="Revenue from donations and grants.",
title="Contributions, Gifts, and Grants",
)
program_service_revenue: float = Field(
...,
description="Revenue from program services.",
title="Program Service Revenue",
)
membership_dues: float = Field(
..., description="Revenue from membership dues.", title="Membership Dues"
)
investment_income: float = Field(
...,
description="Revenue from interest and dividends.",
title="Investment Income",
)
gains_losses_sales_assets: float = Field(
...,
description="Net gains or losses from asset sales.",
title="Gains/Losses from Sales of Assets",
)
rental_income: float = Field(
...,
description="Income from rental of real estate or equipment.",
title="Rental Income",
)
related_organizations_revenue: float = Field(
...,
description="Revenue from related organizations.",
title="Related Organizations Revenue",
)
gaming_revenue: float = Field(
..., description="Revenue from gaming activities.", title="Gaming Revenue"
)
other_revenue: float = Field(
..., description="Miscellaneous revenue sources.", title="Other Revenue"
)
government_grants: float = Field(
...,
description="Revenue from government grants.",
title="Revenue from Government Grants",
)
foreign_contributions: float = Field(
..., description="Revenue from foreign sources.", title="Foreign Contributions"
)
class ExpensesBreakdown(BaseModel):
total_expenses: float = Field(
..., description="Sum of all expenses.", title="Total Functional Expenses"
)
program_services_expenses: float = Field(
...,
description="Expenses for program services.",
title="Program Services Expenses",
)
management_general_expenses: float = Field(
...,
description="Administrative and management expenses.",
title="Management & General Expenses",
)
fundraising_expenses: float = Field(
...,
description="Expenses for fundraising activities.",
title="Fundraising Expenses",
)
grants_us_organizations: float = Field(
...,
description="Grants and assistance to U.S. organizations.",
title="Grants to U.S. Organizations",
)
grants_us_individuals: float = Field(
...,
description="Grants and assistance to U.S. individuals.",
title="Grants to U.S. Individuals",
)
grants_foreign_organizations: float = Field(
...,
description="Grants and assistance to foreign organizations.",
title="Grants to Foreign Organizations",
)
grants_foreign_individuals: float = Field(
...,
description="Grants and assistance to foreign individuals.",
title="Grants to Foreign Individuals",
)
compensation_officers: float = Field(
...,
description="Compensation paid to officers and key employees.",
title="Compensation of Officers/Key Employees",
)
compensation_other_staff: float = Field(
...,
description="Compensation paid to other staff.",
title="Compensation of Other Staff",
)
payroll_taxes_benefits: float = Field(
...,
description="Payroll taxes and employee benefits.",
title="Payroll Taxes and Benefits",
)
professional_fees: float = Field(
...,
description="Legal, accounting, and lobbying fees.",
title="Professional Fees",
)
office_occupancy_costs: float = Field(
...,
description="Office and occupancy expenses.",
title="Office and Occupancy Costs",
)
information_technology_costs: float = Field(
..., description="IT-related expenses.", title="Information Technology Costs"
)
travel_conference_expenses: float = Field(
...,
description="Travel and conference costs.",
title="Travel and Conference Expenses",
)
depreciation_amortization: float = Field(
...,
description="Depreciation and amortization expenses.",
title="Depreciation and Amortization",
)
insurance: float = Field(..., description="Insurance expenses.", title="Insurance")
class OfficersDirectorsTrusteesKeyEmployee(BaseModel):
name: str = Field(..., description="Full name of the individual.", title="Name")
title_position: str = Field(
..., description="Role or position held.", title="Title/Position"
)
average_hours_per_week: float = Field(
...,
description="Average weekly hours devoted to position.",
title="Average Hours Per Week",
)
related_party_transactions: str = Field(
...,
description="Indicates if related-party transactions occurred.",
title="Related-Party Transactions",
)
former_officer: str = Field(
...,
description="Indicates if the individual is a former officer.",
title="Former Officer Indicator",
)
governance_role: str = Field(
...,
description="Role in governance (voting, independent, etc.).",
title="Governance Role",
)
class GovernanceManagementDisclosure(BaseModel):
governing_body_size: float = Field(
...,
description="Number of voting members on the governing body.",
title="Governing Body Size",
)
independent_members: float = Field(
...,
description="Number of independent voting members.",
title="Number of Independent Members",
)
financial_statements_reviewed: str = Field(
...,
description="Indicates if financial statements were reviewed or audited.",
title="Financial Statements Reviewed/Audited",
)
form_990_provided_to_governing_body: str = Field(
...,
description="Indicates if Form 990 was provided to governing body before filing.",
title="Form 990 Provided to Governing Body",
)
conflict_of_interest_policy: str = Field(
...,
description="Indicates if a conflict-of-interest policy is in place.",
title="Conflict-of-Interest Policy",
)
whistleblower_policy: str = Field(
...,
description="Indicates if a whistleblower policy is in place.",
title="Whistleblower Policy",
)
document_retention_policy: str = Field(
...,
description="Indicates if a document retention/destruction policy is in place.",
title="Document Retention/Destruction Policy",
)
ceo_compensation_review_process: str = Field(
...,
description="Description of CEO compensation review process.",
title="CEO Compensation Review Process",
)
public_disclosure_practices: str = Field(
...,
description="Description of public disclosure practices.",
title="Public Disclosure Practices",
)
class ProgramServiceAccomplishment(BaseModel):
program_name: str = Field(
..., description="Name of the program.", title="Program Name"
)
program_description: str = Field(
..., description="Description of the program.", title="Program Description"
)
expenses: float = Field(
..., description="Expenses for the program.", title="Program Expenses"
)
grants: float = Field(
..., description="Grants made under the program.", title="Program Grants"
)
revenue_generated: float = Field(
..., description="Revenue generated by the program.", title="Revenue Generated"
)
quantitative_outputs: str = Field(
...,
description="Quantitative outputs (e.g., number served, events held).",
title="Quantitative Outputs",
)
class FundraisingGrantmaking(BaseModel):
total_fundraising_event_revenue: float = Field(
...,
description="Total revenue from fundraising events.",
title="Total Fundraising Event Revenue",
)
total_fundraising_event_expenses: float = Field(
...,
description="Total direct expenses for fundraising events.",
title="Total Fundraising Event Expenses",
)
professional_fundraiser_fees: float = Field(
...,
description="Fees paid to professional fundraisers.",
title="Professional Fundraiser Fees",
)
class FunctionalOperationalData(BaseModel):
number_of_employees: float = Field(
..., description="Total number of employees.", title="Number of Employees"
)
number_of_volunteers: float = Field(
..., description="Total number of volunteers.", title="Number of Volunteers"
)
occupancy_costs: float = Field(
..., description="Total occupancy costs.", title="Occupancy Costs"
)
fundraising_method_descriptions: str = Field(
...,
description="Descriptions of fundraising methods used.",
title="Fundraising Method Descriptions",
)
joint_ventures_disregarded_entities: str = Field(
...,
description="Details of joint ventures and disregarded entities.",
title="Joint Ventures and Disregarded Entities",
)
class CompensationDetails(BaseModel):
base_compensation: float = Field(
..., description="Base salary or wages.", title="Base Compensation"
)
bonus: float = Field(
..., description="Bonus or incentive compensation.", title="Bonus Compensation"
)
incentive: float = Field(
..., description="Incentive compensation.", title="Incentive Compensation"
)
other: float = Field(
..., description="Other forms of compensation.", title="Other Compensation"
)
non_fixed_compensation: str = Field(
...,
description="Indicates if compensation is non-fixed.",
title="Non-Fixed Compensation Flag",
)
first_class_travel: str = Field(
...,
description="Indicates if first-class travel was provided.",
title="First-Class Travel",
)
housing_allowance: str = Field(
...,
description="Indicates if housing allowance was provided.",
title="Housing Allowance",
)
expense_account_usage: str = Field(
...,
description="Indicates if expense account was used.",
title="Expense Account Usage",
)
supplemental_retirement: str = Field(
...,
description="Indicates if supplemental retirement or deferred comp was provided.",
title="Supplemental Retirement/Deferred Comp",
)
class PoliticalLobbyingActivities(BaseModel):
lobbying_expenditures_direct: float = Field(
...,
description="Direct lobbying expenditures.",
title="Direct Lobbying Expenditures",
)
lobbying_expenditures_grassroots: float = Field(
...,
description="Grassroots lobbying expenditures.",
title="Grassroots Lobbying Expenditures",
)
election_501h_status: str = Field(
...,
description="Indicates if 501(h) election was made.",
title="501(h) Election Status",
)
political_campaign_expenditures: float = Field(
...,
description="Expenditures for political campaigns.",
title="Political Campaign Expenditures",
)
related_organizations_affiliates: str = Field(
...,
description="Details of related organizations or affiliates involved.",
title="Related Organizations/Affiliates Involved",
)
class InvestmentsEndowment(BaseModel):
investment_types: str = Field(
...,
description="Types of investments held (securities, partnerships, real estate).",
title="Investment Types",
)
donor_restricted_endowment_values: float = Field(
...,
description="Value of donor-restricted endowments.",
title="Donor-Restricted Endowment Values",
)
net_appreciation_depreciation: float = Field(
...,
description="Net appreciation or depreciation of investments.",
title="Net Appreciation/Depreciation",
)
related_organization_transactions: str = Field(
...,
description="Details of transactions with related organizations.",
title="Related Organization Transactions",
)
loans_to_from_related_parties: str = Field(
...,
description="Details of loans to or from related parties.",
title="Loans to/from Related Parties",
)
class TaxCompliancePenalties(BaseModel):
penalties_excise_taxes_reported: str = Field(
...,
description="Reported penalties or excise taxes.",
title="Penalties or Excise Taxes Reported",
)
unrelated_business_income_disclosure: str = Field(
...,
description="Disclosure of unrelated business income (UBI).",
title="Unrelated Business Income Disclosure",
)
foreign_bank_account_reporting: str = Field(
...,
description="Disclosure of foreign bank accounts (FBAR equivalent).",
title="Foreign Bank Account Reporting",
)
schedule_o_narrative_explanations: str = Field(
...,
description="Narrative explanations from Schedule O.",
title="Schedule O Narrative Explanations",
)
_OFFICER_HOURS_PATTERN = re.compile(r"([\d.]+)\s*hrs?/wk", re.IGNORECASE)
def _parse_officer_list(entries: list[str] | None) -> list[dict[str, Any]]:
if not entries:
return []
parsed: list[dict[str, Any]] = []
for raw in entries:
if not isinstance(raw, str):
continue
parts = [part.strip() for part in raw.split(",")]
name = parts[0] if parts else ""
title = parts[1] if len(parts) > 1 else ""
role = parts[3] if len(parts) > 3 else ""
hours = 0.0
match = _OFFICER_HOURS_PATTERN.search(raw)
if match:
try:
hours = float(match.group(1))
except ValueError:
hours = 0.0
parsed.append(
{
"name": name,
"title_position": title,
"average_hours_per_week": hours,
"related_party_transactions": "",
"former_officer": "",
"governance_role": role,
}
)
return parsed
def _build_program_accomplishments(
descriptions: list[str] | None,
) -> list[dict[str, Any]]:
if not descriptions:
return []
programs: list[dict[str, Any]] = []
for idx, description in enumerate(descriptions, start=1):
if not isinstance(description, str):
continue
programs.append(
{
"program_name": f"Program {idx}",
"program_description": description.strip(),
"expenses": 0.0,
"grants": 0.0,
"revenue_generated": 0.0,
"quantitative_outputs": "",
}
)
return programs
def _transform_flat_payload(data: dict[str, Any]) -> dict[str, Any]:
def get_str(key: str) -> str:
value = data.get(key)
if value is None:
return ""
return str(value)
def get_value(key: str, default: Any = 0) -> Any:
return data.get(key, default)
transformed: dict[str, Any] = {
"core_organization_metadata": {
"ein": get_str("ein"),
"legal_name": get_str("legal_name"),
"phone_number": get_str("phone_number"),
"website_url": get_str("website_url"),
"return_type": get_str("return_type"),
"amended_return": get_str("amended_return"),
"group_exemption_number": get_str("group_exemption_number"),
"subsection_code": get_str("subsection_code"),
"ruling_date": get_str("ruling_date"),
"accounting_method": get_str("accounting_method"),
"organization_type": get_str("organization_type"),
"year_of_formation": get_str("year_of_formation"),
"incorporation_state": get_str("incorporation_state"),
},
"revenue_breakdown": {
"total_revenue": get_value("total_revenue"),
"contributions_gifts_grants": get_value("contributions_gifts_grants"),
"program_service_revenue": get_value("program_service_revenue"),
"membership_dues": get_value("membership_dues"),
"investment_income": get_value("investment_income"),
"gains_losses_sales_assets": get_value("gains_losses_sales_assets"),
"rental_income": get_value("rental_income"),
"related_organizations_revenue": get_value("related_organizations_revenue"),
"gaming_revenue": get_value("gaming_revenue"),
"other_revenue": get_value("other_revenue"),
"government_grants": get_value("government_grants"),
"foreign_contributions": get_value("foreign_contributions"),
},
"expenses_breakdown": {
"total_expenses": get_value("total_expenses"),
"program_services_expenses": get_value("program_services_expenses"),
"management_general_expenses": get_value("management_general_expenses"),
"fundraising_expenses": get_value("fundraising_expenses"),
"grants_us_organizations": get_value("grants_us_organizations"),
"grants_us_individuals": get_value("grants_us_individuals"),
"grants_foreign_organizations": get_value("grants_foreign_organizations"),
"grants_foreign_individuals": get_value("grants_foreign_individuals"),
"compensation_officers": get_value("compensation_officers"),
"compensation_other_staff": get_value("compensation_other_staff"),
"payroll_taxes_benefits": get_value("payroll_taxes_benefits"),
"professional_fees": get_value("professional_fees"),
"office_occupancy_costs": get_value("office_occupancy_costs"),
"information_technology_costs": get_value("information_technology_costs"),
"travel_conference_expenses": get_value("travel_conference_expenses"),
"depreciation_amortization": get_value("depreciation_amortization"),
"insurance": get_value("insurance"),
},
"balance_sheet": data.get("balance_sheet") or {},
"officers_directors_trustees_key_employees": _parse_officer_list(
data.get("officers_list")
),
"governance_management_disclosure": {
"governing_body_size": get_value("governing_body_size"),
"independent_members": get_value("independent_members"),
"financial_statements_reviewed": get_str("financial_statements_reviewed"),
"form_990_provided_to_governing_body": get_str(
"form_990_provided_to_governing_body"
),
"conflict_of_interest_policy": get_str("conflict_of_interest_policy"),
"whistleblower_policy": get_str("whistleblower_policy"),
"document_retention_policy": get_str("document_retention_policy"),
"ceo_compensation_review_process": get_str(
"ceo_compensation_review_process"
),
"public_disclosure_practices": get_str("public_disclosure_practices"),
},
"program_service_accomplishments": _build_program_accomplishments(
data.get("program_accomplishments_list")
),
"fundraising_grantmaking": {
"total_fundraising_event_revenue": get_value(
"total_fundraising_event_revenue"
),
"total_fundraising_event_expenses": get_value(
"total_fundraising_event_expenses"
),
"professional_fundraiser_fees": get_value("professional_fundraiser_fees"),
},
"functional_operational_data": {
"number_of_employees": get_value("number_of_employees"),
"number_of_volunteers": get_value("number_of_volunteers"),
"occupancy_costs": get_value("occupancy_costs"),
"fundraising_method_descriptions": get_str(
"fundraising_method_descriptions"
),
"joint_ventures_disregarded_entities": get_str(
"joint_ventures_disregarded_entities"
),
},
"compensation_details": {
"base_compensation": get_value("base_compensation"),
"bonus": get_value("bonus"),
"incentive": get_value("incentive"),
"other": get_value("other_compensation", get_value("other", 0)),
"non_fixed_compensation": get_str("non_fixed_compensation"),
"first_class_travel": get_str("first_class_travel"),
"housing_allowance": get_str("housing_allowance"),
"expense_account_usage": get_str("expense_account_usage"),
"supplemental_retirement": get_str("supplemental_retirement"),
},
"political_lobbying_activities": {
"lobbying_expenditures_direct": get_value("lobbying_expenditures_direct"),
"lobbying_expenditures_grassroots": get_value(
"lobbying_expenditures_grassroots"
),
"election_501h_status": get_str("election_501h_status"),
"political_campaign_expenditures": get_value(
"political_campaign_expenditures"
),
"related_organizations_affiliates": get_str(
"related_organizations_affiliates"
),
},
"investments_endowment": {
"investment_types": get_str("investment_types"),
"donor_restricted_endowment_values": get_value(
"donor_restricted_endowment_values"
),
"net_appreciation_depreciation": get_value("net_appreciation_depreciation"),
"related_organization_transactions": get_str(
"related_organization_transactions"
),
"loans_to_from_related_parties": get_str("loans_to_from_related_parties"),
},
"tax_compliance_penalties": {
"penalties_excise_taxes_reported": get_str(
"penalties_excise_taxes_reported"
),
"unrelated_business_income_disclosure": get_str(
"unrelated_business_income_disclosure"
),
"foreign_bank_account_reporting": get_str("foreign_bank_account_reporting"),
"schedule_o_narrative_explanations": get_str(
"schedule_o_narrative_explanations"
),
},
}
return transformed
class ExtractedIrsForm990PfDataSchema(BaseModel):
core_organization_metadata: CoreOrganizationMetadata = Field(
...,
description="Essential identifiers and attributes for normalizing entities across filings and years.",
title="Core Organization Metadata",
)
revenue_breakdown: RevenueBreakdown = Field(
...,
description="Detailed breakdown of revenue streams for the fiscal year.",
title="Revenue Breakdown",
)
expenses_breakdown: ExpensesBreakdown = Field(
...,
description="Detailed breakdown of expenses for the fiscal year.",
title="Expenses Breakdown",
)
balance_sheet: dict[str, Any] = Field(
default_factory=dict,
description="Assets, liabilities, and net assets at year end.",
title="Balance Sheet Data",
)
officers_directors_trustees_key_employees: list[
OfficersDirectorsTrusteesKeyEmployee
] = Field(
...,
description="List of key personnel and their compensation.",
title="Officers, Directors, Trustees, Key Employees",
)
governance_management_disclosure: GovernanceManagementDisclosure = Field(
...,
description="Governance and management practices, policies, and disclosures.",
title="Governance, Management, and Disclosure",
)
program_service_accomplishments: list[ProgramServiceAccomplishment] = Field(
...,
description="Major programs and their outputs for the fiscal year.",
title="Program Service Accomplishments",
)
fundraising_grantmaking: FundraisingGrantmaking = Field(
...,
description="Fundraising event details and grantmaking activities.",
title="Fundraising & Grantmaking",
)
functional_operational_data: FunctionalOperationalData = Field(
...,
description="Operational metrics and related-organization relationships.",
title="Functional & Operational Data",
)
compensation_details: CompensationDetails = Field(
...,
description="Detailed breakdown of officer compensation and benefits.",
title="Compensation Details",
)
political_lobbying_activities: PoliticalLobbyingActivities = Field(
...,
description="Details of political and lobbying expenditures and affiliations.",
title="Political & Lobbying Activities",
)
investments_endowment: InvestmentsEndowment = Field(
...,
description="Investment holdings, endowment values, and related transactions.",
title="Investments & Endowment",
)
tax_compliance_penalties: TaxCompliancePenalties = Field(
...,
description="Tax compliance indicators, penalties, and narrative explanations.",
title="Tax Compliance / Penalties",
)
@model_validator(mode="before")
@classmethod
def _ensure_structure(cls, value: Any) -> Any:
if not isinstance(value, dict):
return value
if "core_organization_metadata" in value:
return value
return _transform_flat_payload(value)
class ValidatorState(BaseModel):
extraction: ExtractedIrsForm990PfDataSchema
initial_findings: list[AuditFinding] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from .agent import agent
from .models import WebSearchResponse, WebSearchState
async def search_web(
query: str,
max_results: int = 5,
include_raw_content: bool = False,
) -> WebSearchResponse:
"""
Execute web search using Tavily MCP server.
Args:
query: Search query string
max_results: Maximum number of results to return (1-10)
include_raw_content: Whether to include full content in results
Returns:
WebSearchResponse with results and summary
"""
state = WebSearchState(
user_query=query,
max_results=max_results,
include_raw_content=include_raw_content,
)
prompt = (
f"Search the web for: {query}\n\n"
f"Return the top {max_results} most relevant results. "
"Provide a concise summary of the key findings."
)
# Ejecutar agente con Tavily API directa
result = await agent.run(prompt, deps=state)
return result.output

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
from pydantic_ai import Agent, RunContext
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.azure import AzureProvider
from tavily import TavilyClient
from app.core.config import settings
from .models import WebSearchResponse, WebSearchState, SearchResult
provider = AzureProvider(
azure_endpoint=settings.AZURE_OPENAI_ENDPOINT,
api_version=settings.AZURE_OPENAI_API_VERSION,
api_key=settings.AZURE_OPENAI_API_KEY,
)
model = OpenAIChatModel(model_name="gpt-4o", provider=provider)
tavily_client = TavilyClient(api_key=settings.TAVILY_API_KEY)
agent = Agent(
model=model,
name="WebSearchAgent",
deps_type=WebSearchState,
output_type=WebSearchResponse,
system_prompt=(
"You are a web search assistant powered by Tavily. "
"Use the tavily_search tool to find relevant, up-to-date information. "
"Return a structured WebSearchResponse with results and a concise summary. "
"Always cite your sources with URLs."
),
)
@agent.tool
def tavily_search(ctx: RunContext[WebSearchState], query: str) -> list[SearchResult]:
"""Search the web using Tavily API for up-to-date information."""
response = tavily_client.search(
query=query,
max_results=ctx.deps.max_results,
search_depth="basic",
include_raw_content=ctx.deps.include_raw_content,
)
results = []
for item in response.get("results", []):
results.append(
SearchResult(
title=item.get("title", ""),
url=item.get("url", ""),
content=item.get("content", ""),
score=item.get("score"),
)
)
return results
@agent.output_validator
def finalize_response(
ctx: RunContext[WebSearchState],
response: WebSearchResponse,
) -> WebSearchResponse:
"""Post-process and validate the search response"""
return response.model_copy(
update={
"query": ctx.deps.user_query,
"total_results": len(response.results),
}
)

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from pydantic import BaseModel, Field
class SearchResult(BaseModel):
"""Individual search result from web search"""
title: str
url: str
content: str
score: float | None = None
class WebSearchState(BaseModel):
"""State passed to agent tools via deps"""
user_query: str
max_results: int = Field(default=5, ge=1, le=10)
include_raw_content: bool = False
class WebSearchResponse(BaseModel):
"""Structured response from the web search agent"""
query: str
results: list[SearchResult]
summary: str
total_results: int

View File

@@ -1,6 +1,6 @@
import os
from typing import List
from pydantic import validator
from pydantic import RedisDsn
from pydantic_settings import BaseSettings
@@ -8,20 +8,22 @@ class Settings(BaseSettings):
"""
Configuración básica de la aplicación
"""
# Configuración básica de la aplicación
APP_NAME: str = "File Manager API"
DEBUG: bool = False
HOST: str = "0.0.0.0"
PORT: int = 8000
# Configuración de CORS para React frontend
ALLOWED_ORIGINS: List[str] = [
"http://localhost:3000", # React dev server
"http://localhost:5173",
"http://frontend:3000", # Docker container name
"http://frontend:3000", # Docker container name
]
REDIS_OM_URL: RedisDsn
# Azure Blob Storage configuración
AZURE_STORAGE_CONNECTION_STRING: str
AZURE_STORAGE_ACCOUNT_NAME: str = ""
@@ -35,10 +37,17 @@ class Settings(BaseSettings):
# Azure OpenAI configuración
AZURE_OPENAI_ENDPOINT: str
AZURE_OPENAI_API_KEY: str
AZURE_OPENAI_API_VERSION: str = "2024-02-01"
AZURE_OPENAI_API_VERSION: str = "2024-08-01-preview"
AZURE_OPENAI_EMBEDDING_MODEL: str = "text-embedding-3-large"
AZURE_OPENAI_EMBEDDING_DEPLOYMENT: str = "text-embedding-3-large"
# Rate limiting para embeddings (ajustar según tier de Azure OpenAI)
# S0 tier: batch_size=16, delay=1.0 es seguro
# Tier superior: batch_size=100, delay=0.1
EMBEDDING_BATCH_SIZE: int = 16
EMBEDDING_DELAY_BETWEEN_BATCHES: float = 1.0
EMBEDDING_MAX_RETRIES: int = 5
# Google Cloud / Vertex AI configuración
GOOGLE_APPLICATION_CREDENTIALS: str
GOOGLE_CLOUD_PROJECT: str
@@ -48,70 +57,17 @@ class Settings(BaseSettings):
# LandingAI configuración
LANDINGAI_API_KEY: str
LANDINGAI_ENVIRONMENT: str = "production" # "production" o "eu"
TAVILY_API_KEY: str
# Schemas storage
SCHEMAS_DIR: str = "./data/schemas"
@validator("AZURE_STORAGE_CONNECTION_STRING")
def validate_azure_connection_string(cls, v):
"""Validar que el connection string de Azure esté presente"""
if not v:
raise ValueError("AZURE_STORAGE_CONNECTION_STRING es requerido")
return v
@validator("QDRANT_URL")
def validate_qdrant_url(cls, v):
"""Validar que la URL de Qdrant esté presente"""
if not v:
raise ValueError("QDRANT_URL es requerido")
return v
@validator("QDRANT_API_KEY")
def validate_qdrant_api_key(cls, v):
"""Validar que la API key de Qdrant esté presente"""
if not v:
raise ValueError("QDRANT_API_KEY es requerido")
return v
@validator("AZURE_OPENAI_ENDPOINT")
def validate_azure_openai_endpoint(cls, v):
"""Validar que el endpoint de Azure OpenAI esté presente"""
if not v:
raise ValueError("AZURE_OPENAI_ENDPOINT es requerido")
return v
@validator("AZURE_OPENAI_API_KEY")
def validate_azure_openai_api_key(cls, v):
"""Validar que la API key de Azure OpenAI esté presente"""
if not v:
raise ValueError("AZURE_OPENAI_API_KEY es requerido")
return v
@validator("GOOGLE_APPLICATION_CREDENTIALS")
def validate_google_credentials(cls, v):
"""Validar que el path de credenciales de Google esté presente"""
if not v:
raise ValueError("GOOGLE_APPLICATION_CREDENTIALS es requerido")
return v
@validator("GOOGLE_CLOUD_PROJECT")
def validate_google_project(cls, v):
"""Validar que el proyecto de Google Cloud esté presente"""
if not v:
raise ValueError("GOOGLE_CLOUD_PROJECT es requerido")
return v
@validator("LANDINGAI_API_KEY")
def validate_landingai_api_key(cls, v):
"""Validar que la API key de LandingAI esté presente"""
if not v:
raise ValueError("LANDINGAI_API_KEY es requerido")
return v
class Config:
env_file = ".env"
case_sensitive = True
# Instancia global de configuración
settings = Settings()
settings = Settings.model_validate({})

View File

@@ -0,0 +1,608 @@
{
"extraction": {
"core_organization_metadata": {
"ein": "84-2674654",
"legal_name": "07 IN HEAVEN MEMORIAL SCHOLARSHIP",
"phone_number": "(262) 215-0300",
"website_url": "",
"return_type": "990-PF",
"amended_return": "No",
"group_exemption_number": "",
"subsection_code": "501(c)(3)",
"ruling_date": "",
"accounting_method": "Cash",
"organization_type": "corporation",
"year_of_formation": "",
"incorporation_state": "WI"
},
"revenue_breakdown": {
"total_revenue": 5227,
"contributions_gifts_grants": 5227,
"program_service_revenue": 0,
"membership_dues": 0,
"investment_income": 0,
"gains_losses_sales_assets": 0,
"rental_income": 0,
"related_organizations_revenue": 0,
"gaming_revenue": 0,
"other_revenue": 0,
"government_grants": 0,
"foreign_contributions": 0
},
"expenses_breakdown": {
"total_expenses": 2104,
"program_services_expenses": 0,
"management_general_expenses": 0,
"fundraising_expenses": 2104,
"grants_us_organizations": 0,
"grants_us_individuals": 0,
"grants_foreign_organizations": 0,
"grants_foreign_individuals": 0,
"compensation_officers": 0,
"compensation_other_staff": 0,
"payroll_taxes_benefits": 0,
"professional_fees": 0,
"office_occupancy_costs": 0,
"information_technology_costs": 0,
"travel_conference_expenses": 0,
"depreciation_amortization": 0,
"insurance": 0
},
"balance_sheet": {},
"officers_directors_trustees_key_employees": [
{
"name": "REBECCA TERPSTRA",
"title_position": "PRESIDENT",
"average_hours_per_week": 0.1,
"related_party_transactions": "",
"former_officer": "",
"governance_role": ""
},
{
"name": "ROBERT GUZMAN",
"title_position": "VICE PRESDEINT",
"average_hours_per_week": 0.1,
"related_party_transactions": "",
"former_officer": "",
"governance_role": ""
},
{
"name": "ANDREA VALENTI",
"title_position": "TREASURER",
"average_hours_per_week": 0.1,
"related_party_transactions": "",
"former_officer": "",
"governance_role": ""
},
{
"name": "BETHANY WALSH",
"title_position": "SECRETARY",
"average_hours_per_week": 0.1,
"related_party_transactions": "",
"former_officer": "",
"governance_role": ""
}
],
"governance_management_disclosure": {
"governing_body_size": 4,
"independent_members": 4,
"financial_statements_reviewed": "",
"form_990_provided_to_governing_body": "",
"conflict_of_interest_policy": "",
"whistleblower_policy": "",
"document_retention_policy": "",
"ceo_compensation_review_process": "",
"public_disclosure_practices": "Yes"
},
"program_service_accomplishments": [],
"fundraising_grantmaking": {
"total_fundraising_event_revenue": 0,
"total_fundraising_event_expenses": 2104,
"professional_fundraiser_fees": 0
},
"functional_operational_data": {
"number_of_employees": 0,
"number_of_volunteers": 0,
"occupancy_costs": 0,
"fundraising_method_descriptions": "",
"joint_ventures_disregarded_entities": ""
},
"compensation_details": {
"base_compensation": 0,
"bonus": 0,
"incentive": 0,
"other": 0,
"non_fixed_compensation": "",
"first_class_travel": "",
"housing_allowance": "",
"expense_account_usage": "",
"supplemental_retirement": ""
},
"political_lobbying_activities": {
"lobbying_expenditures_direct": 0,
"lobbying_expenditures_grassroots": 0,
"election_501h_status": "",
"political_campaign_expenditures": 0,
"related_organizations_affiliates": ""
},
"investments_endowment": {
"investment_types": "",
"donor_restricted_endowment_values": 0,
"net_appreciation_depreciation": 0,
"related_organization_transactions": "",
"loans_to_from_related_parties": ""
},
"tax_compliance_penalties": {
"penalties_excise_taxes_reported": "No",
"unrelated_business_income_disclosure": "No",
"foreign_bank_account_reporting": "No",
"schedule_o_narrative_explanations": ""
}
},
"extraction_metadata": {
"core_organization_metadata": {
"ein": {
"value": "84-2674654",
"references": ["0-7"]
},
"legal_name": {
"value": "07 IN HEAVEN MEMORIAL SCHOLARSHIP",
"references": ["0-6"]
},
"phone_number": {
"value": "(262) 215-0300",
"references": ["0-a"]
},
"website_url": {
"value": "",
"references": []
},
"return_type": {
"value": "990-PF",
"references": ["4ade8ed0-bce7-4bd5-bd8d-190e3e4be95b"]
},
"amended_return": {
"value": "No",
"references": ["4ac9edc4-e9bb-430f-b4c4-a42bf4c04b28"]
},
"group_exemption_number": {
"value": "",
"references": []
},
"subsection_code": {
"value": "501(c)(3)",
"references": ["4ac9edc4-e9bb-430f-b4c4-a42bf4c04b28"]
},
"ruling_date": {
"value": "",
"references": []
},
"accounting_method": {
"value": "Cash",
"references": ["0-d"]
},
"organization_type": {
"value": "corporation",
"references": ["4ac9edc4-e9bb-430f-b4c4-a42bf4c04b28"]
},
"year_of_formation": {
"value": "",
"references": []
},
"incorporation_state": {
"value": "WI",
"references": ["4ac9edc4-e9bb-430f-b4c4-a42bf4c04b28"]
}
},
"revenue_breakdown": {
"total_revenue": {
"value": 5227,
"references": ["0-1z"]
},
"contributions_gifts_grants": {
"value": 5227,
"references": ["0-m"]
},
"program_service_revenue": {
"value": 0,
"references": []
},
"membership_dues": {
"value": 0,
"references": []
},
"investment_income": {
"value": 0,
"references": []
},
"gains_losses_sales_assets": {
"value": 0,
"references": []
},
"rental_income": {
"value": 0,
"references": []
},
"related_organizations_revenue": {
"value": 0,
"references": []
},
"gaming_revenue": {
"value": 0,
"references": []
},
"other_revenue": {
"value": 0,
"references": []
},
"government_grants": {
"value": 0,
"references": []
},
"foreign_contributions": {
"value": 0,
"references": []
}
},
"expenses_breakdown": {
"total_expenses": {
"value": 2104,
"references": ["0-2S"]
},
"program_services_expenses": {
"value": 0,
"references": []
},
"management_general_expenses": {
"value": 0,
"references": []
},
"fundraising_expenses": {
"value": 2104,
"references": ["13-d"]
},
"grants_us_organizations": {
"value": 0,
"references": []
},
"grants_us_individuals": {
"value": 0,
"references": []
},
"grants_foreign_organizations": {
"value": 0,
"references": []
},
"grants_foreign_individuals": {
"value": 0,
"references": []
},
"compensation_officers": {
"value": 0,
"references": ["5-1q", "5-1w", "5-1C", "5-1I"]
},
"compensation_other_staff": {
"value": 0,
"references": []
},
"payroll_taxes_benefits": {
"value": 0,
"references": []
},
"professional_fees": {
"value": 0,
"references": []
},
"office_occupancy_costs": {
"value": 0,
"references": []
},
"information_technology_costs": {
"value": 0,
"references": []
},
"travel_conference_expenses": {
"value": 0,
"references": []
},
"depreciation_amortization": {
"value": 0,
"references": []
},
"insurance": {
"value": 0,
"references": []
}
},
"balance_sheet": {},
"officers_directors_trustees_key_employees": [
{
"name": {
"value": "REBECCA TERPSTRA",
"references": ["5-1o"]
},
"title_position": {
"value": "PRESIDENT",
"references": ["5-1p"]
},
"average_hours_per_week": {
"value": 0.1,
"references": ["5-1p"]
},
"related_party_transactions": {
"value": "",
"references": []
},
"former_officer": {
"value": "",
"references": []
},
"governance_role": {
"value": "",
"references": []
}
},
{
"name": {
"value": "ROBERT GUZMAN",
"references": ["5-1u"]
},
"title_position": {
"value": "VICE PRESDEINT",
"references": ["5-1v"]
},
"average_hours_per_week": {
"value": 0.1,
"references": ["5-1v"]
},
"related_party_transactions": {
"value": "",
"references": []
},
"former_officer": {
"value": "",
"references": []
},
"governance_role": {
"value": "",
"references": []
}
},
{
"name": {
"value": "ANDREA VALENTI",
"references": ["5-1A"]
},
"title_position": {
"value": "TREASURER",
"references": ["5-1B"]
},
"average_hours_per_week": {
"value": 0.1,
"references": ["5-1B"]
},
"related_party_transactions": {
"value": "",
"references": []
},
"former_officer": {
"value": "",
"references": []
},
"governance_role": {
"value": "",
"references": []
}
},
{
"name": {
"value": "BETHANY WALSH",
"references": ["5-1G"]
},
"title_position": {
"value": "SECRETARY",
"references": ["5-1H"]
},
"average_hours_per_week": {
"value": 0.1,
"references": ["5-1H"]
},
"related_party_transactions": {
"value": "",
"references": []
},
"former_officer": {
"value": "",
"references": []
},
"governance_role": {
"value": "",
"references": []
}
}
],
"governance_management_disclosure": {
"governing_body_size": {
"value": 4,
"references": ["5-1o", "5-1u", "5-1A", "5-1G"]
},
"independent_members": {
"value": 4,
"references": ["5-1o", "5-1u", "5-1A", "5-1G"]
},
"financial_statements_reviewed": {
"value": "",
"references": []
},
"form_990_provided_to_governing_body": {
"value": "",
"references": []
},
"conflict_of_interest_policy": {
"value": "",
"references": []
},
"whistleblower_policy": {
"value": "",
"references": []
},
"document_retention_policy": {
"value": "",
"references": []
},
"ceo_compensation_review_process": {
"value": "",
"references": []
},
"public_disclosure_practices": {
"value": "Yes",
"references": ["4-g"]
}
},
"program_service_accomplishments": [],
"fundraising_grantmaking": {
"total_fundraising_event_revenue": {
"value": 0,
"references": []
},
"total_fundraising_event_expenses": {
"value": 2104,
"references": ["13-d"]
},
"professional_fundraiser_fees": {
"value": 0,
"references": []
}
},
"functional_operational_data": {
"number_of_employees": {
"value": 0,
"references": []
},
"number_of_volunteers": {
"value": 0,
"references": []
},
"occupancy_costs": {
"value": 0,
"references": []
},
"fundraising_method_descriptions": {
"value": "",
"references": []
},
"joint_ventures_disregarded_entities": {
"value": "",
"references": []
}
},
"compensation_details": {
"base_compensation": {
"value": 0,
"references": ["5-1q", "5-1w"]
},
"bonus": {
"value": 0,
"references": []
},
"incentive": {
"value": 0,
"references": []
},
"other": {
"value": 0,
"references": []
},
"non_fixed_compensation": {
"value": "",
"references": []
},
"first_class_travel": {
"value": "",
"references": []
},
"housing_allowance": {
"value": "",
"references": []
},
"expense_account_usage": {
"value": "",
"references": []
},
"supplemental_retirement": {
"value": "",
"references": []
}
},
"political_lobbying_activities": {
"lobbying_expenditures_direct": {
"value": 0,
"references": []
},
"lobbying_expenditures_grassroots": {
"value": 0,
"references": []
},
"election_501h_status": {
"value": "",
"references": []
},
"political_campaign_expenditures": {
"value": 0,
"references": []
},
"related_organizations_affiliates": {
"value": "",
"references": []
}
},
"investments_endowment": {
"investment_types": {
"value": "",
"references": []
},
"donor_restricted_endowment_values": {
"value": 0,
"references": []
},
"net_appreciation_depreciation": {
"value": 0,
"references": []
},
"related_organization_transactions": {
"value": "",
"references": []
},
"loans_to_from_related_parties": {
"value": "",
"references": []
}
},
"tax_compliance_penalties": {
"penalties_excise_taxes_reported": {
"value": "No",
"references": ["3-I"]
},
"unrelated_business_income_disclosure": {
"value": "No",
"references": ["3-Y"]
},
"foreign_bank_account_reporting": {
"value": "No",
"references": ["4-H"]
},
"schedule_o_narrative_explanations": {
"value": "",
"references": []
}
}
},
"metadata": {
"filename": "markdown.md",
"org_id": null,
"duration_ms": 16656,
"credit_usage": 27.2,
"job_id": "nnmr8lcxtykk5ll5wodjtrnn6",
"version": "extract-20250930"
}
}

View File

@@ -1,35 +1,49 @@
import logging
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import uvicorn
import logging
# Import routers
from .routers.files import router as files_router
from .routers.vectors import router as vectors_router
from .routers.chunking import router as chunking_router
from .routers.schemas import router as schemas_router
from .routers.chunking_landingai import router as chunking_landingai_router
from .core.config import settings
# from routers.ai import router as ai_router # futuro con Azure OpenAI
# Import config
from .routers.agent import router as agent_router
from .routers.chunking import router as chunking_router
from .routers.chunking_landingai import router as chunking_landingai_router
from .routers.dataroom import router as dataroom_router
from .routers.extracted_data import router as extracted_data_router
from .routers.files import router as files_router
from .routers.schemas import router as schemas_router
from .routers.vectors import router as vectors_router
# Configurar logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
level=logging.WARNING, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logging.getLogger("app").setLevel(logging.INFO)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_: FastAPI):
logger.info("Iniciando File Manager API...")
logger.info(
f"Conectando a Azure Storage Account: {settings.AZURE_STORAGE_ACCOUNT_NAME}"
)
logger.info(f"Conectando a Qdrant: {settings.QDRANT_URL}")
yield
logger.info("Cerrando File Manager API...")
# Cleanup de recursos si es necesario
app = FastAPI(
title="File Manager API",
description=" DoRa",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc"
redoc_url="/redoc",
)
# Configurar CORS para React frontend
@@ -41,6 +55,7 @@ app.add_middleware(
allow_headers=["*"],
)
# Middleware para logging de requests
@app.middleware("http")
async def log_requests(request, call_next):
@@ -49,19 +64,17 @@ async def log_requests(request, call_next):
logger.info(f"Response: {response.status_code}")
return response
# Manejador global de excepciones
@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc):
logger.error(f"HTTP Exception: {exc.status_code} - {exc.detail}")
return JSONResponse(
status_code=exc.status_code,
content={
"error": True,
"message": exc.detail,
"status_code": exc.status_code
}
content={"error": True, "message": exc.detail, "status_code": exc.status_code},
)
@app.exception_handler(Exception)
async def general_exception_handler(request, exc):
logger.error(f"Unhandled Exception: {str(exc)}")
@@ -70,10 +83,11 @@ async def general_exception_handler(request, exc):
content={
"error": True,
"message": "Error interno del servidor",
"status_code": 500
}
"status_code": 500,
},
)
# Health check endpoint
@app.get("/health")
async def health_check():
@@ -81,9 +95,10 @@ async def health_check():
return {
"status": "healthy",
"message": "File Manager API está funcionando correctamente",
"version": "1.0.0"
"version": "1.0.0",
}
# Root endpoint
@app.get("/")
async def root():
@@ -92,27 +107,16 @@ async def root():
"message": "File Manager API",
"version": "1.0.0",
"docs": "/docs",
"health": "/health"
"health": "/health",
}
# Incluir routers
app.include_router(
files_router,
prefix="/api/v1/files",
tags=["files"]
)
app.include_router(files_router, prefix="/api/v1/files", tags=["files"])
app.include_router(
vectors_router,
prefix="/api/v1",
tags=["vectors"]
)
app.include_router(vectors_router, prefix="/api/v1", tags=["vectors"])
app.include_router(
chunking_router,
prefix="/api/v1",
tags=["chunking"]
)
app.include_router(chunking_router, prefix="/api/v1", tags=["chunking"])
# Schemas router (nuevo)
app.include_router(schemas_router)
@@ -120,6 +124,13 @@ app.include_router(schemas_router)
# Chunking LandingAI router (nuevo)
app.include_router(chunking_landingai_router)
# Extracted data router (nuevo)
app.include_router(extracted_data_router)
app.include_router(dataroom_router, prefix="/api/v1")
app.include_router(agent_router)
# Router para IA
# app.include_router(
# ai_router,
@@ -127,21 +138,6 @@ app.include_router(chunking_landingai_router)
# tags=["ai"]
# )
# Evento de startup
@app.on_event("startup")
async def startup_event():
logger.info("Iniciando File Manager API...")
logger.info(f"Conectando a Azure Storage Account: {settings.AZURE_STORAGE_ACCOUNT_NAME}")
logger.info(f"Conectando a Qdrant: {settings.QDRANT_URL}")
# validaciones de conexión a Azure
# Evento de shutdown
@app.on_event("shutdown")
async def shutdown_event():
logger.info("Cerrando File Manager API...")
# Cleanup de recursos si es necesario
if __name__ == "__main__":
uvicorn.run(
@@ -149,5 +145,5 @@ if __name__ == "__main__":
host=settings.HOST,
port=settings.PORT,
reload=settings.DEBUG,
log_level="info"
)
log_level="info",
)

View File

@@ -0,0 +1,10 @@
from redis_om import HashModel, Migrator
class DataRoom(HashModel):
name: str
collection: str
storage: str
Migrator().run()

View File

@@ -0,0 +1,68 @@
"""
Modelo Redis-OM para almacenar datos extraídos de documentos.
Permite búsqueda rápida de datos estructurados sin necesidad de búsqueda vectorial.
"""
from datetime import datetime
from typing import Optional, Dict, Any
from redis_om import HashModel, Field, Migrator
import json
class ExtractedDocument(HashModel):
"""
Modelo para guardar datos extraídos de documentos en Redis.
Uso:
1. Cuando se procesa un PDF con schema y se extraen datos
2. Los chunks van a Qdrant (para RAG)
3. Los datos extraídos van a Redis (para búsqueda estructurada)
Ventajas:
- Búsqueda rápida por file_name, tema, collection_name
- Acceso directo a datos extraídos sin búsqueda vectorial
- Permite filtros y agregaciones
"""
# Identificadores
file_name: str = Field(index=True)
tema: str = Field(index=True)
collection_name: str = Field(index=True)
# Datos extraídos (JSON serializado)
# Redis-OM HashModel no soporta Dict directamente, usamos str y serializamos
extracted_data_json: str
# Metadata
extraction_timestamp: str # ISO format
class Meta:
database = None # Se configura en runtime
global_key_prefix = "extracted_doc"
model_key_prefix = "doc"
def set_extracted_data(self, data: Dict[str, Any]) -> None:
"""Helper para serializar datos extraídos a JSON"""
self.extracted_data_json = json.dumps(data, ensure_ascii=False, indent=2)
def get_extracted_data(self) -> Dict[str, Any]:
"""Helper para deserializar datos extraídos desde JSON"""
return json.loads(self.extracted_data_json)
@classmethod
def find_by_file(cls, file_name: str):
"""Busca todos los documentos extraídos de un archivo"""
return cls.find(cls.file_name == file_name).all()
@classmethod
def find_by_tema(cls, tema: str):
"""Busca todos los documentos extraídos de un tema"""
return cls.find(cls.tema == tema).all()
@classmethod
def find_by_collection(cls, collection_name: str):
"""Busca todos los documentos en una colección"""
return cls.find(cls.collection_name == collection_name).all()
# Ejecutar migración para crear índices en Redis
Migrator().run()

View File

@@ -58,7 +58,7 @@ class CustomSchema(BaseModel):
schema_id: Optional[str] = Field(None, description="ID único del schema (generado automáticamente si no se provee)")
schema_name: str = Field(..., description="Nombre descriptivo del schema", min_length=1, max_length=100)
description: str = Field(..., description="Descripción de qué extrae este schema", min_length=1, max_length=500)
fields: List[SchemaField] = Field(..., description="Lista de campos a extraer", min_items=1, max_items=50)
fields: List[SchemaField] = Field(..., description="Lista de campos a extraer", min_items=1, max_items=200)
# Metadata
created_at: Optional[str] = Field(None, description="Timestamp de creación ISO")

View File

@@ -0,0 +1,66 @@
import json
from dataclasses import dataclass
from typing import Annotated, Any
from fastapi import APIRouter, Header
from pydantic_ai import Agent, RunContext
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.azure import AzureProvider
from pydantic_ai.ui.vercel_ai import VercelAIAdapter
from starlette.requests import Request
from starlette.responses import Response
from app.agents import form_auditor, web_search
from app.core.config import settings
from app.services.extracted_data_service import get_extracted_data_service
provider = AzureProvider(
azure_endpoint=settings.AZURE_OPENAI_ENDPOINT,
api_version=settings.AZURE_OPENAI_API_VERSION,
api_key=settings.AZURE_OPENAI_API_KEY,
)
model = OpenAIChatModel(model_name="gpt-4o", provider=provider)
@dataclass
class Deps:
extracted_data: dict[str, Any]
agent = Agent(model=model, deps_type=Deps)
router = APIRouter(prefix="/api/v1/agent", tags=["Agent"])
@agent.tool
async def build_audit_report(ctx: RunContext[Deps]):
"""Calls the audit subagent to get a full audit report of the organization"""
data = ctx.deps.extracted_data
with open("data/audit_report.json", "w") as f:
json.dump(data, f)
result = await form_auditor.build_audit_report(data)
return result.model_dump()
@agent.tool_plain
async def search_web_information(query: str, max_results: int = 5):
"""Search the web for up-to-date information using Tavily. Use this when you need current information, news, research, or facts not in your knowledge base."""
result = await web_search.search_web(query=query, max_results=max_results)
return result.model_dump()
@router.post("/chat")
async def chat(request: Request, tema: Annotated[str, Header()]) -> Response:
extracted_data_service = get_extracted_data_service()
data = await extracted_data_service.get_by_tema(tema)
extracted_data = [doc.get_extracted_data() for doc in data]
deps = Deps(extracted_data=extracted_data[0])
return await VercelAIAdapter.dispatch_request(request, agent=agent, deps=deps)

View File

@@ -2,17 +2,19 @@
Router para procesamiento de PDFs con LandingAI.
Soporta dos modos: rápido (solo parse) y extracción (parse + extract con schema).
"""
import logging
import time
from typing import List, Literal, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from typing import Optional, List, Literal
from langchain_core.documents import Document
from pydantic import BaseModel, Field
from ..services.landingai_service import get_landingai_service
from ..services.chunking_service import get_chunking_service
from ..repositories.schema_repository import get_schema_repository
from ..services.chunking_service import get_chunking_service
from ..services.landingai_service import get_landingai_service
from ..services.extracted_data_service import get_extracted_data_service
from ..utils.chunking.token_manager import TokenManager
logger = logging.getLogger(__name__)
@@ -22,6 +24,7 @@ router = APIRouter(prefix="/api/v1/chunking-landingai", tags=["chunking-landinga
class ProcessLandingAIRequest(BaseModel):
"""Request para procesar PDF con LandingAI"""
file_name: str = Field(..., description="Nombre del archivo PDF")
tema: str = Field(..., description="Tema/carpeta del archivo")
collection_name: str = Field(..., description="Colección de Qdrant")
@@ -29,34 +32,33 @@ class ProcessLandingAIRequest(BaseModel):
# Modo de procesamiento
mode: Literal["quick", "extract"] = Field(
default="quick",
description="Modo: 'quick' (solo parse) o 'extract' (parse + datos estructurados)"
description="Modo: 'quick' (solo parse) o 'extract' (parse + datos estructurados)",
)
# Schema (obligatorio si mode='extract')
schema_id: Optional[str] = Field(
None,
description="ID del schema a usar (requerido si mode='extract')"
None, description="ID del schema a usar (requerido si mode='extract')"
)
# Configuración de chunks
include_chunk_types: List[str] = Field(
default=["text", "table"],
description="Tipos de chunks a incluir: text, table, figure, etc."
description="Tipos de chunks a incluir: text, table, figure, etc.",
)
max_tokens_per_chunk: int = Field(
default=1500,
ge=500,
le=3000,
description="Tokens máximos por chunk (flexible para tablas/figuras)"
description="Tokens máximos por chunk (flexible para tablas/figuras)",
)
merge_small_chunks: bool = Field(
default=True,
description="Unir chunks pequeños de la misma página y tipo"
default=True, description="Unir chunks pequeños de la misma página y tipo"
)
class ProcessLandingAIResponse(BaseModel):
"""Response del procesamiento con LandingAI"""
success: bool
mode: str
processing_time_seconds: float
@@ -97,21 +99,22 @@ async def process_with_landingai(request: ProcessLandingAIRequest):
start_time = time.time()
try:
logger.info(f"\n{'='*60}")
logger.info(f"INICIANDO PROCESAMIENTO CON LANDINGAI")
logger.info(f"{'='*60}")
logger.info(f"\n{'=' * 60}")
logger.info("INICIANDO PROCESAMIENTO CON LANDINGAI")
logger.info(f"{'=' * 60}")
logger.info(f"Archivo: {request.file_name}")
logger.info(f"Tema: {request.tema}")
logger.info(f"Modo: {request.mode}")
logger.info(f"Colección: {request.collection_name}")
logger.info(f"Schema ID recibido: '{request.schema_id}' (tipo: {type(request.schema_id).__name__})")
# 1. Validar schema si es modo extract
custom_schema = None
if request.mode == "extract":
if not request.schema_id:
if not request.schema_id or request.schema_id.strip() == "":
raise HTTPException(
status_code=400,
detail="schema_id es requerido cuando mode='extract'"
detail="schema_id es requerido cuando mode='extract'",
)
schema_repo = get_schema_repository()
@@ -119,8 +122,7 @@ async def process_with_landingai(request: ProcessLandingAIRequest):
if not custom_schema:
raise HTTPException(
status_code=404,
detail=f"Schema no encontrado: {request.schema_id}"
status_code=404, detail=f"Schema no encontrado: {request.schema_id}"
)
logger.info(f"Schema seleccionado: {custom_schema.schema_name}")
@@ -131,14 +133,12 @@ async def process_with_landingai(request: ProcessLandingAIRequest):
try:
pdf_bytes = await chunking_service.download_pdf_from_blob(
request.file_name,
request.tema
request.file_name, request.tema
)
except Exception as e:
logger.error(f"Error descargando PDF: {e}")
raise HTTPException(
status_code=404,
detail=f"No se pudo descargar el PDF: {str(e)}"
status_code=404, detail=f"No se pudo descargar el PDF: {str(e)}"
)
# 3. Procesar con LandingAI
@@ -150,13 +150,12 @@ async def process_with_landingai(request: ProcessLandingAIRequest):
pdf_bytes=pdf_bytes,
file_name=request.file_name,
custom_schema=custom_schema,
include_chunk_types=request.include_chunk_types
include_chunk_types=request.include_chunk_types,
)
except Exception as e:
logger.error(f"Error en LandingAI: {e}")
raise HTTPException(
status_code=500,
detail=f"Error procesando con LandingAI: {str(e)}"
status_code=500, detail=f"Error procesando con LandingAI: {str(e)}"
)
documents = result["chunks"]
@@ -164,7 +163,7 @@ async def process_with_landingai(request: ProcessLandingAIRequest):
if not documents:
raise HTTPException(
status_code=400,
detail="No se generaron chunks después del procesamiento"
detail="No se generaron chunks después del procesamiento",
)
# 4. Aplicar control flexible de tokens
@@ -172,7 +171,7 @@ async def process_with_landingai(request: ProcessLandingAIRequest):
documents = _apply_flexible_token_control(
documents,
max_tokens=request.max_tokens_per_chunk,
merge_small=request.merge_small_chunks
merge_small=request.merge_small_chunks,
)
# 5. Generar embeddings
@@ -180,13 +179,16 @@ async def process_with_landingai(request: ProcessLandingAIRequest):
texts = [doc.page_content for doc in documents]
try:
embeddings = await chunking_service.embedding_service.generate_embeddings_batch(texts)
embeddings = (
await chunking_service.embedding_service.generate_embeddings_batch(
texts
)
)
logger.info(f"Embeddings generados: {len(embeddings)} vectores")
except Exception as e:
logger.error(f"Error generando embeddings: {e}")
raise HTTPException(
status_code=500,
detail=f"Error generando embeddings: {str(e)}"
status_code=500, detail=f"Error generando embeddings: {str(e)}"
)
# 6. Preparar chunks para Qdrant con IDs determinísticos
@@ -198,38 +200,54 @@ async def process_with_landingai(request: ProcessLandingAIRequest):
chunk_id = chunking_service._generate_deterministic_id(
file_name=request.file_name,
page=doc.metadata.get("page", 1),
chunk_index=doc.metadata.get("chunk_id", str(idx))
chunk_index=doc.metadata.get("chunk_id", str(idx)),
)
qdrant_chunks.append({
"id": chunk_id,
"vector": embedding,
"payload": {
"page_content": doc.page_content,
"metadata": doc.metadata # Metadata rica de LandingAI
qdrant_chunks.append(
{
"id": chunk_id,
"vector": embedding,
"payload": {
"page_content": doc.page_content,
"metadata": doc.metadata, # Metadata rica de LandingAI
},
}
})
)
# 7. Subir a Qdrant
try:
upload_result = await chunking_service.vector_db.add_chunks(
request.collection_name,
qdrant_chunks
request.collection_name, qdrant_chunks
)
logger.info(f"Subida completada: {upload_result['chunks_added']} chunks")
except Exception as e:
logger.error(f"Error subiendo a Qdrant: {e}")
raise HTTPException(
status_code=500,
detail=f"Error subiendo a Qdrant: {str(e)}"
status_code=500, detail=f"Error subiendo a Qdrant: {str(e)}"
)
# 8. Guardar datos extraídos en Redis (si existe extracted_data)
if result.get("extracted_data") and result["extracted_data"].get("extraction"):
try:
logger.info("\n[6/6] Guardando datos extraídos en Redis...")
extracted_data_service = get_extracted_data_service()
await extracted_data_service.save_extracted_data(
file_name=request.file_name,
tema=request.tema,
collection_name=request.collection_name,
extracted_data=result["extracted_data"]["extraction"]
)
except Exception as e:
# No fallar si Redis falla, solo logear
logger.warning(f"⚠️ No se pudieron guardar datos en Redis (no crítico): {e}")
# Tiempo total
processing_time = time.time() - start_time
logger.info(f"\n{'='*60}")
logger.info(f"\n{'=' * 60}")
logger.info(f"PROCESAMIENTO COMPLETADO")
logger.info(f"{'='*60}")
logger.info(f"{'=' * 60}")
logger.info(f"Tiempo: {processing_time:.2f}s")
logger.info(f"Chunks procesados: {len(documents)}")
logger.info(f"Chunks subidos: {upload_result['chunks_added']}")
@@ -245,23 +263,18 @@ async def process_with_landingai(request: ProcessLandingAIRequest):
schema_used=custom_schema.schema_id if custom_schema else None,
extracted_data=result.get("extracted_data"),
parse_metadata=result["parse_metadata"],
message=f"PDF procesado exitosamente en modo {request.mode}"
message=f"PDF procesado exitosamente en modo {request.mode}",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error inesperado en procesamiento: {e}")
raise HTTPException(
status_code=500,
detail=f"Error inesperado: {str(e)}"
)
raise HTTPException(status_code=500, detail=f"Error inesperado: {str(e)}")
def _apply_flexible_token_control(
documents: List[Document],
max_tokens: int,
merge_small: bool
documents: List[Document], max_tokens: int, merge_small: bool
) -> List[Document]:
"""
Aplica control flexible de tokens (Opción C del diseño).
@@ -306,14 +319,10 @@ def _apply_flexible_token_control(
else:
# Intentar merge si es pequeño
if (
merge_small and
tokens < max_tokens * 0.5 and
i < len(documents) - 1
):
if merge_small and tokens < max_tokens * 0.5 and i < len(documents) - 1:
next_doc = documents[i + 1]
if _can_merge(doc, next_doc, max_tokens, token_manager):
logger.debug(f"Merging chunks {i} y {i+1}")
logger.debug(f"Merging chunks {i} y {i + 1}")
doc = _merge_documents(doc, next_doc)
i += 1 # Skip next
@@ -326,9 +335,7 @@ def _apply_flexible_token_control(
def _split_large_chunk(
doc: Document,
max_tokens: int,
token_manager: TokenManager
doc: Document, max_tokens: int, token_manager: TokenManager
) -> List[Document]:
"""Divide un chunk grande en sub-chunks"""
content = doc.page_content
@@ -343,8 +350,7 @@ def _split_large_chunk(
# Guardar chunk actual
sub_content = " ".join(current_chunk)
sub_doc = Document(
page_content=sub_content,
metadata={**doc.metadata, "is_split": True}
page_content=sub_content, metadata={**doc.metadata, "is_split": True}
)
sub_chunks.append(sub_doc)
current_chunk = [word]
@@ -357,8 +363,7 @@ def _split_large_chunk(
if current_chunk:
sub_content = " ".join(current_chunk)
sub_doc = Document(
page_content=sub_content,
metadata={**doc.metadata, "is_split": True}
page_content=sub_content, metadata={**doc.metadata, "is_split": True}
)
sub_chunks.append(sub_doc)
@@ -366,10 +371,7 @@ def _split_large_chunk(
def _can_merge(
doc1: Document,
doc2: Document,
max_tokens: int,
token_manager: TokenManager
doc1: Document, doc2: Document, max_tokens: int, token_manager: TokenManager
) -> bool:
"""Verifica si dos docs se pueden mergear"""
# Misma página
@@ -391,6 +393,5 @@ def _merge_documents(doc1: Document, doc2: Document) -> Document:
"""Mergea dos documentos"""
merged_content = f"{doc1.page_content}\n\n{doc2.page_content}"
return Document(
page_content=merged_content,
metadata={**doc1.metadata, "is_merged": True}
page_content=merged_content, metadata={**doc1.metadata, "is_merged": True}
)

View File

@@ -0,0 +1,285 @@
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..models.dataroom import DataRoom
from ..models.vector_models import CollectionCreateRequest
from ..services.azure_service import azure_service
from ..services.vector_service import vector_service
logger = logging.getLogger(__name__)
class DataroomCreate(BaseModel):
name: str
@property
def collection(self) -> str:
return self.name.lower().replace(" ", "_")
@property
def storage(self) -> str:
return self.name.lower().replace(" ", "_")
class DataroomInfo(BaseModel):
name: str
collection: str
storage: str
file_count: int
total_size_bytes: int
total_size_mb: float
collection_exists: bool
vector_count: Optional[int]
collection_info: Optional[dict]
file_types: dict
recent_files: list
router = APIRouter(prefix="/dataroom", tags=["Dataroom"])
@router.get("/{dataroom_name}/info")
async def dataroom_info(dataroom_name: str) -> DataroomInfo:
"""
Obtener información detallada de un dataroom específico
"""
try:
# Find the dataroom in Redis
datarooms = DataRoom.find().all()
dataroom = None
for room in datarooms:
if room.name == dataroom_name:
dataroom = room
break
if not dataroom:
raise HTTPException(
status_code=404, detail=f"Dataroom '{dataroom_name}' not found"
)
# Get file information from Azure Storage
try:
files_data = await azure_service.list_files(dataroom_name)
except Exception as e:
logger.warning(f"Could not fetch files for dataroom '{dataroom_name}': {e}")
files_data = []
# Calculate file metrics
file_count = len(files_data)
total_size_bytes = sum(file_data.get("size", 0) for file_data in files_data)
total_size_mb = (
round(total_size_bytes / (1024 * 1024), 2) if total_size_bytes > 0 else 0.0
)
# Analyze file types
file_types = {}
recent_files = []
for file_data in files_data:
# Count file types by extension
filename = file_data.get("name", "")
if "." in filename:
ext = filename.split(".")[-1].lower()
file_types[ext] = file_types.get(ext, 0) + 1
# Collect recent files (up to 5)
if len(recent_files) < 5:
recent_files.append(
{
"name": filename,
"size_mb": round(file_data.get("size", 0) / (1024 * 1024), 2),
"last_modified": file_data.get("last_modified"),
}
)
# Sort recent files by last modified (newest first)
recent_files.sort(key=lambda x: x.get("last_modified", ""), reverse=True)
# Get vector collection information
collection_exists = False
vector_count = None
collection_info = None
try:
collection_exists_response = await vector_service.check_collection_exists(
dataroom_name
)
collection_exists = collection_exists_response.exists
if collection_exists:
collection_info_response = await vector_service.get_collection_info(
dataroom_name
)
if collection_info_response:
collection_info = {
"vectors_count": collection_info_response.vectors_count,
"indexed_vectors_count": collection_info_response.vectors_count,
"points_count": collection_info_response.vectors_count,
"segments_count": collection_info_response.vectors_count,
"status": collection_info_response.status,
}
vector_count = collection_info_response.vectors_count
except Exception as e:
logger.warning(
f"Could not fetch collection info for '{dataroom_name}': {e}"
)
logger.info(
f"Retrieved info for dataroom '{dataroom_name}': {file_count} files, {total_size_mb}MB"
)
return DataroomInfo(
name=dataroom.name,
collection=dataroom.collection,
storage=dataroom.storage,
file_count=file_count,
total_size_bytes=total_size_bytes,
total_size_mb=total_size_mb,
collection_exists=collection_exists,
vector_count=vector_count,
collection_info=collection_info,
file_types=file_types,
recent_files=recent_files,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting dataroom info for '{dataroom_name}': {e}")
raise HTTPException(
status_code=500, detail=f"Error getting dataroom info: {str(e)}"
)
@router.get("/")
async def list_datarooms():
"""
Listar todos los temas disponibles
"""
try:
# Get all DataRoom instances
datarooms: list[DataRoom] = DataRoom.find().all()
logger.info(f"Found {len(datarooms)} datarooms in Redis")
# Convert to list of dictionaries
dataroom_list = [
{"name": room.name, "collection": room.collection, "storage": room.storage}
for room in datarooms
]
logger.info(f"Returning dataroom list: {dataroom_list}")
return {"datarooms": dataroom_list}
except Exception as e:
logger.error(f"Error listing datarooms: {e}")
raise HTTPException(
status_code=500, detail=f"Error listing datarooms: {str(e)}"
)
@router.post("/")
async def create_dataroom(dataroom: DataroomCreate):
"""
Crear un nuevo dataroom y su colección vectorial asociada
"""
try:
# Create new DataRoom instance
new_dataroom = DataRoom(
name=dataroom.name, collection=dataroom.collection, storage=dataroom.storage
)
# Save to Redis
new_dataroom.save()
# Create the vector collection for this dataroom
try:
# First check if collection already exists
collection_exists_response = await vector_service.check_collection_exists(
dataroom.name
)
if not collection_exists_response.exists:
# Only create if it doesn't exist
collection_request = CollectionCreateRequest(
collection_name=dataroom.name,
vector_size=3072, # Default vector size for embeddings
distance="Cosine", # Default distance metric
)
await vector_service.create_collection(collection_request)
logger.info(f"Collection '{dataroom.name}' created successfully")
else:
logger.info(
f"Collection '{dataroom.name}' already exists, skipping creation"
)
except Exception as e:
# Log the error but don't fail the dataroom creation
logger.warning(
f"Could not create collection for dataroom '{dataroom.name}': {e}"
)
return {
"message": "Dataroom created successfully",
"dataroom": {
"name": new_dataroom.name,
"collection": new_dataroom.collection,
"storage": new_dataroom.storage,
},
}
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Error creating dataroom: {str(e)}"
)
@router.delete("/{dataroom_name}")
async def delete_dataroom(dataroom_name: str):
"""
Eliminar un dataroom y su colección vectorial asociada
"""
try:
# First check if dataroom exists
existing_datarooms = DataRoom.find().all()
dataroom_exists = any(room.name == dataroom_name for room in existing_datarooms)
if not dataroom_exists:
raise HTTPException(
status_code=404, detail=f"Dataroom '{dataroom_name}' not found"
)
# Delete the vector collection first
try:
collection_exists = await vector_service.check_collection_exists(
dataroom_name
)
if collection_exists.exists:
await vector_service.delete_collection(dataroom_name)
logger.info(
f"Collection '{dataroom_name}' deleted from vector database"
)
except Exception as e:
logger.warning(
f"Could not delete collection '{dataroom_name}' from vector database: {e}"
)
# Continue with dataroom deletion even if collection deletion fails
# Delete the dataroom from Redis
for room in existing_datarooms:
if room.name == dataroom_name:
# Delete using the primary key
DataRoom.delete(room.pk)
logger.info(f"Dataroom '{dataroom_name}' deleted from Redis")
break
return {
"message": "Dataroom deleted successfully",
"dataroom_name": dataroom_name,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting dataroom '{dataroom_name}': {e}")
raise HTTPException(
status_code=500, detail=f"Error deleting dataroom: {str(e)}"
)

View File

@@ -0,0 +1,141 @@
"""
Router para consultar datos extraídos almacenados en Redis.
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from ..services.extracted_data_service import get_extracted_data_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/extracted-data", tags=["extracted-data"])
class ExtractedDataResponse(BaseModel):
"""Response con datos extraídos de un documento"""
pk: str
file_name: str
tema: str
collection_name: str
extracted_data: dict
extraction_timestamp: str
class ExtractedDataListResponse(BaseModel):
"""Response con lista de datos extraídos"""
total: int
documents: List[ExtractedDataResponse]
@router.get("/by-file/{file_name}", response_model=ExtractedDataListResponse)
async def get_by_file(file_name: str):
"""
Obtiene todos los datos extraídos de un archivo específico.
Args:
file_name: Nombre del archivo
Returns:
Lista de documentos con datos extraídos
"""
try:
service = get_extracted_data_service()
docs = await service.get_by_file(file_name)
documents = [
ExtractedDataResponse(
pk=doc.pk,
file_name=doc.file_name,
tema=doc.tema,
collection_name=doc.collection_name,
extracted_data=doc.get_extracted_data(),
extraction_timestamp=doc.extraction_timestamp
)
for doc in docs
]
return ExtractedDataListResponse(
total=len(documents),
documents=documents
)
except Exception as e:
logger.error(f"Error obteniendo datos extraídos por archivo: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/by-tema/{tema}", response_model=ExtractedDataListResponse)
async def get_by_tema(tema: str):
"""
Obtiene todos los datos extraídos de un tema específico.
Args:
tema: Nombre del tema
Returns:
Lista de documentos con datos extraídos
"""
try:
service = get_extracted_data_service()
docs = await service.get_by_tema(tema)
documents = [
ExtractedDataResponse(
pk=doc.pk,
file_name=doc.file_name,
tema=doc.tema,
collection_name=doc.collection_name,
extracted_data=doc.get_extracted_data(),
extraction_timestamp=doc.extraction_timestamp
)
for doc in docs
]
return ExtractedDataListResponse(
total=len(documents),
documents=documents
)
except Exception as e:
logger.error(f"Error obteniendo datos extraídos por tema: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/by-collection/{collection_name}", response_model=ExtractedDataListResponse)
async def get_by_collection(collection_name: str):
"""
Obtiene todos los datos extraídos de una colección específica.
Args:
collection_name: Nombre de la colección
Returns:
Lista de documentos con datos extraídos
"""
try:
service = get_extracted_data_service()
docs = await service.get_by_collection(collection_name)
documents = [
ExtractedDataResponse(
pk=doc.pk,
file_name=doc.file_name,
tema=doc.tema,
collection_name=doc.collection_name,
extracted_data=doc.get_extracted_data(),
extraction_timestamp=doc.extraction_timestamp
)
for doc in docs
]
return ExtractedDataListResponse(
total=len(documents),
documents=documents
)
except Exception as e:
logger.error(f"Error obteniendo datos extraídos por colección: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,18 +1,28 @@
from fastapi import APIRouter, UploadFile, File, HTTPException, Query, Form
from fastapi.responses import StreamingResponse, Response
from typing import Optional, List
import io
import logging
import os
import zipfile
import io
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import Response, StreamingResponse
from ..models.dataroom import DataRoom
from ..models.file_models import (
FileUploadRequest, FileUploadResponse, FileInfo, FileListResponse,
FileDeleteResponse, FileBatchDeleteRequest,
FileConflictResponse, FileBatchDeleteResponse,
FileBatchDownloadRequest, TemasListResponse,
FileUploadCheckRequest, FileUploadConfirmRequest, ErrorResponse
ErrorResponse,
FileBatchDeleteRequest,
FileBatchDeleteResponse,
FileBatchDownloadRequest,
FileConflictResponse,
FileDeleteResponse,
FileInfo,
FileListResponse,
FileUploadCheckRequest,
FileUploadConfirmRequest,
FileUploadRequest,
FileUploadResponse,
TemasListResponse,
)
from ..services.azure_service import azure_service
from ..services.file_service import file_service
@@ -31,27 +41,27 @@ async def check_file_before_upload(request: FileUploadCheckRequest):
is_valid, error_msg = file_service.validate_filename(request.filename)
if not is_valid:
raise HTTPException(status_code=400, detail=error_msg)
# Validar extensión
is_valid, error_msg = file_service.validate_file_extension(request.filename)
if not is_valid:
raise HTTPException(status_code=400, detail=error_msg)
# Limpiar tema
clean_tema = file_service.clean_tema_name(request.tema or "")
# Verificar si existe conflicto
has_conflict, suggested_name = await file_service.handle_file_conflict(
request.filename, clean_tema
)
if has_conflict:
return FileConflictResponse(
conflict=True,
message=f"El archivo '{request.filename}' ya existe en el tema '{clean_tema or 'general'}'",
existing_file=request.filename,
suggested_name=suggested_name,
tema=clean_tema
tema=clean_tema,
)
else:
# No hay conflicto, se puede subir directamente
@@ -60,14 +70,16 @@ async def check_file_before_upload(request: FileUploadCheckRequest):
message="Archivo disponible para subir",
existing_file=request.filename,
suggested_name=request.filename,
tema=clean_tema
tema=clean_tema,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error verificando archivo '{request.filename}': {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.post("/upload/confirm", response_model=FileUploadResponse)
@@ -75,7 +87,7 @@ async def upload_file_with_confirmation(
file: UploadFile = File(...),
action: str = Form(...),
tema: Optional[str] = Form(None),
new_filename: Optional[str] = Form(None)
new_filename: Optional[str] = Form(None),
):
"""
Subir archivo con confirmación de acción para conflictos
@@ -84,61 +96,54 @@ async def upload_file_with_confirmation(
# Validar archivo
if not file.filename:
raise HTTPException(status_code=400, detail="Nombre de archivo requerido")
# Crear request de confirmación para validaciones
confirm_request = FileUploadConfirmRequest(
filename=file.filename,
tema=tema,
action=action,
new_filename=new_filename
filename=file.filename, tema=tema, action=action, new_filename=new_filename
)
# Si la acción es cancelar, no hacer nada
if confirm_request.action == "cancel":
return FileUploadResponse(
success=False,
message="Subida cancelada por el usuario",
file=None
success=False, message="Subida cancelada por el usuario", file=None
)
# Determinar el nombre final del archivo
final_filename = file.filename
if confirm_request.action == "rename" and confirm_request.new_filename:
final_filename = confirm_request.new_filename
# Validar extensión del archivo final
is_valid, error_msg = file_service.validate_file_extension(final_filename)
if not is_valid:
raise HTTPException(status_code=400, detail=error_msg)
# Leer contenido del archivo
file_content = await file.read()
# Validar tamaño del archivo
is_valid, error_msg = file_service.validate_file_size(len(file_content))
if not is_valid:
raise HTTPException(status_code=400, detail=error_msg)
# Limpiar tema
clean_tema = file_service.clean_tema_name(confirm_request.tema or "")
# Si es sobrescribir, verificar que el archivo original exista
if confirm_request.action == "overwrite":
exists = await file_service.check_file_exists(file.filename, clean_tema)
if not exists:
raise HTTPException(
status_code=404,
detail=f"Archivo '{file.filename}' no existe para sobrescribir"
status_code=404,
detail=f"Archivo '{file.filename}' no existe para sobrescribir",
)
# Subir archivo a Azure
file_stream = io.BytesIO(file_content)
uploaded_file_info = await azure_service.upload_file(
file_data=file_stream,
blob_name=final_filename,
tema=clean_tema
file_data=file_stream, blob_name=final_filename, tema=clean_tema
)
# Crear objeto FileInfo
file_info = FileInfo(
name=uploaded_file_info["name"],
@@ -146,75 +151,95 @@ async def upload_file_with_confirmation(
tema=uploaded_file_info["tema"],
size=uploaded_file_info["size"],
last_modified=uploaded_file_info["last_modified"],
url=uploaded_file_info["url"]
url=uploaded_file_info["url"],
)
action_msg = {
"overwrite": "sobrescrito",
"rename": f"renombrado a '{final_filename}'"
"rename": f"renombrado a '{final_filename}'",
}
logger.info(f"Archivo '{file.filename}' {action_msg.get(confirm_request.action, 'subido')} exitosamente")
logger.info(
f"Archivo '{file.filename}' {action_msg.get(confirm_request.action, 'subido')} exitosamente"
)
return FileUploadResponse(
success=True,
message=f"Archivo {action_msg.get(confirm_request.action, 'subido')} exitosamente",
file=file_info
file=file_info,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error en subida confirmada: {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.post("/upload", response_model=FileUploadResponse)
async def upload_file(
file: UploadFile = File(...),
tema: Optional[str] = Form(None)
):
async def upload_file(file: UploadFile = File(...), tema: Optional[str] = Form(None)):
"""
Subir un archivo al almacenamiento
"""
try:
# Validar que el dataroom existe si se proporciona un tema
if tema:
existing_datarooms = DataRoom.find().all()
dataroom_exists = any(room.name == tema for room in existing_datarooms)
if not dataroom_exists:
raise HTTPException(
status_code=400,
detail=f"El dataroom '{tema}' no existe. Créalo primero antes de subir archivos.",
)
# Validar archivo
if not file.filename:
raise HTTPException(status_code=400, detail="Nombre de archivo requerido")
# Validar extensión del archivo
file_extension = os.path.splitext(file.filename)[1].lower()
allowed_extensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv']
allowed_extensions = [
".pdf",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".txt",
".csv",
]
if file_extension not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"Tipo de archivo no permitido. Extensiones permitidas: {', '.join(allowed_extensions)}"
status_code=400,
detail=f"Tipo de archivo no permitido. Extensiones permitidas: {', '.join(allowed_extensions)}",
)
# Leer contenido del archivo
file_content = await file.read()
# Validar tamaño del archivo (100MB máximo)
max_size = 100 * 1024 * 1024 # 100MB
if len(file_content) > max_size:
raise HTTPException(
status_code=400,
detail=f"Archivo demasiado grande. Tamaño máximo permitido: 100MB"
detail=f"Archivo demasiado grande. Tamaño máximo permitido: 100MB",
)
# Procesar tema
upload_request = FileUploadRequest(tema=tema)
processed_tema = upload_request.tema or ""
# Subir archivo a Azure
file_stream = io.BytesIO(file_content)
uploaded_file_info = await azure_service.upload_file(
file_data=file_stream,
blob_name=file.filename,
tema=processed_tema
file_data=file_stream, blob_name=file.filename, tema=processed_tema
)
# Crear objeto FileInfo
file_info = FileInfo(
name=uploaded_file_info["name"],
@@ -222,22 +247,24 @@ async def upload_file(
tema=uploaded_file_info["tema"],
size=uploaded_file_info["size"],
last_modified=uploaded_file_info["last_modified"],
url=uploaded_file_info["url"]
url=uploaded_file_info["url"],
)
logger.info(f"Archivo '{file.filename}' subido exitosamente al tema '{processed_tema}'")
logger.info(
f"Archivo '{file.filename}' subido exitosamente al tema '{processed_tema}'"
)
return FileUploadResponse(
success=True,
message="Archivo subido exitosamente",
file=file_info
success=True, message="Archivo subido exitosamente", file=file_info
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error subiendo archivo: {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.get("/", response_model=FileListResponse)
@@ -248,7 +275,7 @@ async def list_files(tema: Optional[str] = Query(None, description="Filtrar por
try:
# Obtener archivos de Azure
files_data = await azure_service.list_files(tema=tema or "")
# Convertir a objetos FileInfo
files_info = []
for file_data in files_data:
@@ -258,21 +285,22 @@ async def list_files(tema: Optional[str] = Query(None, description="Filtrar por
tema=file_data["tema"],
size=file_data["size"],
last_modified=file_data["last_modified"],
content_type=file_data.get("content_type")
content_type=file_data.get("content_type"),
)
files_info.append(file_info)
logger.info(f"Listados {len(files_info)} archivos" + (f" del tema '{tema}'" if tema else ""))
return FileListResponse(
files=files_info,
total=len(files_info),
tema=tema
logger.info(
f"Listados {len(files_info)} archivos"
+ (f" del tema '{tema}'" if tema else "")
)
return FileListResponse(files=files_info, total=len(files_info), tema=tema)
except Exception as e:
logger.error(f"Error listando archivos: {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.get("/temas", response_model=TemasListResponse)
@@ -283,31 +311,30 @@ async def list_temas():
try:
# Obtener todos los archivos
files_data = await azure_service.list_files()
# Extraer temas únicos
temas = set()
for file_data in files_data:
if file_data["tema"]:
temas.add(file_data["tema"])
temas_list = sorted(list(temas))
logger.info(f"Encontrados {len(temas_list)} temas")
return TemasListResponse(
temas=temas_list,
total=len(temas_list)
)
return TemasListResponse(temas=temas_list, total=len(temas_list))
except Exception as e:
logger.error(f"Error listando temas: {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.get("/{filename}/download")
async def download_file(
filename: str,
tema: Optional[str] = Query(None, description="Tema donde está el archivo")
tema: Optional[str] = Query(None, description="Tema donde está el archivo"),
):
"""
Descargar un archivo individual
@@ -315,64 +342,71 @@ async def download_file(
try:
# Descargar archivo de Azure
file_content = await azure_service.download_file(
blob_name=filename,
tema=tema or ""
blob_name=filename, tema=tema or ""
)
# Obtener información del archivo para content-type
file_info = await azure_service.get_file_info(
blob_name=filename,
tema=tema or ""
blob_name=filename, tema=tema or ""
)
# Determinar content-type
content_type = file_info.get("content_type", "application/octet-stream")
logger.info(f"Descargando archivo '{filename}'" + (f" del tema '{tema}'" if tema else ""))
logger.info(
f"Descargando archivo '{filename}'"
+ (f" del tema '{tema}'" if tema else "")
)
return Response(
content=file_content,
media_type=content_type,
headers={
"Content-Disposition": f"attachment; filename={filename}"
}
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado")
raise HTTPException(
status_code=404, detail=f"Archivo '{filename}' no encontrado"
)
except Exception as e:
logger.error(f"Error descargando archivo '{filename}': {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.delete("/{filename}", response_model=FileDeleteResponse)
async def delete_file(
filename: str,
tema: Optional[str] = Query(None, description="Tema donde está el archivo")
tema: Optional[str] = Query(None, description="Tema donde está el archivo"),
):
"""
Eliminar un archivo
"""
try:
# Eliminar archivo de Azure
await azure_service.delete_file(
blob_name=filename,
tema=tema or ""
await azure_service.delete_file(blob_name=filename, tema=tema or "")
logger.info(
f"Archivo '{filename}' eliminado exitosamente"
+ (f" del tema '{tema}'" if tema else "")
)
logger.info(f"Archivo '{filename}' eliminado exitosamente" + (f" del tema '{tema}'" if tema else ""))
return FileDeleteResponse(
success=True,
message="Archivo eliminado exitosamente",
deleted_file=filename
deleted_file=filename,
)
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado")
raise HTTPException(
status_code=404, detail=f"Archivo '{filename}' no encontrado"
)
except Exception as e:
logger.error(f"Error eliminando archivo '{filename}': {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.post("/delete-batch", response_model=FileBatchDeleteResponse)
@@ -383,34 +417,35 @@ async def delete_batch_files(request: FileBatchDeleteRequest):
try:
deleted_files = []
failed_files = []
for filename in request.files:
try:
await azure_service.delete_file(
blob_name=filename,
tema=request.tema or ""
blob_name=filename, tema=request.tema or ""
)
deleted_files.append(filename)
logger.info(f"Archivo '{filename}' eliminado exitosamente")
except Exception as e:
failed_files.append(filename)
logger.error(f"Error eliminando archivo '{filename}': {e}")
success = len(failed_files) == 0
message = f"Eliminados {len(deleted_files)} archivos exitosamente"
if failed_files:
message += f", {len(failed_files)} archivos fallaron"
return FileBatchDeleteResponse(
success=success,
message=message,
deleted_files=deleted_files,
failed_files=failed_files
failed_files=failed_files,
)
except Exception as e:
logger.error(f"Error en eliminación batch: {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.post("/download-batch")
@@ -421,44 +456,43 @@ async def download_batch_files(request: FileBatchDownloadRequest):
try:
# Crear ZIP en memoria
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for filename in request.files:
try:
# Descargar archivo de Azure
file_content = await azure_service.download_file(
blob_name=filename,
tema=request.tema or ""
blob_name=filename, tema=request.tema or ""
)
# Agregar al ZIP
zip_file.writestr(filename, file_content)
logger.info(f"Archivo '{filename}' agregado al ZIP")
except Exception as e:
logger.error(f"Error agregando '{filename}' al ZIP: {e}")
# Continuar con otros archivos
continue
zip_buffer.seek(0)
# Generar nombre del ZIP
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
zip_filename = f"{request.zip_name}_{timestamp}.zip"
logger.info(f"ZIP creado exitosamente: {zip_filename}")
return StreamingResponse(
io.BytesIO(zip_buffer.read()),
media_type="application/zip",
headers={
"Content-Disposition": f"attachment; filename={zip_filename}"
}
headers={"Content-Disposition": f"attachment; filename={zip_filename}"},
)
except Exception as e:
logger.error(f"Error creando ZIP: {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.get("/tema/{tema}/download-all")
@@ -469,54 +503,58 @@ async def download_tema_completo(tema: str):
try:
# Obtener todos los archivos del tema
files_data = await azure_service.list_files(tema=tema)
if not files_data:
raise HTTPException(status_code=404, detail=f"No se encontraron archivos en el tema '{tema}'")
raise HTTPException(
status_code=404,
detail=f"No se encontraron archivos en el tema '{tema}'",
)
# Crear ZIP en memoria
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for file_data in files_data:
try:
filename = file_data["name"]
# Descargar archivo de Azure
file_content = await azure_service.download_file(
blob_name=filename,
tema=tema
blob_name=filename, tema=tema
)
# Agregar al ZIP
zip_file.writestr(filename, file_content)
logger.info(f"Archivo '{filename}' agregado al ZIP del tema '{tema}'")
logger.info(
f"Archivo '{filename}' agregado al ZIP del tema '{tema}'"
)
except Exception as e:
logger.error(f"Error agregando '{filename}' al ZIP: {e}")
# Continuar con otros archivos
continue
zip_buffer.seek(0)
# Generar nombre del ZIP
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
zip_filename = f"{tema}_{timestamp}.zip"
logger.info(f"ZIP del tema '{tema}' creado exitosamente: {zip_filename}")
return StreamingResponse(
io.BytesIO(zip_buffer.read()),
media_type="application/zip",
headers={
"Content-Disposition": f"attachment; filename={zip_filename}"
}
headers={"Content-Disposition": f"attachment; filename={zip_filename}"},
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creando ZIP del tema '{tema}': {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.delete("/tema/{tema}/delete-all", response_model=FileBatchDeleteResponse)
@@ -527,51 +565,59 @@ async def delete_tema_completo(tema: str):
try:
# Obtener todos los archivos del tema
files_data = await azure_service.list_files(tema=tema)
if not files_data:
raise HTTPException(status_code=404, detail=f"No se encontraron archivos en el tema '{tema}'")
raise HTTPException(
status_code=404,
detail=f"No se encontraron archivos en el tema '{tema}'",
)
deleted_files = []
failed_files = []
for file_data in files_data:
filename = file_data["name"]
try:
await azure_service.delete_file(
blob_name=filename,
tema=tema
)
await azure_service.delete_file(blob_name=filename, tema=tema)
deleted_files.append(filename)
logger.info(f"Archivo '{filename}' eliminado del tema '{tema}'")
except Exception as e:
failed_files.append(filename)
logger.error(f"Error eliminando archivo '{filename}' del tema '{tema}': {e}")
logger.error(
f"Error eliminando archivo '{filename}' del tema '{tema}': {e}"
)
success = len(failed_files) == 0
message = f"Tema '{tema}': eliminados {len(deleted_files)} archivos exitosamente"
message = (
f"Tema '{tema}': eliminados {len(deleted_files)} archivos exitosamente"
)
if failed_files:
message += f", {len(failed_files)} archivos fallaron"
logger.info(f"Eliminación completa del tema '{tema}': {len(deleted_files)} exitosos, {len(failed_files)} fallidos")
logger.info(
f"Eliminación completa del tema '{tema}': {len(deleted_files)} exitosos, {len(failed_files)} fallidos"
)
return FileBatchDeleteResponse(
success=success,
message=message,
deleted_files=deleted_files,
failed_files=failed_files
failed_files=failed_files,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error eliminando tema '{tema}': {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.get("/{filename}/info", response_model=FileInfo)
async def get_file_info(
filename: str,
tema: Optional[str] = Query(None, description="Tema donde está el archivo")
tema: Optional[str] = Query(None, description="Tema donde está el archivo"),
):
"""
Obtener información detallada de un archivo
@@ -579,8 +625,7 @@ async def get_file_info(
try:
# Obtener información de Azure
file_data = await azure_service.get_file_info(
blob_name=filename,
tema=tema or ""
blob_name=filename, tema=tema or ""
)
# Convertir a objeto FileInfo
@@ -591,24 +636,30 @@ async def get_file_info(
size=file_data["size"],
last_modified=file_data["last_modified"],
content_type=file_data.get("content_type"),
url=file_data.get("url")
url=file_data.get("url"),
)
logger.info(f"Información obtenida para archivo '{filename}'")
return file_info
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado")
raise HTTPException(
status_code=404, detail=f"Archivo '{filename}' no encontrado"
)
except Exception as e:
logger.error(f"Error obteniendo info del archivo '{filename}': {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)
@router.get("/{filename}/preview-url")
async def get_file_preview_url(
filename: str,
tema: Optional[str] = Query(None, description="Tema donde está el archivo"),
expiry_hours: int = Query(1, description="Horas de validez de la URL (máximo 24)", ge=1, le=24)
expiry_hours: int = Query(
1, description="Horas de validez de la URL (máximo 24)", ge=1, le=24
),
):
"""
Generar una URL temporal (SAS) para vista previa de archivos
@@ -633,23 +684,28 @@ async def get_file_preview_url(
try:
# Generar SAS URL usando el servicio de Azure
sas_url = await azure_service.generate_sas_url(
blob_name=filename,
tema=tema or "",
expiry_hours=expiry_hours
blob_name=filename, tema=tema or "", expiry_hours=expiry_hours
)
logger.info(f"SAS URL generada para preview de '{filename}'" + (f" del tema '{tema}'" if tema else ""))
logger.info(
f"SAS URL generada para preview de '{filename}'"
+ (f" del tema '{tema}'" if tema else "")
)
return {
"success": True,
"filename": filename,
"url": sas_url,
"expiry_hours": expiry_hours,
"message": f"URL temporal generada (válida por {expiry_hours} hora{'s' if expiry_hours > 1 else ''})"
"message": f"URL temporal generada (válida por {expiry_hours} hora{'s' if expiry_hours > 1 else ''})",
}
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado")
raise HTTPException(
status_code=404, detail=f"Archivo '{filename}' no encontrado"
)
except Exception as e:
logger.error(f"Error generando preview URL para '{filename}': {e}")
raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error interno del servidor: {str(e)}"
)

View File

@@ -1,9 +1,17 @@
from azure.storage.blob import BlobServiceClient, BlobClient, ContainerClient, generate_blob_sas, BlobSasPermissions
from azure.core.exceptions import ResourceNotFoundError, ResourceExistsError
from typing import List, Optional, BinaryIO
import logging
from datetime import datetime, timezone, timedelta
import os
from datetime import datetime, timedelta, timezone
from typing import BinaryIO, List, Optional
from azure.core.exceptions import ResourceExistsError, ResourceNotFoundError
from azure.storage.blob import (
BlobClient,
BlobSasPermissions,
BlobServiceClient,
ContainerClient,
generate_blob_sas,
)
from ..core.config import settings
logger = logging.getLogger(__name__)
@@ -13,7 +21,7 @@ class AzureBlobService:
"""
Servicio para interactuar con Azure Blob Storage
"""
def __init__(self):
"""Inicializar el cliente de Azure Blob Storage"""
try:
@@ -21,7 +29,9 @@ class AzureBlobService:
settings.AZURE_STORAGE_CONNECTION_STRING
)
self.container_name = settings.AZURE_CONTAINER_NAME
logger.info(f"Cliente de Azure Blob Storage inicializado para container: {self.container_name}")
logger.info(
f"Cliente de Azure Blob Storage inicializado para container: {self.container_name}"
)
# Configurar CORS automáticamente al inicializar
self._configure_cors()
@@ -45,7 +55,7 @@ class AzureBlobService:
allowed_methods=["GET", "HEAD", "OPTIONS"],
allowed_headers=["*"],
exposed_headers=["*"],
max_age_in_seconds=3600
max_age_in_seconds=3600,
)
# Aplicar la configuración CORS
@@ -55,15 +65,19 @@ class AzureBlobService:
except Exception as e:
# No fallar si CORS no se puede configurar (puede que ya esté configurado)
logger.warning(f"No se pudo configurar CORS automáticamente: {e}")
logger.warning("Asegúrate de configurar CORS manualmente en Azure Portal si es necesario")
logger.warning(
"Asegúrate de configurar CORS manualmente en Azure Portal si es necesario"
)
async def create_container_if_not_exists(self) -> bool:
"""
Crear el container si no existe
Returns: True si se creó, False si ya existía
"""
try:
container_client = self.blob_service_client.get_container_client(self.container_name)
container_client = self.blob_service_client.get_container_client(
self.container_name
)
container_client.create_container()
logger.info(f"Container '{self.container_name}' creado exitosamente")
return True
@@ -73,217 +87,249 @@ class AzureBlobService:
except Exception as e:
logger.error(f"Error creando container: {e}")
raise e
async def upload_file(self, file_data: BinaryIO, blob_name: str, tema: str = "") -> dict:
async def upload_file(
self, file_data: BinaryIO, blob_name: str, tema: str = ""
) -> dict:
"""
Subir un archivo a Azure Blob Storage
Args:
file_data: Datos del archivo
blob_name: Nombre del archivo en el blob
tema: Tema/carpeta donde guardar el archivo
tema: Tema/carpeta donde guardar el archivo (se normaliza a lowercase)
Returns:
dict: Información del archivo subido
"""
try:
# Construir la ruta completa con tema si se proporciona
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
# Normalizar tema a lowercase para consistencia
tema_normalized = tema.lower() if tema else ""
# Construir la ruta completa con tema normalizado
full_blob_name = (
f"{tema_normalized}/{blob_name}" if tema_normalized else blob_name
)
# Obtener cliente del blob
blob_client = self.blob_service_client.get_blob_client(
container=self.container_name,
blob=full_blob_name
container=self.container_name, blob=full_blob_name
)
# Subir el archivo
blob_client.upload_blob(file_data, overwrite=True)
# Obtener propiedades del blob
blob_properties = blob_client.get_blob_properties()
logger.info(f"Archivo '{full_blob_name}' subido exitosamente")
return {
"name": blob_name,
"full_path": full_blob_name,
"tema": tema,
"tema": tema_normalized,
"size": blob_properties.size,
"last_modified": blob_properties.last_modified,
"url": blob_client.url
"url": blob_client.url,
}
except Exception as e:
logger.error(f"Error subiendo archivo '{blob_name}': {e}")
raise e
async def download_file(self, blob_name: str, tema: str = "") -> bytes:
"""
Descargar un archivo de Azure Blob Storage
Args:
blob_name: Nombre del archivo
tema: Tema/carpeta donde está el archivo
tema: Tema/carpeta donde está el archivo (búsqueda case-insensitive)
Returns:
bytes: Contenido del archivo
"""
try:
# Construir la ruta completa
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
# Si se proporciona tema, buscar el archivo de manera case-insensitive
if tema:
full_blob_name = await self._find_blob_case_insensitive(blob_name, tema)
else:
full_blob_name = blob_name
# Obtener cliente del blob
blob_client = self.blob_service_client.get_blob_client(
container=self.container_name,
blob=full_blob_name
container=self.container_name, blob=full_blob_name
)
# Descargar el archivo
blob_data = blob_client.download_blob()
content = blob_data.readall()
logger.info(f"Archivo '{full_blob_name}' descargado exitosamente")
return content
except ResourceNotFoundError:
logger.error(f"Archivo '{full_blob_name}' no encontrado")
raise FileNotFoundError(f"El archivo '{blob_name}' no existe")
except Exception as e:
logger.error(f"Error descargando archivo '{blob_name}': {e}")
raise e
async def delete_file(self, blob_name: str, tema: str = "") -> bool:
"""
Eliminar un archivo de Azure Blob Storage
Args:
blob_name: Nombre del archivo
tema: Tema/carpeta donde está el archivo
tema: Tema/carpeta donde está el archivo (búsqueda case-insensitive)
Returns:
bool: True si se eliminó exitosamente
"""
try:
# Construir la ruta completa
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
# Si se proporciona tema, buscar el archivo de manera case-insensitive
if tema:
full_blob_name = await self._find_blob_case_insensitive(blob_name, tema)
else:
full_blob_name = blob_name
# Obtener cliente del blob
blob_client = self.blob_service_client.get_blob_client(
container=self.container_name,
blob=full_blob_name
container=self.container_name, blob=full_blob_name
)
# Eliminar el archivo
blob_client.delete_blob()
logger.info(f"Archivo '{full_blob_name}' eliminado exitosamente")
return True
except ResourceNotFoundError:
logger.error(f"Archivo '{full_blob_name}' no encontrado para eliminar")
raise FileNotFoundError(f"El archivo '{blob_name}' no existe")
except Exception as e:
logger.error(f"Error eliminando archivo '{blob_name}': {e}")
raise e
async def list_files(self, tema: str = "") -> List[dict]:
"""
Listar archivos en el container o en un tema específico
Args:
tema: Tema/carpeta específica (opcional)
tema: Tema/carpeta específica (opcional) - filtrado case-insensitive
Returns:
List[dict]: Lista de archivos con sus propiedades
"""
try:
container_client = self.blob_service_client.get_container_client(self.container_name)
# Filtrar por tema si se proporciona
name_starts_with = f"{tema}/" if tema else None
blobs = container_client.list_blobs(name_starts_with=name_starts_with)
container_client = self.blob_service_client.get_container_client(
self.container_name
)
# Obtener todos los blobs para hacer filtrado case-insensitive
blobs = container_client.list_blobs()
files = []
tema_lower = tema.lower() if tema else ""
for blob in blobs:
# Extraer información del blob
blob_tema = os.path.dirname(blob.name) if "/" in blob.name else ""
# Filtrar por tema de manera case-insensitive si se proporciona
if tema and blob_tema.lower() != tema_lower:
continue
blob_info = {
"name": os.path.basename(blob.name),
"full_path": blob.name,
"tema": os.path.dirname(blob.name) if "/" in blob.name else "",
"tema": blob_tema,
"size": blob.size,
"last_modified": blob.last_modified,
"content_type": blob.content_settings.content_type if blob.content_settings else None
"content_type": blob.content_settings.content_type
if blob.content_settings
else None,
}
files.append(blob_info)
logger.info(f"Listados {len(files)} archivos" + (f" en tema '{tema}'" if tema else ""))
logger.info(
f"Listados {len(files)} archivos"
+ (f" en tema '{tema}' (case-insensitive)" if tema else "")
)
return files
except Exception as e:
logger.error(f"Error listando archivos: {e}")
raise e
async def get_file_info(self, blob_name: str, tema: str = "") -> dict:
"""
Obtener información de un archivo específico
Args:
blob_name: Nombre del archivo
tema: Tema/carpeta donde está el archivo
tema: Tema/carpeta donde está el archivo (búsqueda case-insensitive)
Returns:
dict: Información del archivo
"""
try:
# Construir la ruta completa
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
# Si se proporciona tema, buscar el archivo de manera case-insensitive
if tema:
full_blob_name = await self._find_blob_case_insensitive(blob_name, tema)
# Extraer el tema real del path encontrado
real_tema = (
os.path.dirname(full_blob_name) if "/" in full_blob_name else ""
)
else:
full_blob_name = blob_name
real_tema = ""
# Obtener cliente del blob
blob_client = self.blob_service_client.get_blob_client(
container=self.container_name,
blob=full_blob_name
container=self.container_name, blob=full_blob_name
)
# Obtener propiedades
properties = blob_client.get_blob_properties()
return {
"name": blob_name,
"full_path": full_blob_name,
"tema": tema,
"tema": real_tema,
"size": properties.size,
"last_modified": properties.last_modified,
"content_type": properties.content_settings.content_type,
"url": blob_client.url
"url": blob_client.url,
}
except ResourceNotFoundError:
logger.error(f"Archivo '{full_blob_name}' no encontrado")
raise FileNotFoundError(f"El archivo '{blob_name}' no existe")
except Exception as e:
logger.error(f"Error obteniendo info del archivo '{blob_name}': {e}")
raise e
async def get_download_url(self, blob_name: str, tema: str = "") -> str:
"""
Obtener URL de descarga directa para un archivo
Args:
blob_name: Nombre del archivo
tema: Tema/carpeta donde está el archivo
tema: Tema/carpeta donde está el archivo (búsqueda case-insensitive)
Returns:
str: URL de descarga
"""
try:
# Construir la ruta completa
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
# Si se proporciona tema, buscar el archivo de manera case-insensitive
if tema:
full_blob_name = await self._find_blob_case_insensitive(blob_name, tema)
else:
full_blob_name = blob_name
# Obtener cliente del blob
blob_client = self.blob_service_client.get_blob_client(
container=self.container_name,
blob=full_blob_name
container=self.container_name, blob=full_blob_name
)
return blob_client.url
@@ -292,7 +338,9 @@ class AzureBlobService:
logger.error(f"Error obteniendo URL de descarga para '{blob_name}': {e}")
raise e
async def generate_sas_url(self, blob_name: str, tema: str = "", expiry_hours: int = 1) -> str:
async def generate_sas_url(
self, blob_name: str, tema: str = "", expiry_hours: int = 1
) -> str:
"""
Generar una URL SAS (Shared Access Signature) temporal para acceder a un archivo
@@ -301,7 +349,7 @@ class AzureBlobService:
Args:
blob_name: Nombre del archivo
tema: Tema/carpeta donde está el archivo
tema: Tema/carpeta donde está el archivo (búsqueda case-insensitive)
expiry_hours: Horas de validez de la URL (por defecto 1 hora)
Returns:
@@ -310,13 +358,15 @@ class AzureBlobService:
try:
from azure.storage.blob import ContentSettings
# Construir la ruta completa del blob
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
# Si se proporciona tema, buscar el archivo de manera case-insensitive
if tema:
full_blob_name = await self._find_blob_case_insensitive(blob_name, tema)
else:
full_blob_name = blob_name
# Obtener cliente del blob
blob_client = self.blob_service_client.get_blob_client(
container=self.container_name,
blob=full_blob_name
container=self.container_name, blob=full_blob_name
)
# Verificar que el archivo existe antes de generar el SAS
@@ -327,11 +377,13 @@ class AzureBlobService:
# Esto hace que el navegador muestre el PDF en lugar de descargarlo
try:
content_settings = ContentSettings(
content_type='application/pdf',
content_disposition='inline' # Clave para mostrar en navegador
content_type="application/pdf",
content_disposition="inline", # Clave para mostrar en navegador
)
blob_client.set_http_headers(content_settings=content_settings)
logger.info(f"Headers configurados para visualización inline de '{full_blob_name}'")
logger.info(
f"Headers configurados para visualización inline de '{full_blob_name}'"
)
except Exception as e:
logger.warning(f"No se pudieron configurar headers inline: {e}")
@@ -342,9 +394,9 @@ class AzureBlobService:
# Extraer la account key del connection string para generar el SAS
# El SAS necesita la account key para firmar el token
account_key = None
for part in settings.AZURE_STORAGE_CONNECTION_STRING.split(';'):
if part.startswith('AccountKey='):
account_key = part.split('=', 1)[1]
for part in settings.AZURE_STORAGE_CONNECTION_STRING.split(";"):
if part.startswith("AccountKey="):
account_key = part.split("=", 1)[1]
break
if not account_key:
@@ -358,13 +410,15 @@ class AzureBlobService:
account_key=account_key,
permission=BlobSasPermissions(read=True), # Solo permisos de lectura
expiry=expiry_time,
start=start_time
start=start_time,
)
# Construir la URL completa con el SAS token
sas_url = f"{blob_client.url}?{sas_token}"
logger.info(f"SAS URL generada para '{full_blob_name}' (válida por {expiry_hours} horas)")
logger.info(
f"SAS URL generada para '{full_blob_name}' (válida por {expiry_hours} horas)"
)
return sas_url
except FileNotFoundError:
@@ -374,6 +428,47 @@ class AzureBlobService:
logger.error(f"Error generando SAS URL para '{blob_name}': {e}")
raise e
async def _find_blob_case_insensitive(self, blob_name: str, tema: str) -> str:
"""
Buscar un blob de manera case-insensitive
Args:
blob_name: Nombre del archivo a buscar
tema: Tema donde buscar (case-insensitive)
Returns:
str: Ruta completa del blob encontrado
Raises:
FileNotFoundError: Si no se encuentra el archivo
"""
try:
container_client = self.blob_service_client.get_container_client(
self.container_name
)
blobs = container_client.list_blobs()
tema_lower = tema.lower()
blob_name_lower = blob_name.lower()
for blob in blobs:
blob_tema = os.path.dirname(blob.name) if "/" in blob.name else ""
current_blob_name = os.path.basename(blob.name)
if (
blob_tema.lower() == tema_lower
and current_blob_name.lower() == blob_name_lower
):
return blob.name
# Si no se encuentra, usar la construcción original para que falle apropiadamente
return f"{tema}/{blob_name}"
except Exception as e:
logger.error(f"Error buscando blob case-insensitive: {e}")
# Fallback a construcción original
return f"{tema}/{blob_name}"
# Instancia global del servicio
azure_service = AzureBlobService()
azure_service = AzureBlobService()

View File

@@ -66,6 +66,8 @@ class ChunkingService:
"""
Descarga un PDF desde Azure Blob Storage.
NOTA: Todos los blobs se guardan en minúsculas en Azure.
Args:
file_name: Nombre del archivo
tema: Tema/carpeta del archivo
@@ -77,8 +79,9 @@ class ChunkingService:
Exception: Si hay error descargando el archivo
"""
try:
blob_path = f"{tema}/{file_name}"
logger.info(f"Descargando PDF: {blob_path}")
# Convertir a minúsculas ya que todos los blobs están en minúsculas
blob_path = f"{tema.lower()}/{file_name.lower()}"
logger.info(f"Descargando PDF: {blob_path} (tema original: {tema}, file original: {file_name})")
blob_client = self.blob_service.get_blob_client(
container=self.container_name,

View File

@@ -1,10 +1,12 @@
"""
Servicio de embeddings usando Azure OpenAI.
Genera embeddings para chunks de texto usando text-embedding-3-large (3072 dimensiones).
Incluye manejo de rate limits con retry exponencial y delays entre batches.
"""
import asyncio
import logging
from typing import List
from openai import AzureOpenAI
from openai import AzureOpenAI, RateLimitError
from ..core.config import settings
logger = logging.getLogger(__name__)
@@ -63,46 +65,89 @@ class EmbeddingService:
async def generate_embeddings_batch(
self,
texts: List[str],
batch_size: int = 100
batch_size: int | None = None,
delay_between_batches: float | None = None,
max_retries: int | None = None
) -> List[List[float]]:
"""
Genera embeddings para múltiples textos en lotes.
Genera embeddings para múltiples textos en lotes con manejo de rate limits.
Args:
texts: Lista de textos para generar embeddings
batch_size: Tamaño del lote para procesamiento (default: 100)
batch_size: Tamaño del lote (None = usar configuración de settings)
delay_between_batches: Segundos de espera entre batches (None = usar configuración)
max_retries: Número máximo de reintentos (None = usar configuración)
Returns:
Lista de vectores de embeddings
Raises:
Exception: Si hay error al generar los embeddings
Exception: Si hay error al generar los embeddings después de todos los reintentos
"""
# Usar configuración de settings si no se proporciona
batch_size = batch_size or settings.EMBEDDING_BATCH_SIZE
delay_between_batches = delay_between_batches or settings.EMBEDDING_DELAY_BETWEEN_BATCHES
max_retries = max_retries or settings.EMBEDDING_MAX_RETRIES
try:
embeddings = []
total_batches = (len(texts) - 1) // batch_size + 1
logger.info(f"Iniciando generación de embeddings: {len(texts)} textos en {total_batches} batches")
logger.info(f"Configuración: batch_size={batch_size}, delay={delay_between_batches}s, max_retries={max_retries}")
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
logger.info(f"Procesando lote {i//batch_size + 1}/{(len(texts)-1)//batch_size + 1}")
batch_num = i // batch_size + 1
response = self.client.embeddings.create(
input=batch,
model=self.model
)
logger.info(f"📊 Procesando batch {batch_num}/{total_batches} ({len(batch)} textos)...")
batch_embeddings = [item.embedding for item in response.data]
# Validar dimensiones
for idx, emb in enumerate(batch_embeddings):
if len(emb) != self.embedding_dimension:
raise ValueError(
f"Dimensión incorrecta en índice {i + idx}: "
f"esperada {self.embedding_dimension}, obtenida {len(emb)}"
# Retry con exponential backoff
retry_count = 0
while retry_count <= max_retries:
try:
response = self.client.embeddings.create(
input=batch,
model=self.model
)
embeddings.extend(batch_embeddings)
batch_embeddings = [item.embedding for item in response.data]
logger.info(f"Generados {len(embeddings)} embeddings exitosamente")
# Validar dimensiones
for idx, emb in enumerate(batch_embeddings):
if len(emb) != self.embedding_dimension:
raise ValueError(
f"Dimensión incorrecta en índice {i + idx}: "
f"esperada {self.embedding_dimension}, obtenida {len(emb)}"
)
embeddings.extend(batch_embeddings)
logger.info(f"✓ Batch {batch_num}/{total_batches} completado exitosamente")
break # Éxito, salir del retry loop
except RateLimitError as e:
retry_count += 1
if retry_count > max_retries:
logger.error(f"❌ Rate limit excedido después de {max_retries} reintentos")
raise
# Exponential backoff: 2^retry_count segundos
wait_time = 2 ** retry_count
logger.warning(
f"⚠️ Rate limit alcanzado en batch {batch_num}/{total_batches}. "
f"Reintento {retry_count}/{max_retries} en {wait_time}s..."
)
await asyncio.sleep(wait_time)
except Exception as e:
logger.error(f"❌ Error en batch {batch_num}/{total_batches}: {e}")
raise
# Delay entre batches para respetar rate limit (excepto en el último)
if i + batch_size < len(texts):
await asyncio.sleep(delay_between_batches)
logger.info(f"✅ Embeddings generados exitosamente: {len(embeddings)} vectores de {self.embedding_dimension}D")
return embeddings
except Exception as e:

View File

@@ -0,0 +1,131 @@
"""
Servicio para manejar el almacenamiento de datos extraídos en Redis.
"""
import logging
from datetime import datetime
from typing import Dict, Any, List, Optional
from ..models.extracted_data import ExtractedDocument
logger = logging.getLogger(__name__)
class ExtractedDataService:
"""Servicio para guardar y recuperar datos extraídos de documentos"""
async def save_extracted_data(
self,
file_name: str,
tema: str,
collection_name: str,
extracted_data: Dict[str, Any]
) -> ExtractedDocument:
"""
Guarda datos extraídos de un documento en Redis.
Args:
file_name: Nombre del archivo
tema: Tema del documento
collection_name: Colección de Qdrant
extracted_data: Datos extraídos (dict)
Returns:
ExtractedDocument guardado
"""
try:
# Crear instancia del modelo
doc = ExtractedDocument(
file_name=file_name,
tema=tema,
collection_name=collection_name,
extracted_data_json="", # Se setea después
extraction_timestamp=datetime.utcnow().isoformat()
)
# Serializar datos extraídos
doc.set_extracted_data(extracted_data)
# Guardar en Redis
doc.save()
logger.info(
f"💾 Datos extraídos guardados en Redis: {file_name} "
f"({len(extracted_data)} campos)"
)
return doc
except Exception as e:
logger.error(f"Error guardando datos extraídos en Redis: {e}")
raise
async def get_by_file(self, file_name: str) -> List[ExtractedDocument]:
"""
Obtiene todos los documentos extraídos de un archivo.
Args:
file_name: Nombre del archivo
Returns:
Lista de ExtractedDocument
"""
try:
docs = ExtractedDocument.find_by_file(file_name)
logger.info(f"Encontrados {len(docs)} documentos extraídos para {file_name}")
return docs
except Exception as e:
logger.error(f"Error buscando documentos por archivo: {e}")
return []
async def get_by_tema(self, tema: str) -> List[ExtractedDocument]:
"""
Obtiene todos los documentos extraídos de un tema.
Args:
tema: Tema a buscar
Returns:
Lista de ExtractedDocument
"""
try:
docs = ExtractedDocument.find_by_tema(tema)
logger.info(f"Encontrados {len(docs)} documentos extraídos para tema {tema}")
return docs
except Exception as e:
logger.error(f"Error buscando documentos por tema: {e}")
return []
async def get_by_collection(self, collection_name: str) -> List[ExtractedDocument]:
"""
Obtiene todos los documentos de una colección.
Args:
collection_name: Nombre de la colección
Returns:
Lista de ExtractedDocument
"""
try:
docs = ExtractedDocument.find_by_collection(collection_name)
logger.info(f"Encontrados {len(docs)} documentos en colección {collection_name}")
return docs
except Exception as e:
logger.error(f"Error buscando documentos por colección: {e}")
return []
# Instancia global singleton
_extracted_data_service: Optional[ExtractedDataService] = None
def get_extracted_data_service() -> ExtractedDataService:
"""
Obtiene la instancia singleton del servicio.
Returns:
Instancia de ExtractedDataService
"""
global _extracted_data_service
if _extracted_data_service is None:
_extracted_data_service = ExtractedDataService()
return _extracted_data_service

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,767 @@
{
"schema_id": "schema_103b7090a542",
"schema_name": "Form 990-PF Data Extraction",
"description": "Comprehensive data extraction schema for IRS Form 990-PF (Private Foundation) including financial, governance, and operational information",
"fields": [
{
"name": "ein",
"type": "string",
"description": "Federal Employer Identification Number of the organization",
"required": true,
"min_value": null,
"max_value": null,
"pattern": "^\\d{2}-\\d{7}$"
},
{
"name": "legal_name",
"type": "string",
"description": "Official registered name of the organization",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "phone_number",
"type": "string",
"description": "Primary contact phone number",
"required": true,
"min_value": null,
"max_value": null,
"pattern": "^\\([0-9]{3}\\) [0-9]{3}-[0-9]{4}$"
},
{
"name": "website_url",
"type": "string",
"description": "Organization's website address",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "return_type",
"type": "string",
"description": "Type of IRS return filed (990-PF for private foundations)",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "amended_return",
"type": "string",
"description": "Indicates if this is an amended return (Yes/No)",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "group_exemption_number",
"type": "string",
"description": "IRS group exemption number, if applicable",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "subsection_code",
"type": "string",
"description": "IRS subsection code (typically 501(c)(3) for foundations)",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "ruling_date",
"type": "string",
"description": "Date of IRS ruling or determination letter",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "accounting_method",
"type": "string",
"description": "Accounting method used (Cash, Accrual, or Other)",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "organization_type",
"type": "string",
"description": "Legal structure (corporation, trust, association, etc.)",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "year_of_formation",
"type": "string",
"description": "Year the organization was established",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "incorporation_state",
"type": "string",
"description": "State where the organization was incorporated",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "total_revenue",
"type": "float",
"description": "Sum of all revenue sources for the year",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "contributions_gifts_grants",
"type": "float",
"description": "Revenue from donations, contributions, and grants",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "program_service_revenue",
"type": "float",
"description": "Revenue generated from program services",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "membership_dues",
"type": "float",
"description": "Revenue from membership dues and assessments",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "investment_income",
"type": "float",
"description": "Income from interest, dividends, and other investments",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "gains_losses_sales_assets",
"type": "float",
"description": "Net gains or losses from sale of investments and assets",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "rental_income",
"type": "float",
"description": "Income from rental of real estate or equipment",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "related_organizations_revenue",
"type": "float",
"description": "Revenue received from related organizations",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "gaming_revenue",
"type": "float",
"description": "Revenue from gaming and gambling activities",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "other_revenue",
"type": "float",
"description": "All other revenue not categorized elsewhere",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "government_grants",
"type": "float",
"description": "Revenue from federal, state, and local government grants",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "foreign_contributions",
"type": "float",
"description": "Revenue from foreign sources and contributors",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "total_expenses",
"type": "float",
"description": "Sum of all organizational expenses for the year",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "program_services_expenses",
"type": "float",
"description": "Direct expenses for charitable program activities",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "management_general_expenses",
"type": "float",
"description": "Administrative and general operating expenses",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "fundraising_expenses",
"type": "float",
"description": "Expenses related to fundraising activities",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "grants_us_organizations",
"type": "float",
"description": "Grants and assistance provided to domestic organizations",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "grants_us_individuals",
"type": "float",
"description": "Grants and assistance provided to domestic individuals",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "grants_foreign_organizations",
"type": "float",
"description": "Grants and assistance provided to foreign organizations",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "grants_foreign_individuals",
"type": "float",
"description": "Grants and assistance provided to foreign individuals",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "compensation_officers",
"type": "float",
"description": "Total compensation paid to officers and key employees",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "compensation_other_staff",
"type": "float",
"description": "Compensation paid to other employees",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "payroll_taxes_benefits",
"type": "float",
"description": "Payroll taxes, pension plans, and employee benefits",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "professional_fees",
"type": "float",
"description": "Legal, accounting, and other professional service fees",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "office_occupancy_costs",
"type": "float",
"description": "Rent, utilities, and facility-related expenses",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "information_technology_costs",
"type": "float",
"description": "IT equipment, software, and technology expenses",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "travel_conference_expenses",
"type": "float",
"description": "Travel, conferences, conventions, and meetings",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "depreciation_amortization",
"type": "float",
"description": "Depreciation of equipment and amortization of intangibles",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "insurance",
"type": "float",
"description": "Insurance premiums and related costs",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "officers_list",
"type": "array_string",
"description": "JSON array of officers, directors, trustees, and key employees with their details",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "governing_body_size",
"type": "integer",
"description": "Total number of voting members on the governing body",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "independent_members",
"type": "integer",
"description": "Number of independent voting members",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "financial_statements_reviewed",
"type": "string",
"description": "Whether financial statements were reviewed or audited",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "form_990_provided_to_governing_body",
"type": "string",
"description": "Whether Form 990 was provided to governing body before filing",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "conflict_of_interest_policy",
"type": "string",
"description": "Whether organization has a conflict of interest policy",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "whistleblower_policy",
"type": "string",
"description": "Whether organization has a whistleblower policy",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "document_retention_policy",
"type": "string",
"description": "Whether organization has a document retention and destruction policy",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "ceo_compensation_review_process",
"type": "string",
"description": "Process used to determine compensation of organization's top management",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "public_disclosure_practices",
"type": "string",
"description": "How organization makes its governing documents and annual returns available to the public",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "program_accomplishments_list",
"type": "array_string",
"description": "JSON array of program service accomplishments with descriptions and financial details",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "total_fundraising_event_revenue",
"type": "float",
"description": "Total revenue from all fundraising events",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "total_fundraising_event_expenses",
"type": "float",
"description": "Total direct expenses for all fundraising events",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "professional_fundraiser_fees",
"type": "float",
"description": "Fees paid to professional fundraising services",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "number_of_employees",
"type": "integer",
"description": "Total number of employees during the year",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "number_of_volunteers",
"type": "integer",
"description": "Estimate of volunteers who provided services",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "occupancy_costs",
"type": "float",
"description": "Total costs for office space and facilities",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "fundraising_method_descriptions",
"type": "string",
"description": "Description of methods used for fundraising",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "joint_ventures_disregarded_entities",
"type": "string",
"description": "Information about joint ventures and disregarded entities",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "base_compensation",
"type": "float",
"description": "Base salary or wages paid to key personnel",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "bonus",
"type": "float",
"description": "Bonus and incentive compensation paid",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "incentive",
"type": "float",
"description": "Other incentive compensation",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "other_compensation",
"type": "float",
"description": "Other forms of compensation",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "non_fixed_compensation",
"type": "string",
"description": "Whether compensation arrangement is non-fixed",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "first_class_travel",
"type": "string",
"description": "Whether first-class or charter travel was provided",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "housing_allowance",
"type": "string",
"description": "Whether housing allowance or residence was provided",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "expense_account_usage",
"type": "string",
"description": "Whether payments for business use of personal residence were made",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "supplemental_retirement",
"type": "string",
"description": "Whether supplemental nonqualified retirement plan was provided",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "lobbying_expenditures_direct",
"type": "float",
"description": "Amount spent on direct lobbying activities",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "lobbying_expenditures_grassroots",
"type": "float",
"description": "Amount spent on grassroots lobbying activities",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "election_501h_status",
"type": "string",
"description": "Whether the organization made a Section 501(h) election",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "political_campaign_expenditures",
"type": "float",
"description": "Amount spent on political campaign activities",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "related_organizations_affiliates",
"type": "string",
"description": "Information about related organizations involved in political activities",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "investment_types",
"type": "string",
"description": "Description of types of investments held",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "donor_restricted_endowment_values",
"type": "float",
"description": "Value of permanently restricted endowment funds",
"required": true,
"min_value": 0,
"max_value": null,
"pattern": null
},
{
"name": "net_appreciation_depreciation",
"type": "float",
"description": "Net appreciation or depreciation in fair value of investments",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "related_organization_transactions",
"type": "string",
"description": "Information about transactions with related organizations",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "loans_to_from_related_parties",
"type": "string",
"description": "Information about loans to or from related parties",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "penalties_excise_taxes_reported",
"type": "string",
"description": "Whether the organization reported any penalties or excise taxes",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "unrelated_business_income_disclosure",
"type": "string",
"description": "Whether the organization had unrelated business income",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "foreign_bank_account_reporting",
"type": "string",
"description": "Whether the organization had foreign bank accounts or assets",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
},
{
"name": "schedule_o_narrative_explanations",
"type": "string",
"description": "Additional narrative explanations from Schedule O",
"required": true,
"min_value": null,
"max_value": null,
"pattern": null
}
],
"created_at": "2025-11-07T23:45:00.000000",
"updated_at": "2025-11-07T23:45:00.000000",
"tema": "IRS_FORM_990PF",
"is_global": true
}

View File

View File

@@ -27,7 +27,15 @@ dependencies = [
"langchain-text-splitters>=1.0.0",
# LandingAI Document AI
"landingai-ade>=0.2.1",
"redis-om>=0.3.5",
"pydantic-ai-slim[google,openai,mcp]>=1.11.1",
"tavily-python>=0.5.0",
]
[project.scripts]
dev = "uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
start = "uvicorn app.main:app --host 0.0.0.0 --port 8000"
[dependency-groups]
dev = [
"ruff>=0.14.4",
]

521
backend/uv.lock generated
View File

@@ -30,6 +30,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" },
]
[[package]]
name = "attrs"
version = "25.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "azure-core"
version = "1.35.0"
@@ -74,16 +83,24 @@ dependencies = [
{ name = "openai" },
{ name = "pdf2image" },
{ name = "pillow" },
{ name = "pydantic-ai-slim", extra = ["google", "mcp", "openai"] },
{ name = "pydantic-settings" },
{ name = "pypdf" },
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "qdrant-client" },
{ name = "redis-om" },
{ name = "tavily-python" },
{ name = "tiktoken" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "websockets" },
]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "azure-storage-blob", specifier = ">=12.26.0" },
@@ -96,16 +113,22 @@ requires-dist = [
{ name = "openai", specifier = ">=1.59.6" },
{ name = "pdf2image", specifier = ">=1.17.0" },
{ name = "pillow", specifier = ">=11.0.0" },
{ name = "pydantic-ai-slim", extras = ["google", "openai", "mcp"], specifier = ">=1.11.1" },
{ name = "pydantic-settings", specifier = ">=2.10.1" },
{ name = "pypdf", specifier = ">=5.1.0" },
{ name = "python-dotenv", specifier = ">=1.1.1" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "qdrant-client", specifier = ">=1.15.1" },
{ name = "redis-om", specifier = ">=0.3.5" },
{ name = "tavily-python", specifier = ">=0.5.0" },
{ name = "tiktoken", specifier = ">=0.8.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35.0" },
{ name = "websockets", specifier = ">=14.1" },
]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.14.4" }]
[[package]]
name = "cachetools"
version = "6.2.1"
@@ -287,6 +310,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" },
]
[[package]]
name = "genai-prices"
version = "0.0.36"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/39/e2/45c863fb61cf2d70d948e80d63e4f3db213a957976a2a3564e40ebe8f506/genai_prices-0.0.36.tar.gz", hash = "sha256:1092f5b96168967fa880440dd9dcc9287fd73910b284045f0226a38f628ccbc9", size = 46046, upload-time = "2025-11-05T14:04:13.437Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/89/14b4be11b74dd29827bc37b648b0540fcf3bd6530cb48031f1ce7da4594c/genai_prices-0.0.36-py3-none-any.whl", hash = "sha256:7ad39e04fbcdb5cfdc3891e68de6ca1064b6660e06e9ba76fa6f161ff12b32e4", size = 48688, upload-time = "2025-11-05T14:04:12.133Z" },
]
[[package]]
name = "google-api-core"
version = "2.28.1"
@@ -484,6 +520,18 @@ grpc = [
{ name = "grpcio", version = "1.76.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
]
[[package]]
name = "griffe"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ec/d7/6c09dd7ce4c7837e4cdb11dce980cb45ae3cd87677298dc3b781b6bce7d3/griffe-1.14.0.tar.gz", hash = "sha256:9d2a15c1eca966d68e00517de5d69dd1bc5c9f2335ef6c1775362ba5b8651a13", size = 424684, upload-time = "2025-09-05T15:02:29.167Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/b1/9ff6578d789a89812ff21e4e0f80ffae20a65d5dd84e7a17873fe3b365be/griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0", size = 144439, upload-time = "2025-09-05T15:02:27.511Z" },
]
[[package]]
name = "grpc-google-iam-v1"
version = "0.14.3"
@@ -632,6 +680,66 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
]
[[package]]
name = "hiredis"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/82/d2817ce0653628e0a0cb128533f6af0dd6318a49f3f3a6a7bd1f2f2154af/hiredis-3.3.0.tar.gz", hash = "sha256:105596aad9249634361815c574351f1bd50455dc23b537c2940066c4a9dea685", size = 89048, upload-time = "2025-10-14T16:33:34.263Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/1c/ed28ae5d704f5c7e85b946fa327f30d269e6272c847fef7e91ba5fc86193/hiredis-3.3.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:5b8e1d6a2277ec5b82af5dce11534d3ed5dffeb131fd9b210bc1940643b39b5f", size = 82026, upload-time = "2025-10-14T16:32:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/f4/9b/79f30c5c40e248291023b7412bfdef4ad9a8a92d9e9285d65d600817dac7/hiredis-3.3.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c4981de4d335f996822419e8a8b3b87367fcef67dc5fb74d3bff4df9f6f17783", size = 46217, upload-time = "2025-10-14T16:32:13.133Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c3/02b9ed430ad9087aadd8afcdf616717452d16271b701fa47edfe257b681e/hiredis-3.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1706480a683e328ae9ba5d704629dee2298e75016aa0207e7067b9c40cecc271", size = 41858, upload-time = "2025-10-14T16:32:13.98Z" },
{ url = "https://files.pythonhosted.org/packages/f1/98/b2a42878b82130a535c7aa20bc937ba2d07d72e9af3ad1ad93e837c419b5/hiredis-3.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a95cef9989736ac313639f8f545b76b60b797e44e65834aabbb54e4fad8d6c8", size = 170195, upload-time = "2025-10-14T16:32:14.728Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/9dcde7a75115d3601b016113d9b90300726fa8e48aacdd11bf01a453c145/hiredis-3.3.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca2802934557ccc28a954414c245ba7ad904718e9712cb67c05152cf6b9dd0a3", size = 181808, upload-time = "2025-10-14T16:32:15.622Z" },
{ url = "https://files.pythonhosted.org/packages/56/a1/60f6bda9b20b4e73c85f7f5f046bc2c154a5194fc94eb6861e1fd97ced52/hiredis-3.3.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fe730716775f61e76d75810a38ee4c349d3af3896450f1525f5a4034cf8f2ed7", size = 180578, upload-time = "2025-10-14T16:32:16.514Z" },
{ url = "https://files.pythonhosted.org/packages/d9/01/859d21de65085f323a701824e23ea3330a0ac05f8e184544d7aa5c26128d/hiredis-3.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:749faa69b1ce1f741f5eaf743435ac261a9262e2d2d66089192477e7708a9abc", size = 172508, upload-time = "2025-10-14T16:32:17.411Z" },
{ url = "https://files.pythonhosted.org/packages/99/a8/28fd526e554c80853d0fbf57ef2a3235f00e4ed34ce0e622e05d27d0f788/hiredis-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:95c9427f2ac3f1dd016a3da4e1161fa9d82f221346c8f3fdd6f3f77d4e28946c", size = 166341, upload-time = "2025-10-14T16:32:18.561Z" },
{ url = "https://files.pythonhosted.org/packages/f2/91/ded746b7d2914f557fbbf77be55e90d21f34ba758ae10db6591927c642c8/hiredis-3.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c863ee44fe7bff25e41f3a5105c936a63938b76299b802d758f40994ab340071", size = 176765, upload-time = "2025-10-14T16:32:19.491Z" },
{ url = "https://files.pythonhosted.org/packages/d6/4c/04aa46ff386532cb5f08ee495c2bf07303e93c0acf2fa13850e031347372/hiredis-3.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2213c7eb8ad5267434891f3241c7776e3bafd92b5933fc57d53d4456247dc542", size = 170312, upload-time = "2025-10-14T16:32:20.404Z" },
{ url = "https://files.pythonhosted.org/packages/90/6e/67f9d481c63f542a9cf4c9f0ea4e5717db0312fb6f37fb1f78f3a66de93c/hiredis-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a172bae3e2837d74530cd60b06b141005075db1b814d966755977c69bd882ce8", size = 167965, upload-time = "2025-10-14T16:32:21.259Z" },
{ url = "https://files.pythonhosted.org/packages/7a/df/dde65144d59c3c0d85e43255798f1fa0c48d413e668cfd92b3d9f87924ef/hiredis-3.3.0-cp312-cp312-win32.whl", hash = "sha256:cb91363b9fd6d41c80df9795e12fffbaf5c399819e6ae8120f414dedce6de068", size = 20533, upload-time = "2025-10-14T16:32:22.192Z" },
{ url = "https://files.pythonhosted.org/packages/f5/a9/55a4ac9c16fdf32e92e9e22c49f61affe5135e177ca19b014484e28950f7/hiredis-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:04ec150e95eea3de9ff8bac754978aa17b8bf30a86d4ab2689862020945396b0", size = 22379, upload-time = "2025-10-14T16:32:22.916Z" },
{ url = "https://files.pythonhosted.org/packages/6d/39/2b789ebadd1548ccb04a2c18fbc123746ad1a7e248b7f3f3cac618ca10a6/hiredis-3.3.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:b7048b4ec0d5dddc8ddd03da603de0c4b43ef2540bf6e4c54f47d23e3480a4fa", size = 82035, upload-time = "2025-10-14T16:32:23.715Z" },
{ url = "https://files.pythonhosted.org/packages/85/74/4066d9c1093be744158ede277f2a0a4e4cd0fefeaa525c79e2876e9e5c72/hiredis-3.3.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:e5f86ce5a779319c15567b79e0be806e8e92c18bb2ea9153e136312fafa4b7d6", size = 46219, upload-time = "2025-10-14T16:32:24.554Z" },
{ url = "https://files.pythonhosted.org/packages/fa/3f/f9e0f6d632f399d95b3635703e1558ffaa2de3aea4cfcbc2d7832606ba43/hiredis-3.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fbdb97a942e66016fff034df48a7a184e2b7dc69f14c4acd20772e156f20d04b", size = 41860, upload-time = "2025-10-14T16:32:25.356Z" },
{ url = "https://files.pythonhosted.org/packages/4a/c5/b7dde5ec390dabd1cabe7b364a509c66d4e26de783b0b64cf1618f7149fc/hiredis-3.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0fb4bea72fe45ff13e93ddd1352b43ff0749f9866263b5cca759a4c960c776f", size = 170094, upload-time = "2025-10-14T16:32:26.148Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d6/7f05c08ee74d41613be466935688068e07f7b6c55266784b5ace7b35b766/hiredis-3.3.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85b9baf98050e8f43c2826ab46aaf775090d608217baf7af7882596aef74e7f9", size = 181746, upload-time = "2025-10-14T16:32:27.844Z" },
{ url = "https://files.pythonhosted.org/packages/0e/d2/aaf9f8edab06fbf5b766e0cae3996324297c0516a91eb2ca3bd1959a0308/hiredis-3.3.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69079fb0f0ebb61ba63340b9c4bce9388ad016092ca157e5772eb2818209d930", size = 180465, upload-time = "2025-10-14T16:32:29.185Z" },
{ url = "https://files.pythonhosted.org/packages/8d/1e/93ded8b9b484519b211fc71746a231af98c98928e3ebebb9086ed20bb1ad/hiredis-3.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c17f77b79031ea4b0967d30255d2ae6e7df0603ee2426ad3274067f406938236", size = 172419, upload-time = "2025-10-14T16:32:30.059Z" },
{ url = "https://files.pythonhosted.org/packages/68/13/02880458e02bbfcedcaabb8f7510f9dda1c89d7c1921b1bb28c22bb38cbf/hiredis-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45d14f745fc177bc05fc24bdf20e2b515e9a068d3d4cce90a0fb78d04c9c9d9a", size = 166400, upload-time = "2025-10-14T16:32:31.173Z" },
{ url = "https://files.pythonhosted.org/packages/11/60/896e03267670570f19f61dc65a2137fcb2b06e83ab0911d58eeec9f3cb88/hiredis-3.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ba063fdf1eff6377a0c409609cbe890389aefddfec109c2d20fcc19cfdafe9da", size = 176845, upload-time = "2025-10-14T16:32:32.12Z" },
{ url = "https://files.pythonhosted.org/packages/f1/90/a1d4bd0cdcf251fda72ac0bd932f547b48ad3420f89bb2ef91bf6a494534/hiredis-3.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1799cc66353ad066bfdd410135c951959da9f16bcb757c845aab2f21fc4ef099", size = 170365, upload-time = "2025-10-14T16:32:33.035Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9a/7c98f7bb76bdb4a6a6003cf8209721f083e65d2eed2b514f4a5514bda665/hiredis-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2cbf71a121996ffac82436b6153290815b746afb010cac19b3290a1644381b07", size = 168022, upload-time = "2025-10-14T16:32:34.81Z" },
{ url = "https://files.pythonhosted.org/packages/0d/ca/672ee658ffe9525558615d955b554ecd36aa185acd4431ccc9701c655c9b/hiredis-3.3.0-cp313-cp313-win32.whl", hash = "sha256:a7cbbc6026bf03659f0b25e94bbf6e64f6c8c22f7b4bc52fe569d041de274194", size = 20533, upload-time = "2025-10-14T16:32:35.7Z" },
{ url = "https://files.pythonhosted.org/packages/20/93/511fd94f6a7b6d72a4cf9c2b159bf3d780585a9a1dca52715dd463825299/hiredis-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:a8def89dd19d4e2e4482b7412d453dec4a5898954d9a210d7d05f60576cedef6", size = 22387, upload-time = "2025-10-14T16:32:36.441Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b3/b948ee76a6b2bc7e45249861646f91f29704f743b52565cf64cee9c4658b/hiredis-3.3.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c135bda87211f7af9e2fd4e046ab433c576cd17b69e639a0f5bb2eed5e0e71a9", size = 82105, upload-time = "2025-10-14T16:32:37.204Z" },
{ url = "https://files.pythonhosted.org/packages/a2/9b/4210f4ebfb3ab4ada964b8de08190f54cbac147198fb463cd3c111cc13e0/hiredis-3.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f855c678230aed6fc29b962ce1cc67e5858a785ef3a3fd6b15dece0487a2e60", size = 46237, upload-time = "2025-10-14T16:32:38.07Z" },
{ url = "https://files.pythonhosted.org/packages/b3/7a/e38bfd7d04c05036b4ccc6f42b86b1032185cf6ae426e112a97551fece14/hiredis-3.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4059c78a930cbb33c391452ccce75b137d6f89e2eebf6273d75dafc5c2143c03", size = 41894, upload-time = "2025-10-14T16:32:38.929Z" },
{ url = "https://files.pythonhosted.org/packages/28/d3/eae43d9609c5d9a6effef0586ee47e13a0d84b44264b688d97a75cd17ee5/hiredis-3.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:334a3f1d14c253bb092e187736c3384203bd486b244e726319bbb3f7dffa4a20", size = 170486, upload-time = "2025-10-14T16:32:40.147Z" },
{ url = "https://files.pythonhosted.org/packages/c3/fd/34d664554880b27741ab2916d66207357563b1639e2648685f4c84cfb755/hiredis-3.3.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd137b147235447b3d067ec952c5b9b95ca54b71837e1b38dbb2ec03b89f24fc", size = 182031, upload-time = "2025-10-14T16:32:41.06Z" },
{ url = "https://files.pythonhosted.org/packages/08/a3/0c69fdde3f4155b9f7acc64ccffde46f312781469260061b3bbaa487fd34/hiredis-3.3.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f88f4f2aceb73329ece86a1cb0794fdbc8e6d614cb5ca2d1023c9b7eb432db8", size = 180542, upload-time = "2025-10-14T16:32:42.993Z" },
{ url = "https://files.pythonhosted.org/packages/68/7a/ad5da4d7bc241e57c5b0c4fe95aa75d1f2116e6e6c51577394d773216e01/hiredis-3.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:550f4d1538822fc75ebf8cf63adc396b23d4958bdbbad424521f2c0e3dfcb169", size = 172353, upload-time = "2025-10-14T16:32:43.965Z" },
{ url = "https://files.pythonhosted.org/packages/4b/dc/c46eace64eb047a5b31acd5e4b0dc6d2f0390a4a3f6d507442d9efa570ad/hiredis-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54b14211fbd5930fc696f6fcd1f1f364c660970d61af065a80e48a1fa5464dd6", size = 166435, upload-time = "2025-10-14T16:32:44.97Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ac/ad13a714e27883a2e4113c980c94caf46b801b810de5622c40f8d3e8335f/hiredis-3.3.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9e96f63dbc489fc86f69951e9f83dadb9582271f64f6822c47dcffa6fac7e4a", size = 177218, upload-time = "2025-10-14T16:32:45.936Z" },
{ url = "https://files.pythonhosted.org/packages/c2/38/268fabd85b225271fe1ba82cb4a484fcc1bf922493ff2c74b400f1a6f339/hiredis-3.3.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:106e99885d46684d62ab3ec1d6b01573cc0e0083ac295b11aaa56870b536c7ec", size = 170477, upload-time = "2025-10-14T16:32:46.898Z" },
{ url = "https://files.pythonhosted.org/packages/20/6b/02bb8af810ea04247334ab7148acff7a61c08a8832830c6703f464be83a9/hiredis-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:087e2ef3206361281b1a658b5b4263572b6ba99465253e827796964208680459", size = 167915, upload-time = "2025-10-14T16:32:47.847Z" },
{ url = "https://files.pythonhosted.org/packages/83/94/901fa817e667b2e69957626395e6dee416e31609dca738f28e6b545ca6c2/hiredis-3.3.0-cp314-cp314-win32.whl", hash = "sha256:80638ebeab1cefda9420e9fedc7920e1ec7b4f0513a6b23d58c9d13c882f8065", size = 21165, upload-time = "2025-10-14T16:32:50.753Z" },
{ url = "https://files.pythonhosted.org/packages/b1/7e/4881b9c1d0b4cdaba11bd10e600e97863f977ea9d67c5988f7ec8cd363e5/hiredis-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a68aaf9ba024f4e28cf23df9196ff4e897bd7085872f3a30644dca07fa787816", size = 22996, upload-time = "2025-10-14T16:32:51.543Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b6/d7e6c17da032665a954a89c1e6ee3bd12cb51cd78c37527842b03519981d/hiredis-3.3.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f7f80442a32ce51ee5d89aeb5a84ee56189a0e0e875f1a57bbf8d462555ae48f", size = 83034, upload-time = "2025-10-14T16:32:52.395Z" },
{ url = "https://files.pythonhosted.org/packages/27/6c/6751b698060cdd1b2d8427702cff367c9ed7a1705bcf3792eb5b896f149b/hiredis-3.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1a67530da714954ed50579f4fe1ab0ddbac9c43643b1721c2cb226a50dde263", size = 46701, upload-time = "2025-10-14T16:32:53.572Z" },
{ url = "https://files.pythonhosted.org/packages/ce/8e/20a5cf2c83c7a7e08c76b9abab113f99f71cd57468a9c7909737ce6e9bf8/hiredis-3.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:616868352e47ab355559adca30f4f3859f9db895b4e7bc71e2323409a2add751", size = 42381, upload-time = "2025-10-14T16:32:54.762Z" },
{ url = "https://files.pythonhosted.org/packages/be/0a/547c29c06e8c9c337d0df3eec39da0cf1aad701daf8a9658dd37f25aca66/hiredis-3.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e799b79f3150083e9702fc37e6243c0bd47a443d6eae3f3077b0b3f510d6a145", size = 180313, upload-time = "2025-10-14T16:32:55.644Z" },
{ url = "https://files.pythonhosted.org/packages/89/8a/488de5469e3d0921a1c425045bf00e983d48b2111a90e47cf5769eaa536c/hiredis-3.3.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ef1dfb0d2c92c3701655e2927e6bbe10c499aba632c7ea57b6392516df3864b", size = 190488, upload-time = "2025-10-14T16:32:56.649Z" },
{ url = "https://files.pythonhosted.org/packages/b5/59/8493edc3eb9ae0dbea2b2230c2041a52bc03e390b02ffa3ac0bca2af9aea/hiredis-3.3.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c290da6bc2a57e854c7da9956cd65013483ede935677e84560da3b848f253596", size = 189210, upload-time = "2025-10-14T16:32:57.759Z" },
{ url = "https://files.pythonhosted.org/packages/f0/de/8c9a653922057b32fb1e2546ecd43ef44c9aa1a7cf460c87cae507eb2bc7/hiredis-3.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd8c438d9e1728f0085bf9b3c9484d19ec31f41002311464e75b69550c32ffa8", size = 180972, upload-time = "2025-10-14T16:32:58.737Z" },
{ url = "https://files.pythonhosted.org/packages/e4/a3/51e6e6afaef2990986d685ca6e254ffbd191f1635a59b2d06c9e5d10c8a2/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1bbc6b8a88bbe331e3ebf6685452cebca6dfe6d38a6d4efc5651d7e363ba28bd", size = 175315, upload-time = "2025-10-14T16:32:59.774Z" },
{ url = "https://files.pythonhosted.org/packages/96/54/e436312feb97601f70f8b39263b8da5ac4a5d18305ebdfb08ad7621f6119/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:55d8c18fe9a05496c5c04e6eccc695169d89bf358dff964bcad95696958ec05f", size = 185653, upload-time = "2025-10-14T16:33:00.749Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a3/88e66030d066337c6c0f883a912c6d4b2d6d7173490fbbc113a6cbe414ff/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4ddc79afa76b805d364e202a754666cb3c4d9c85153cbfed522871ff55827838", size = 179032, upload-time = "2025-10-14T16:33:01.711Z" },
{ url = "https://files.pythonhosted.org/packages/bc/1f/fb7375467e9adaa371cd617c2984fefe44bdce73add4c70b8dd8cab1b33a/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e8a4b8540581dcd1b2b25827a54cfd538e0afeaa1a0e3ca87ad7126965981cc", size = 176127, upload-time = "2025-10-14T16:33:02.793Z" },
{ url = "https://files.pythonhosted.org/packages/66/14/0dc2b99209c400f3b8f24067273e9c3cb383d894e155830879108fb19e98/hiredis-3.3.0-cp314-cp314t-win32.whl", hash = "sha256:298593bb08487753b3afe6dc38bac2532e9bac8dcee8d992ef9977d539cc6776", size = 22024, upload-time = "2025-10-14T16:33:03.812Z" },
{ url = "https://files.pythonhosted.org/packages/b2/2f/8a0befeed8bbe142d5a6cf3b51e8cbe019c32a64a596b0ebcbc007a8f8f1/hiredis-3.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b442b6ab038a6f3b5109874d2514c4edf389d8d8b553f10f12654548808683bc", size = 23808, upload-time = "2025-10-14T16:33:04.965Z" },
]
[[package]]
name = "hpack"
version = "4.1.0"
@@ -696,6 +804,15 @@ http2 = [
{ name = "h2" },
]
[[package]]
name = "httpx-sse"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
@@ -714,6 +831,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "importlib-metadata"
version = "8.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" },
]
[[package]]
name = "isodate"
version = "0.7.2"
@@ -812,6 +941,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" },
]
[[package]]
name = "jsonschema"
version = "4.25.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "landingai-ade"
version = "0.20.3"
@@ -947,6 +1103,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/4c/6c0c338ca7182e4ecb7af61049415e7b3513cc6cea9aa5bf8ca508f53539/langsmith-0.4.41-py3-none-any.whl", hash = "sha256:5cdc554e5f0361bf791fdd5e8dea16d5ba9dfce09b3b8f8bba5e99450c569b27", size = 399279, upload-time = "2025-11-04T22:31:30.268Z" },
]
[[package]]
name = "logfire-api"
version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/59/25/6072086af3b3ac5c2c2f2a6cf89488a1b228ffc6ee0fb357ed1e227efd13/logfire_api-4.14.2.tar.gz", hash = "sha256:bbdeccd931069b76ab811261b41bc52d8b78d1c045fc4b4237dbc085e0fb9bcd", size = 57604, upload-time = "2025-10-24T20:14:40.551Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/c7/b06a83df678fca882c24fb498e628e0406bdb95ffdfa7ae43ecc0a714d52/logfire_api-4.14.2-py3-none-any.whl", hash = "sha256:aa4af2ecb007c3e0095e25ba4526fd8c0e2c0be2ceceac71ca651c4ad86dc713", size = 95021, upload-time = "2025-10-24T20:14:36.161Z" },
]
[[package]]
name = "mcp"
version = "1.21.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/54/dd2330ef4611c27ae59124820863c34e1d3edb1133c58e6375e2d938c9c5/mcp-1.21.0.tar.gz", hash = "sha256:bab0a38e8f8c48080d787233343f8d301b0e1e95846ae7dead251b2421d99855", size = 452697, upload-time = "2025-11-06T23:19:58.432Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/47/850b6edc96c03bd44b00de9a0ca3c1cc71e0ba1cd5822955bc9e4eb3fad3/mcp-1.21.0-py3-none-any.whl", hash = "sha256:598619e53eb0b7a6513db38c426b28a4bdf57496fed04332100d2c56acade98b", size = 173672, upload-time = "2025-11-06T23:19:56.508Z" },
]
[[package]]
name = "more-itertools"
version = "10.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
]
[[package]]
name = "numpy"
version = "2.3.2"
@@ -1029,6 +1226,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/74/6bfc3adc81f6c2cea4439f2a734c40e3a420703bbcdc539890096a732bbd/openai-2.7.1-py3-none-any.whl", hash = "sha256:2f2530354d94c59c614645a4662b9dab0a5b881c5cd767a8587398feac0c9021", size = 1008780, upload-time = "2025-11-04T06:07:20.818Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/d8/0f354c375628e048bd0570645b310797299754730079853095bf000fba69/opentelemetry_api-1.38.0.tar.gz", hash = "sha256:f4c193b5e8acb0912b06ac5b16321908dd0843d75049c091487322284a3eea12", size = 65242, upload-time = "2025-10-16T08:35:50.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/a2/d86e01c28300bd41bab8f18afd613676e2bd63515417b77636fc1add426f/opentelemetry_api-1.38.0-py3-none-any.whl", hash = "sha256:2891b0197f47124454ab9f0cf58f3be33faca394457ac3e09daba13ff50aa582", size = 65947, upload-time = "2025-10-16T08:35:30.23Z" },
]
[[package]]
name = "orjson"
version = "3.11.4"
@@ -1293,6 +1503,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
]
[[package]]
name = "pydantic-ai-slim"
version = "1.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "genai-prices" },
{ name = "griffe" },
{ name = "httpx" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
{ name = "pydantic-graph" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/a5/fbfcdd3c89549dd44417606af0130f1118aea8e43f4d14723e49218901a6/pydantic_ai_slim-1.11.1.tar.gz", hash = "sha256:242fb5c7a0f812d540f68d4e2e6498730ef11644b55ccf3da38bf9767802f742", size = 298765, upload-time = "2025-11-06T00:48:42.815Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/6d/d8ea48afdd8838d6419cdbc08d81753e2e732ff3451e3d83f6b4b56388af/pydantic_ai_slim-1.11.1-py3-none-any.whl", hash = "sha256:00ca8b0a8f677fa9efd077239b66c925423d1dc517dfac7953b62547a66adbf2", size = 397971, upload-time = "2025-11-06T00:48:28.219Z" },
]
[package.optional-dependencies]
google = [
{ name = "google-genai" },
]
mcp = [
{ name = "mcp" },
]
openai = [
{ name = "openai" },
]
[[package]]
name = "pydantic-core"
version = "2.33.2"
@@ -1335,6 +1574,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
]
[[package]]
name = "pydantic-graph"
version = "1.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "logfire-api" },
{ name = "pydantic" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6f/b6/1b37a9517bc71fde33184cc6f3f03795c3669b7be5a143a3012fb112742d/pydantic_graph-1.11.1.tar.gz", hash = "sha256:345d6309ac677ef6cf2f5b225e6762afd9b87cc916b943376a5cb555705a7f2b", size = 57964, upload-time = "2025-11-06T00:48:45.028Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/64/934e1f9be64f44515c501bf528cfc2dd672516530d5a7aa7436f72aba5ef/pydantic_graph-1.11.1-py3-none-any.whl", hash = "sha256:4d52d0c925672439e407d64e663a5e7f011f0bb0941c8b6476911044c7478cd6", size = 72002, upload-time = "2025-11-06T00:48:32.411Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.10.1"
@@ -1349,6 +1603,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" },
]
[[package]]
name = "pyjwt"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pypdf"
version = "6.1.3"
@@ -1388,6 +1656,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
]
[[package]]
name = "python-ulid"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/8b/0580d8ee0a73a3f3869488856737c429cbaa08b63c3506275f383c4771a8/python-ulid-1.1.0.tar.gz", hash = "sha256:5fb5e4a91db8ca93e8938a613360b3def299b60d41f847279a8c39c9b2e9c65e", size = 19992, upload-time = "2022-03-10T15:11:41.968Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/8e/c30b08ee9b8dc9b4a10e782c2a7fd5de55388201ddebfe0f7ab99dfbb349/python_ulid-1.1.0-py3-none-any.whl", hash = "sha256:88c952f6be133dbede19c907d72d26717d2691ec8421512b573144794d891e24", size = 9360, upload-time = "2022-03-10T15:11:40.405Z" },
]
[[package]]
name = "pywin32"
version = "311"
@@ -1449,6 +1726,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/33/d8df6a2b214ffbe4138db9a1efe3248f67dc3c671f82308bea1582ecbbb7/qdrant_client-1.15.1-py3-none-any.whl", hash = "sha256:2b975099b378382f6ca1cfb43f0d59e541be6e16a5892f282a4b8de7eff5cb63", size = 337331, upload-time = "2025-07-31T19:35:17.539Z" },
]
[[package]]
name = "redis"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyjwt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/cf/128b1b6d7086200c9f387bd4be9b2572a30b90745ef078bd8b235042dc9f/redis-5.3.1.tar.gz", hash = "sha256:ca49577a531ea64039b5a36db3d6cd1a0c7a60c34124d46924a45b956e8cf14c", size = 4626200, upload-time = "2025-07-25T08:06:27.778Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/26/5c5fa0e83c3621db835cfc1f1d789b37e7fa99ed54423b5f519beb931aa7/redis-5.3.1-py3-none-any.whl", hash = "sha256:dc1909bd24669cc31b5f67a039700b16ec30571096c5f1f0d9d2324bff31af97", size = 272833, upload-time = "2025-07-25T08:06:26.317Z" },
]
[[package]]
name = "redis-om"
version = "0.3.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "hiredis" },
{ name = "more-itertools" },
{ name = "pydantic" },
{ name = "python-ulid" },
{ name = "redis" },
{ name = "setuptools" },
{ name = "types-redis" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/11/32/9bdcb86b88f5b53fd9f80019a62970ded91e4befb65c03fee17bdb2bc9f0/redis_om-0.3.5.tar.gz", hash = "sha256:fd152ccebc9b47604287a347628ef0d2c0051c13d5653f121193e801bb1cc4a7", size = 78939, upload-time = "2025-04-04T12:54:51.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/60/2cc6753c2c36a2a5dded8c380c6cad67a26c5878cd7aad56de2eee1d63c8/redis_om-0.3.5-py3-none-any.whl", hash = "sha256:99ab40f696028ce47c5e2eb5118a1ffc1fd193005428df89c8cf77ad35a0177a", size = 86634, upload-time = "2025-04-04T12:54:50.07Z" },
]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "regex"
version = "2025.11.3"
@@ -1554,6 +1877,87 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
]
[[package]]
name = "rpds-py"
version = "0.28.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/48/dc/95f074d43452b3ef5d06276696ece4b3b5d696e7c9ad7173c54b1390cd70/rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea", size = 27419, upload-time = "2025-10-22T22:24:29.327Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/5c/6c3936495003875fe7b14f90ea812841a08fca50ab26bd840e924097d9c8/rpds_py-0.28.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6b4f28583a4f247ff60cd7bdda83db8c3f5b05a7a82ff20dd4b078571747708f", size = 366439, upload-time = "2025-10-22T22:22:04.525Z" },
{ url = "https://files.pythonhosted.org/packages/56/f9/a0f1ca194c50aa29895b442771f036a25b6c41a35e4f35b1a0ea713bedae/rpds_py-0.28.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d678e91b610c29c4b3d52a2c148b641df2b4676ffe47c59f6388d58b99cdc424", size = 348170, upload-time = "2025-10-22T22:22:06.397Z" },
{ url = "https://files.pythonhosted.org/packages/18/ea/42d243d3a586beb72c77fa5def0487daf827210069a95f36328e869599ea/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e819e0e37a44a78e1383bf1970076e2ccc4dc8c2bbaa2f9bd1dc987e9afff628", size = 378838, upload-time = "2025-10-22T22:22:07.932Z" },
{ url = "https://files.pythonhosted.org/packages/e7/78/3de32e18a94791af8f33601402d9d4f39613136398658412a4e0b3047327/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5ee514e0f0523db5d3fb171f397c54875dbbd69760a414dccf9d4d7ad628b5bd", size = 393299, upload-time = "2025-10-22T22:22:09.435Z" },
{ url = "https://files.pythonhosted.org/packages/13/7e/4bdb435afb18acea2eb8a25ad56b956f28de7c59f8a1d32827effa0d4514/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3fa06d27fdcee47f07a39e02862da0100cb4982508f5ead53ec533cd5fe55e", size = 518000, upload-time = "2025-10-22T22:22:11.326Z" },
{ url = "https://files.pythonhosted.org/packages/31/d0/5f52a656875cdc60498ab035a7a0ac8f399890cc1ee73ebd567bac4e39ae/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46959ef2e64f9e4a41fc89aa20dbca2b85531f9a72c21099a3360f35d10b0d5a", size = 408746, upload-time = "2025-10-22T22:22:13.143Z" },
{ url = "https://files.pythonhosted.org/packages/3e/cd/49ce51767b879cde77e7ad9fae164ea15dce3616fe591d9ea1df51152706/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8455933b4bcd6e83fde3fefc987a023389c4b13f9a58c8d23e4b3f6d13f78c84", size = 386379, upload-time = "2025-10-22T22:22:14.602Z" },
{ url = "https://files.pythonhosted.org/packages/6a/99/e4e1e1ee93a98f72fc450e36c0e4d99c35370220e815288e3ecd2ec36a2a/rpds_py-0.28.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ad50614a02c8c2962feebe6012b52f9802deec4263946cddea37aaf28dd25a66", size = 401280, upload-time = "2025-10-22T22:22:16.063Z" },
{ url = "https://files.pythonhosted.org/packages/61/35/e0c6a57488392a8b319d2200d03dad2b29c0db9996f5662c3b02d0b86c02/rpds_py-0.28.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e5deca01b271492553fdb6c7fd974659dce736a15bae5dad7ab8b93555bceb28", size = 412365, upload-time = "2025-10-22T22:22:17.504Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6a/841337980ea253ec797eb084665436007a1aad0faac1ba097fb906c5f69c/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:735f8495a13159ce6a0d533f01e8674cec0c57038c920495f87dcb20b3ddb48a", size = 559573, upload-time = "2025-10-22T22:22:19.108Z" },
{ url = "https://files.pythonhosted.org/packages/e7/5e/64826ec58afd4c489731f8b00729c5f6afdb86f1df1df60bfede55d650bb/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:961ca621ff10d198bbe6ba4957decca61aa2a0c56695384c1d6b79bf61436df5", size = 583973, upload-time = "2025-10-22T22:22:20.768Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ee/44d024b4843f8386a4eeaa4c171b3d31d55f7177c415545fd1a24c249b5d/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2374e16cc9131022e7d9a8f8d65d261d9ba55048c78f3b6e017971a4f5e6353c", size = 553800, upload-time = "2025-10-22T22:22:22.25Z" },
{ url = "https://files.pythonhosted.org/packages/7d/89/33e675dccff11a06d4d85dbb4d1865f878d5020cbb69b2c1e7b2d3f82562/rpds_py-0.28.0-cp312-cp312-win32.whl", hash = "sha256:d15431e334fba488b081d47f30f091e5d03c18527c325386091f31718952fe08", size = 216954, upload-time = "2025-10-22T22:22:24.105Z" },
{ url = "https://files.pythonhosted.org/packages/af/36/45f6ebb3210887e8ee6dbf1bc710ae8400bb417ce165aaf3024b8360d999/rpds_py-0.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:a410542d61fc54710f750d3764380b53bf09e8c4edbf2f9141a82aa774a04f7c", size = 227844, upload-time = "2025-10-22T22:22:25.551Z" },
{ url = "https://files.pythonhosted.org/packages/57/91/f3fb250d7e73de71080f9a221d19bd6a1c1eb0d12a1ea26513f6c1052ad6/rpds_py-0.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:1f0cfd1c69e2d14f8c892b893997fa9a60d890a0c8a603e88dca4955f26d1edd", size = 217624, upload-time = "2025-10-22T22:22:26.914Z" },
{ url = "https://files.pythonhosted.org/packages/d3/03/ce566d92611dfac0085c2f4b048cd53ed7c274a5c05974b882a908d540a2/rpds_py-0.28.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e9e184408a0297086f880556b6168fa927d677716f83d3472ea333b42171ee3b", size = 366235, upload-time = "2025-10-22T22:22:28.397Z" },
{ url = "https://files.pythonhosted.org/packages/00/34/1c61da1b25592b86fd285bd7bd8422f4c9d748a7373b46126f9ae792a004/rpds_py-0.28.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:edd267266a9b0448f33dc465a97cfc5d467594b600fe28e7fa2f36450e03053a", size = 348241, upload-time = "2025-10-22T22:22:30.171Z" },
{ url = "https://files.pythonhosted.org/packages/fc/00/ed1e28616848c61c493a067779633ebf4b569eccaacf9ccbdc0e7cba2b9d/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85beb8b3f45e4e32f6802fb6cd6b17f615ef6c6a52f265371fb916fae02814aa", size = 378079, upload-time = "2025-10-22T22:22:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/11/b2/ccb30333a16a470091b6e50289adb4d3ec656fd9951ba8c5e3aaa0746a67/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2412be8d00a1b895f8ad827cc2116455196e20ed994bb704bf138fe91a42724", size = 393151, upload-time = "2025-10-22T22:22:33.453Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d0/73e2217c3ee486d555cb84920597480627d8c0240ff3062005c6cc47773e/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf128350d384b777da0e68796afdcebc2e9f63f0e9f242217754e647f6d32491", size = 517520, upload-time = "2025-10-22T22:22:34.949Z" },
{ url = "https://files.pythonhosted.org/packages/c4/91/23efe81c700427d0841a4ae7ea23e305654381831e6029499fe80be8a071/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2036d09b363aa36695d1cc1a97b36865597f4478470b0697b5ee9403f4fe399", size = 408699, upload-time = "2025-10-22T22:22:36.584Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ee/a324d3198da151820a326c1f988caaa4f37fc27955148a76fff7a2d787a9/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8e1e9be4fa6305a16be628959188e4fd5cd6f1b0e724d63c6d8b2a8adf74ea6", size = 385720, upload-time = "2025-10-22T22:22:38.014Z" },
{ url = "https://files.pythonhosted.org/packages/19/ad/e68120dc05af8b7cab4a789fccd8cdcf0fe7e6581461038cc5c164cd97d2/rpds_py-0.28.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0a403460c9dd91a7f23fc3188de6d8977f1d9603a351d5db6cf20aaea95b538d", size = 401096, upload-time = "2025-10-22T22:22:39.869Z" },
{ url = "https://files.pythonhosted.org/packages/99/90/c1e070620042459d60df6356b666bb1f62198a89d68881816a7ed121595a/rpds_py-0.28.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d7366b6553cdc805abcc512b849a519167db8f5e5c3472010cd1228b224265cb", size = 411465, upload-time = "2025-10-22T22:22:41.395Z" },
{ url = "https://files.pythonhosted.org/packages/68/61/7c195b30d57f1b8d5970f600efee72a4fad79ec829057972e13a0370fd24/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b43c6a3726efd50f18d8120ec0551241c38785b68952d240c45ea553912ac41", size = 558832, upload-time = "2025-10-22T22:22:42.871Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3d/06f3a718864773f69941d4deccdf18e5e47dd298b4628062f004c10f3b34/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0cb7203c7bc69d7c1585ebb33a2e6074492d2fc21ad28a7b9d40457ac2a51ab7", size = 583230, upload-time = "2025-10-22T22:22:44.877Z" },
{ url = "https://files.pythonhosted.org/packages/66/df/62fc783781a121e77fee9a21ead0a926f1b652280a33f5956a5e7833ed30/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a52a5169c664dfb495882adc75c304ae1d50df552fbd68e100fdc719dee4ff9", size = 553268, upload-time = "2025-10-22T22:22:46.441Z" },
{ url = "https://files.pythonhosted.org/packages/84/85/d34366e335140a4837902d3dea89b51f087bd6a63c993ebdff59e93ee61d/rpds_py-0.28.0-cp313-cp313-win32.whl", hash = "sha256:2e42456917b6687215b3e606ab46aa6bca040c77af7df9a08a6dcfe8a4d10ca5", size = 217100, upload-time = "2025-10-22T22:22:48.342Z" },
{ url = "https://files.pythonhosted.org/packages/3c/1c/f25a3f3752ad7601476e3eff395fe075e0f7813fbb9862bd67c82440e880/rpds_py-0.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:e0a0311caedc8069d68fc2bf4c9019b58a2d5ce3cd7cb656c845f1615b577e1e", size = 227759, upload-time = "2025-10-22T22:22:50.219Z" },
{ url = "https://files.pythonhosted.org/packages/e0/d6/5f39b42b99615b5bc2f36ab90423ea404830bdfee1c706820943e9a645eb/rpds_py-0.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:04c1b207ab8b581108801528d59ad80aa83bb170b35b0ddffb29c20e411acdc1", size = 217326, upload-time = "2025-10-22T22:22:51.647Z" },
{ url = "https://files.pythonhosted.org/packages/5c/8b/0c69b72d1cee20a63db534be0df271effe715ef6c744fdf1ff23bb2b0b1c/rpds_py-0.28.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f296ea3054e11fc58ad42e850e8b75c62d9a93a9f981ad04b2e5ae7d2186ff9c", size = 355736, upload-time = "2025-10-22T22:22:53.211Z" },
{ url = "https://files.pythonhosted.org/packages/f7/6d/0c2ee773cfb55c31a8514d2cece856dd299170a49babd50dcffb15ddc749/rpds_py-0.28.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5a7306c19b19005ad98468fcefeb7100b19c79fc23a5f24a12e06d91181193fa", size = 342677, upload-time = "2025-10-22T22:22:54.723Z" },
{ url = "https://files.pythonhosted.org/packages/e2/1c/22513ab25a27ea205144414724743e305e8153e6abe81833b5e678650f5a/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5d9b86aa501fed9862a443c5c3116f6ead8bc9296185f369277c42542bd646b", size = 371847, upload-time = "2025-10-22T22:22:56.295Z" },
{ url = "https://files.pythonhosted.org/packages/60/07/68e6ccdb4b05115ffe61d31afc94adef1833d3a72f76c9632d4d90d67954/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e5bbc701eff140ba0e872691d573b3d5d30059ea26e5785acba9132d10c8c31d", size = 381800, upload-time = "2025-10-22T22:22:57.808Z" },
{ url = "https://files.pythonhosted.org/packages/73/bf/6d6d15df80781d7f9f368e7c1a00caf764436518c4877fb28b029c4624af/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5690671cd672a45aa8616d7374fdf334a1b9c04a0cac3c854b1136e92374fe", size = 518827, upload-time = "2025-10-22T22:22:59.826Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d3/2decbb2976cc452cbf12a2b0aaac5f1b9dc5dd9d1f7e2509a3ee00421249/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f1d92ecea4fa12f978a367c32a5375a1982834649cdb96539dcdc12e609ab1a", size = 399471, upload-time = "2025-10-22T22:23:01.968Z" },
{ url = "https://files.pythonhosted.org/packages/b1/2c/f30892f9e54bd02e5faca3f6a26d6933c51055e67d54818af90abed9748e/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d252db6b1a78d0a3928b6190156042d54c93660ce4d98290d7b16b5296fb7cc", size = 377578, upload-time = "2025-10-22T22:23:03.52Z" },
{ url = "https://files.pythonhosted.org/packages/f0/5d/3bce97e5534157318f29ac06bf2d279dae2674ec12f7cb9c12739cee64d8/rpds_py-0.28.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d61b355c3275acb825f8777d6c4505f42b5007e357af500939d4a35b19177259", size = 390482, upload-time = "2025-10-22T22:23:05.391Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f0/886bd515ed457b5bd93b166175edb80a0b21a210c10e993392127f1e3931/rpds_py-0.28.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:acbe5e8b1026c0c580d0321c8aae4b0a1e1676861d48d6e8c6586625055b606a", size = 402447, upload-time = "2025-10-22T22:23:06.93Z" },
{ url = "https://files.pythonhosted.org/packages/42/b5/71e8777ac55e6af1f4f1c05b47542a1eaa6c33c1cf0d300dca6a1c6e159a/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8aa23b6f0fc59b85b4c7d89ba2965af274346f738e8d9fc2455763602e62fd5f", size = 552385, upload-time = "2025-10-22T22:23:08.557Z" },
{ url = "https://files.pythonhosted.org/packages/5d/cb/6ca2d70cbda5a8e36605e7788c4aa3bea7c17d71d213465a5a675079b98d/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7b14b0c680286958817c22d76fcbca4800ddacef6f678f3a7c79a1fe7067fe37", size = 575642, upload-time = "2025-10-22T22:23:10.348Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d4/407ad9960ca7856d7b25c96dcbe019270b5ffdd83a561787bc682c797086/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bcf1d210dfee61a6c86551d67ee1031899c0fdbae88b2d44a569995d43797712", size = 544507, upload-time = "2025-10-22T22:23:12.434Z" },
{ url = "https://files.pythonhosted.org/packages/51/31/2f46fe0efcac23fbf5797c6b6b7e1c76f7d60773e525cb65fcbc582ee0f2/rpds_py-0.28.0-cp313-cp313t-win32.whl", hash = "sha256:3aa4dc0fdab4a7029ac63959a3ccf4ed605fee048ba67ce89ca3168da34a1342", size = 205376, upload-time = "2025-10-22T22:23:13.979Z" },
{ url = "https://files.pythonhosted.org/packages/92/e4/15947bda33cbedfc134490a41841ab8870a72a867a03d4969d886f6594a2/rpds_py-0.28.0-cp313-cp313t-win_amd64.whl", hash = "sha256:7b7d9d83c942855e4fdcfa75d4f96f6b9e272d42fffcb72cd4bb2577db2e2907", size = 215907, upload-time = "2025-10-22T22:23:15.5Z" },
{ url = "https://files.pythonhosted.org/packages/08/47/ffe8cd7a6a02833b10623bf765fbb57ce977e9a4318ca0e8cf97e9c3d2b3/rpds_py-0.28.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:dcdcb890b3ada98a03f9f2bb108489cdc7580176cb73b4f2d789e9a1dac1d472", size = 353830, upload-time = "2025-10-22T22:23:17.03Z" },
{ url = "https://files.pythonhosted.org/packages/f9/9f/890f36cbd83a58491d0d91ae0db1702639edb33fb48eeb356f80ecc6b000/rpds_py-0.28.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f274f56a926ba2dc02976ca5b11c32855cbd5925534e57cfe1fda64e04d1add2", size = 341819, upload-time = "2025-10-22T22:23:18.57Z" },
{ url = "https://files.pythonhosted.org/packages/09/e3/921eb109f682aa24fb76207698fbbcf9418738f35a40c21652c29053f23d/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fe0438ac4a29a520ea94c8c7f1754cdd8feb1bc490dfda1bfd990072363d527", size = 373127, upload-time = "2025-10-22T22:23:20.216Z" },
{ url = "https://files.pythonhosted.org/packages/23/13/bce4384d9f8f4989f1a9599c71b7a2d877462e5fd7175e1f69b398f729f4/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a358a32dd3ae50e933347889b6af9a1bdf207ba5d1a3f34e1a38cd3540e6733", size = 382767, upload-time = "2025-10-22T22:23:21.787Z" },
{ url = "https://files.pythonhosted.org/packages/23/e1/579512b2d89a77c64ccef5a0bc46a6ef7f72ae0cf03d4b26dcd52e57ee0a/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e80848a71c78aa328fefaba9c244d588a342c8e03bda518447b624ea64d1ff56", size = 517585, upload-time = "2025-10-22T22:23:23.699Z" },
{ url = "https://files.pythonhosted.org/packages/62/3c/ca704b8d324a2591b0b0adcfcaadf9c862375b11f2f667ac03c61b4fd0a6/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f586db2e209d54fe177e58e0bc4946bea5fb0102f150b1b2f13de03e1f0976f8", size = 399828, upload-time = "2025-10-22T22:23:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/da/37/e84283b9e897e3adc46b4c88bb3f6ec92a43bd4d2f7ef5b13459963b2e9c/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ae8ee156d6b586e4292491e885d41483136ab994e719a13458055bec14cf370", size = 375509, upload-time = "2025-10-22T22:23:27.32Z" },
{ url = "https://files.pythonhosted.org/packages/1a/c2/a980beab869d86258bf76ec42dec778ba98151f253a952b02fe36d72b29c/rpds_py-0.28.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a805e9b3973f7e27f7cab63a6b4f61d90f2e5557cff73b6e97cd5b8540276d3d", size = 392014, upload-time = "2025-10-22T22:23:29.332Z" },
{ url = "https://files.pythonhosted.org/packages/da/b5/b1d3c5f9d3fa5aeef74265f9c64de3c34a0d6d5cd3c81c8b17d5c8f10ed4/rpds_py-0.28.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5d3fd16b6dc89c73a4da0b4ac8b12a7ecc75b2864b95c9e5afed8003cb50a728", size = 402410, upload-time = "2025-10-22T22:23:31.14Z" },
{ url = "https://files.pythonhosted.org/packages/74/ae/cab05ff08dfcc052afc73dcb38cbc765ffc86f94e966f3924cd17492293c/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6796079e5d24fdaba6d49bda28e2c47347e89834678f2bc2c1b4fc1489c0fb01", size = 553593, upload-time = "2025-10-22T22:23:32.834Z" },
{ url = "https://files.pythonhosted.org/packages/70/80/50d5706ea2a9bfc9e9c5f401d91879e7c790c619969369800cde202da214/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:76500820c2af232435cbe215e3324c75b950a027134e044423f59f5b9a1ba515", size = 576925, upload-time = "2025-10-22T22:23:34.47Z" },
{ url = "https://files.pythonhosted.org/packages/ab/12/85a57d7a5855a3b188d024b099fd09c90db55d32a03626d0ed16352413ff/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bbdc5640900a7dbf9dd707fe6388972f5bbd883633eb68b76591044cfe346f7e", size = 542444, upload-time = "2025-10-22T22:23:36.093Z" },
{ url = "https://files.pythonhosted.org/packages/6c/65/10643fb50179509150eb94d558e8837c57ca8b9adc04bd07b98e57b48f8c/rpds_py-0.28.0-cp314-cp314-win32.whl", hash = "sha256:adc8aa88486857d2b35d75f0640b949759f79dc105f50aa2c27816b2e0dd749f", size = 207968, upload-time = "2025-10-22T22:23:37.638Z" },
{ url = "https://files.pythonhosted.org/packages/b4/84/0c11fe4d9aaea784ff4652499e365963222481ac647bcd0251c88af646eb/rpds_py-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:66e6fa8e075b58946e76a78e69e1a124a21d9a48a5b4766d15ba5b06869d1fa1", size = 218876, upload-time = "2025-10-22T22:23:39.179Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e0/3ab3b86ded7bb18478392dc3e835f7b754cd446f62f3fc96f4fe2aca78f6/rpds_py-0.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6fe887c2c5c59413353b7c0caff25d0e566623501ccfff88957fa438a69377d", size = 212506, upload-time = "2025-10-22T22:23:40.755Z" },
{ url = "https://files.pythonhosted.org/packages/51/ec/d5681bb425226c3501eab50fc30e9d275de20c131869322c8a1729c7b61c/rpds_py-0.28.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7a69df082db13c7070f7b8b1f155fa9e687f1d6aefb7b0e3f7231653b79a067b", size = 355433, upload-time = "2025-10-22T22:23:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/be/ec/568c5e689e1cfb1ea8b875cffea3649260955f677fdd7ddc6176902d04cd/rpds_py-0.28.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1cde22f2c30ebb049a9e74c5374994157b9b70a16147d332f89c99c5960737a", size = 342601, upload-time = "2025-10-22T22:23:44.372Z" },
{ url = "https://files.pythonhosted.org/packages/32/fe/51ada84d1d2a1d9d8f2c902cfddd0133b4a5eb543196ab5161d1c07ed2ad/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5338742f6ba7a51012ea470bd4dc600a8c713c0c72adaa0977a1b1f4327d6592", size = 372039, upload-time = "2025-10-22T22:23:46.025Z" },
{ url = "https://files.pythonhosted.org/packages/07/c1/60144a2f2620abade1a78e0d91b298ac2d9b91bc08864493fa00451ef06e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1460ebde1bcf6d496d80b191d854adedcc619f84ff17dc1c6d550f58c9efbba", size = 382407, upload-time = "2025-10-22T22:23:48.098Z" },
{ url = "https://files.pythonhosted.org/packages/45/ed/091a7bbdcf4038a60a461df50bc4c82a7ed6d5d5e27649aab61771c17585/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3eb248f2feba84c692579257a043a7699e28a77d86c77b032c1d9fbb3f0219c", size = 518172, upload-time = "2025-10-22T22:23:50.16Z" },
{ url = "https://files.pythonhosted.org/packages/54/dd/02cc90c2fd9c2ef8016fd7813bfacd1c3a1325633ec8f244c47b449fc868/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3bbba5def70b16cd1c1d7255666aad3b290fbf8d0fe7f9f91abafb73611a91", size = 399020, upload-time = "2025-10-22T22:23:51.81Z" },
{ url = "https://files.pythonhosted.org/packages/ab/81/5d98cc0329bbb911ccecd0b9e19fbf7f3a5de8094b4cda5e71013b2dd77e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3114f4db69ac5a1f32e7e4d1cbbe7c8f9cf8217f78e6e002cedf2d54c2a548ed", size = 377451, upload-time = "2025-10-22T22:23:53.711Z" },
{ url = "https://files.pythonhosted.org/packages/b4/07/4d5bcd49e3dfed2d38e2dcb49ab6615f2ceb9f89f5a372c46dbdebb4e028/rpds_py-0.28.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4b0cb8a906b1a0196b863d460c0222fb8ad0f34041568da5620f9799b83ccf0b", size = 390355, upload-time = "2025-10-22T22:23:55.299Z" },
{ url = "https://files.pythonhosted.org/packages/3f/79/9f14ba9010fee74e4f40bf578735cfcbb91d2e642ffd1abe429bb0b96364/rpds_py-0.28.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf681ac76a60b667106141e11a92a3330890257e6f559ca995fbb5265160b56e", size = 403146, upload-time = "2025-10-22T22:23:56.929Z" },
{ url = "https://files.pythonhosted.org/packages/39/4c/f08283a82ac141331a83a40652830edd3a4a92c34e07e2bbe00baaea2f5f/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e8ee6413cfc677ce8898d9cde18cc3a60fc2ba756b0dec5b71eb6eb21c49fa1", size = 552656, upload-time = "2025-10-22T22:23:58.62Z" },
{ url = "https://files.pythonhosted.org/packages/61/47/d922fc0666f0dd8e40c33990d055f4cc6ecff6f502c2d01569dbed830f9b/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3072b16904d0b5572a15eb9d31c1954e0d3227a585fc1351aa9878729099d6c", size = 576782, upload-time = "2025-10-22T22:24:00.312Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0c/5bafdd8ccf6aa9d3bfc630cfece457ff5b581af24f46a9f3590f790e3df2/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b670c30fd87a6aec281c3c9896d3bae4b205fd75d79d06dc87c2503717e46092", size = 544671, upload-time = "2025-10-22T22:24:02.297Z" },
{ url = "https://files.pythonhosted.org/packages/2c/37/dcc5d8397caa924988693519069d0beea077a866128719351a4ad95e82fc/rpds_py-0.28.0-cp314-cp314t-win32.whl", hash = "sha256:8014045a15b4d2b3476f0a287fcc93d4f823472d7d1308d47884ecac9e612be3", size = 205749, upload-time = "2025-10-22T22:24:03.848Z" },
{ url = "https://files.pythonhosted.org/packages/d7/69/64d43b21a10d72b45939a28961216baeb721cc2a430f5f7c3bfa21659a53/rpds_py-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7a4e59c90d9c27c561eb3160323634a9ff50b04e4f7820600a2beb0ac90db578", size = 216233, upload-time = "2025-10-22T22:24:05.471Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
@@ -1566,6 +1970,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]]
name = "ruff"
version = "0.14.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/55/cccfca45157a2031dcbb5a462a67f7cf27f8b37d4b3b1cd7438f0f5c1df6/ruff-0.14.4.tar.gz", hash = "sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3", size = 5587844, upload-time = "2025-11-06T22:07:45.033Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/b9/67240254166ae1eaa38dec32265e9153ac53645a6c6670ed36ad00722af8/ruff-0.14.4-py3-none-linux_armv6l.whl", hash = "sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518", size = 12606781, upload-time = "2025-11-06T22:07:01.841Z" },
{ url = "https://files.pythonhosted.org/packages/46/c8/09b3ab245d8652eafe5256ab59718641429f68681ee713ff06c5c549f156/ruff-0.14.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4", size = 12946765, upload-time = "2025-11-06T22:07:05.858Z" },
{ url = "https://files.pythonhosted.org/packages/14/bb/1564b000219144bf5eed2359edc94c3590dd49d510751dad26202c18a17d/ruff-0.14.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33", size = 11928120, upload-time = "2025-11-06T22:07:08.023Z" },
{ url = "https://files.pythonhosted.org/packages/a3/92/d5f1770e9988cc0742fefaa351e840d9aef04ec24ae1be36f333f96d5704/ruff-0.14.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2", size = 12370877, upload-time = "2025-11-06T22:07:10.015Z" },
{ url = "https://files.pythonhosted.org/packages/e2/29/e9282efa55f1973d109faf839a63235575519c8ad278cc87a182a366810e/ruff-0.14.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5", size = 12408538, upload-time = "2025-11-06T22:07:13.085Z" },
{ url = "https://files.pythonhosted.org/packages/8e/01/930ed6ecfce130144b32d77d8d69f5c610e6d23e6857927150adf5d7379a/ruff-0.14.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e", size = 13141942, upload-time = "2025-11-06T22:07:15.386Z" },
{ url = "https://files.pythonhosted.org/packages/6a/46/a9c89b42b231a9f487233f17a89cbef9d5acd538d9488687a02ad288fa6b/ruff-0.14.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8", size = 14544306, upload-time = "2025-11-06T22:07:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/78/96/9c6cf86491f2a6d52758b830b89b78c2ae61e8ca66b86bf5a20af73d20e6/ruff-0.14.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649", size = 14210427, upload-time = "2025-11-06T22:07:19.832Z" },
{ url = "https://files.pythonhosted.org/packages/71/f4/0666fe7769a54f63e66404e8ff698de1dcde733e12e2fd1c9c6efb689cb5/ruff-0.14.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850", size = 13658488, upload-time = "2025-11-06T22:07:22.32Z" },
{ url = "https://files.pythonhosted.org/packages/ee/79/6ad4dda2cfd55e41ac9ed6d73ef9ab9475b1eef69f3a85957210c74ba12c/ruff-0.14.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5", size = 13354908, upload-time = "2025-11-06T22:07:24.347Z" },
{ url = "https://files.pythonhosted.org/packages/b5/60/f0b6990f740bb15c1588601d19d21bcc1bd5de4330a07222041678a8e04f/ruff-0.14.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132", size = 13587803, upload-time = "2025-11-06T22:07:26.327Z" },
{ url = "https://files.pythonhosted.org/packages/c9/da/eaaada586f80068728338e0ef7f29ab3e4a08a692f92eb901a4f06bbff24/ruff-0.14.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67", size = 12279654, upload-time = "2025-11-06T22:07:28.46Z" },
{ url = "https://files.pythonhosted.org/packages/66/d4/b1d0e82cf9bf8aed10a6d45be47b3f402730aa2c438164424783ac88c0ed/ruff-0.14.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469", size = 12357520, upload-time = "2025-11-06T22:07:31.468Z" },
{ url = "https://files.pythonhosted.org/packages/04/f4/53e2b42cc82804617e5c7950b7079d79996c27e99c4652131c6a1100657f/ruff-0.14.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde", size = 12719431, upload-time = "2025-11-06T22:07:33.831Z" },
{ url = "https://files.pythonhosted.org/packages/a2/94/80e3d74ed9a72d64e94a7b7706b1c1ebaa315ef2076fd33581f6a1cd2f95/ruff-0.14.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349", size = 13464394, upload-time = "2025-11-06T22:07:35.905Z" },
{ url = "https://files.pythonhosted.org/packages/54/1a/a49f071f04c42345c793d22f6cf5e0920095e286119ee53a64a3a3004825/ruff-0.14.4-py3-none-win32.whl", hash = "sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff", size = 12493429, upload-time = "2025-11-06T22:07:38.43Z" },
{ url = "https://files.pythonhosted.org/packages/bc/22/e58c43e641145a2b670328fb98bc384e20679b5774258b1e540207580266/ruff-0.14.4-py3-none-win_amd64.whl", hash = "sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c", size = 13635380, upload-time = "2025-11-06T22:07:40.496Z" },
{ url = "https://files.pythonhosted.org/packages/30/bd/4168a751ddbbf43e86544b4de8b5c3b7be8d7167a2a5cb977d274e04f0a1/ruff-0.14.4-py3-none-win_arm64.whl", hash = "sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb", size = 12663065, upload-time = "2025-11-06T22:07:42.603Z" },
]
[[package]]
name = "setuptools"
version = "80.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
]
[[package]]
name = "shapely"
version = "2.1.2"
@@ -1635,6 +2074,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sse-starlette"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943, upload-time = "2025-10-30T18:44:20.117Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765, upload-time = "2025-10-30T18:44:18.834Z" },
]
[[package]]
name = "starlette"
version = "0.47.3"
@@ -1648,6 +2099,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" },
]
[[package]]
name = "tavily-python"
version = "0.7.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "requests" },
{ name = "tiktoken" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3e/42/ce2329635b844dda548110a5dfa0ab5631cdc1085e15c2d68b1850a2d112/tavily_python-0.7.12.tar.gz", hash = "sha256:661945bbc9284cdfbe70fb50de3951fd656bfd72e38e352481d333a36ae91f5a", size = 17282, upload-time = "2025-09-10T17:02:01.281Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/e2/dbc246d9fb24433f77b17d9ee4e750a1e2718432ebde2756589c9154cbad/tavily_python-0.7.12-py3-none-any.whl", hash = "sha256:00d09b9de3ca02ef9a994cf4e7ae43d4ec9d199f0566ba6e52cbfcbd07349bd1", size = 15473, upload-time = "2025-09-10T17:01:59.859Z" },
]
[[package]]
name = "tenacity"
version = "9.1.2"
@@ -1716,6 +2181,53 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
]
[[package]]
name = "types-cffi"
version = "1.17.0.20250915"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/98/ea454cea03e5f351323af6a482c65924f3c26c515efd9090dede58f2b4b6/types_cffi-1.17.0.20250915.tar.gz", hash = "sha256:4362e20368f78dabd5c56bca8004752cc890e07a71605d9e0d9e069dbaac8c06", size = 17229, upload-time = "2025-09-15T03:01:25.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/ec/092f2b74b49ec4855cdb53050deb9699f7105b8fda6fe034c0781b8687f3/types_cffi-1.17.0.20250915-py3-none-any.whl", hash = "sha256:cef4af1116c83359c11bb4269283c50f0688e9fc1d7f0eeb390f3661546da52c", size = 20112, upload-time = "2025-09-15T03:01:24.187Z" },
]
[[package]]
name = "types-pyopenssl"
version = "24.1.0.20240722"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "types-cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/93/29/47a346550fd2020dac9a7a6d033ea03fccb92fa47c726056618cc889745e/types-pyOpenSSL-24.1.0.20240722.tar.gz", hash = "sha256:47913b4678a01d879f503a12044468221ed8576263c1540dcb0484ca21b08c39", size = 8458, upload-time = "2024-07-22T02:32:22.558Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/05/c868a850b6fbb79c26f5f299b768ee0adc1f9816d3461dcf4287916f655b/types_pyOpenSSL-24.1.0.20240722-py3-none-any.whl", hash = "sha256:6a7a5d2ec042537934cfb4c9d4deb0e16c4c6250b09358df1f083682fe6fda54", size = 7499, upload-time = "2024-07-22T02:32:21.232Z" },
]
[[package]]
name = "types-redis"
version = "4.6.0.20241004"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "types-pyopenssl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/95/c054d3ac940e8bac4ca216470c80c26688a0e79e09f520a942bb27da3386/types-redis-4.6.0.20241004.tar.gz", hash = "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e", size = 49679, upload-time = "2024-10-04T02:43:59.224Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/82/7d25dce10aad92d2226b269bce2f85cfd843b4477cd50245d7d40ecf8f89/types_redis-4.6.0.20241004-py3-none-any.whl", hash = "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed", size = 58737, upload-time = "2024-10-04T02:43:57.968Z" },
]
[[package]]
name = "types-setuptools"
version = "80.9.0.20250822"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/bd/1e5f949b7cb740c9f0feaac430e301b8f1c5f11a81e26324299ea671a237/types_setuptools-80.9.0.20250822.tar.gz", hash = "sha256:070ea7716968ec67a84c7f7768d9952ff24d28b65b6594797a464f1b3066f965", size = 41296, upload-time = "2025-08-22T03:02:08.771Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/2d/475bf15c1cdc172e7a0d665b6e373ebfb1e9bf734d3f2f543d668b07a142/types_setuptools-80.9.0.20250822-py3-none-any.whl", hash = "sha256:53bf881cb9d7e46ed12c76ef76c0aaf28cfe6211d3fab12e0b83620b1a8642c3", size = 63179, upload-time = "2025-08-22T03:02:07.643Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
@@ -1971,6 +2483,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" },
]
[[package]]
name = "zipp"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
]
[[package]]
name = "zstandard"
version = "0.25.0"

View File

@@ -6,8 +6,6 @@ services:
volumes:
- ./frontend:/app
- /app/node_modules
environment:
- VITE_API_URL=http://localhost:8000
depends_on:
- backend
networks:
@@ -20,11 +18,27 @@ services:
ports:
- "8000:8000"
volumes:
- ./backend:/app
- /app/.venv
- ./backend/app:/app/app
- ./backend/.secrets:/app/.secrets
- ./backend/data:/app/data
env_file:
- .env
networks:
- app-network
db:
image: redis/redis-stack:latest
ports:
- 6379:6379
- 8001:8001
environment:
REDIS_ARGS: --appendonly yes
volumes:
- ./redis_data:/data
restart: unless-stopped
networks:
- app-network
networks:
app-network:
driver: bridge
driver: bridge

File diff suppressed because it is too large Load Diff

View File

@@ -10,23 +10,42 @@
"preview": "vite preview"
},
"dependencies": {
"@ai-sdk/react": "^2.0.89",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@xyflow/react": "^12.9.2",
"ai": "^5.0.89",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.543.0",
"motion": "^12.23.24",
"nanoid": "^5.1.6",
"pdfjs-dist": "^5.4.296",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-dropzone": "^14.3.8",
"react-pdf": "^10.2.0",
"shiki": "^3.15.0",
"streamdown": "^1.4.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"tokenlens": "^1.3.1",
"use-stick-to-bottom": "^1.1.1",
"zustand": "^5.0.8"
},
"devDependencies": {

View File

@@ -0,0 +1,326 @@
import React, { useState } from "react";
import {
AlertTriangle,
CheckCircle,
XCircle,
FileText,
AlertCircle,
ChevronDown,
ChevronRight,
Shield,
Info,
} from "lucide-react";
type Severity = "Pass" | "Warning" | "Error";
interface AuditFinding {
check_id: string;
category: string;
severity: Severity;
message: string;
mitigation?: string;
confidence: number;
}
interface AuditSectionSummary {
section: string;
severity: Severity;
summary: string;
confidence: number;
}
interface AuditReportData {
organisation_ein: string;
organisation_name: string;
year?: number;
overall_severity: Severity;
findings: AuditFinding[];
sections: AuditSectionSummary[];
overall_summary?: string;
notes?: string;
}
interface AuditReportProps {
data: AuditReportData;
}
const getSeverityIcon = (severity: Severity, size = "w-4 h-4") => {
switch (severity) {
case "Pass":
return <CheckCircle className={`${size} text-green-600`} />;
case "Warning":
return <AlertTriangle className={`${size} text-yellow-600`} />;
case "Error":
return <XCircle className={`${size} text-red-600`} />;
default:
return <AlertCircle className={`${size} text-gray-600`} />;
}
};
const getSeverityBadge = (severity: Severity) => {
const colors = {
Pass: "bg-green-100 text-green-800 border-green-200",
Warning: "bg-yellow-100 text-yellow-800 border-yellow-200",
Error: "bg-red-100 text-red-800 border-red-200",
};
return (
<span
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border ${colors[severity]}`}
>
{getSeverityIcon(severity, "w-3 h-3")}
{severity}
</span>
);
};
export const AuditReport: React.FC<AuditReportProps> = ({ data }) => {
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(),
);
const [showAllFindings, setShowAllFindings] = useState(false);
const {
organisation_name,
organisation_ein,
year,
overall_severity,
findings,
sections,
overall_summary,
notes,
} = data;
const severityStats = {
Pass: findings.filter((f) => f.severity === "Pass").length,
Warning: findings.filter((f) => f.severity === "Warning").length,
Error: findings.filter((f) => f.severity === "Error").length,
};
const toggleSection = (section: string) => {
const newExpanded = new Set(expandedSections);
if (newExpanded.has(section)) {
newExpanded.delete(section);
} else {
newExpanded.add(section);
}
setExpandedSections(newExpanded);
};
const criticalFindings = findings.filter((f) => f.severity === "Error");
return (
<div className="w-full bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
{/* Compact Header */}
<div className="bg-linear-to-r from-blue-50 to-indigo-50 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="w-6 h-6 text-blue-600" />
<div>
<h3 className="font-semibold text-gray-900">
{organisation_name}
</h3>
<div className="flex items-center gap-3 text-xs text-gray-600">
<span>EIN: {organisation_ein}</span>
{year && <span>{year}</span>}
</div>
</div>
</div>
{getSeverityBadge(overall_severity)}
</div>
</div>
{/* Quick Stats */}
<div className="bg-gray-50 px-4 py-3 border-b">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">
Audit Results
</span>
<div className="flex items-center gap-4 text-sm">
{severityStats.Pass > 0 && (
<div className="flex items-center gap-1">
<CheckCircle className="w-3 h-3 text-green-600" />
<span className="font-medium text-green-700">
{severityStats.Pass}
</span>
</div>
)}
{severityStats.Warning > 0 && (
<div className="flex items-center gap-1">
<AlertTriangle className="w-3 h-3 text-yellow-600" />
<span className="font-medium text-yellow-700">
{severityStats.Warning}
</span>
</div>
)}
{severityStats.Error > 0 && (
<div className="flex items-center gap-1">
<XCircle className="w-3 h-3 text-red-600" />
<span className="font-medium text-red-700">
{severityStats.Error}
</span>
</div>
)}
</div>
</div>
</div>
<div className="p-4 space-y-4">
{/* Summary */}
{overall_summary && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-blue-600 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-blue-900 text-sm mb-1">
Summary
</h4>
<p className="text-sm text-blue-800">{overall_summary}</p>
</div>
</div>
</div>
)}
{/* Critical Issues (if any) */}
{criticalFindings.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<h4 className="font-medium text-red-900 text-sm mb-2 flex items-center gap-2">
<XCircle className="w-4 h-4" />
Critical Issues ({criticalFindings.length})
</h4>
<div className="space-y-2">
{criticalFindings.slice(0, 2).map((finding, index) => (
<div key={index} className="text-sm text-red-800">
<span className="font-medium">{finding.category}:</span>{" "}
{finding.message}
</div>
))}
{criticalFindings.length > 2 && (
<button
onClick={() => setShowAllFindings(!showAllFindings)}
className="text-xs text-red-700 hover:text-red-800 font-medium"
>
{showAllFindings
? "Show less"
: `+${criticalFindings.length - 2} more issues`}
</button>
)}
</div>
</div>
)}
{/* Sections Overview */}
{sections.length > 0 && (
<div>
<button
onClick={() => toggleSection("sections")}
className="flex items-center gap-2 w-full text-left p-2 hover:bg-gray-50 rounded-lg"
>
{expandedSections.has("sections") ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
<span className="font-medium text-sm">
Section Analysis ({sections.length})
</span>
</button>
{expandedSections.has("sections") && (
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{sections.map((section, index) => (
<div key={index} className="border rounded-lg p-3 bg-gray-50">
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm">
{section.section}
</span>
<div className="flex items-center gap-1">
{getSeverityIcon(section.severity)}
<span className="text-xs text-gray-600">
{Math.round(section.confidence * 100)}%
</span>
</div>
</div>
<p className="text-xs text-gray-700">{section.summary}</p>
</div>
))}
</div>
)}
</div>
)}
{/* All Findings */}
<div>
<button
onClick={() => toggleSection("findings")}
className="flex items-center gap-2 w-full text-left p-2 hover:bg-gray-50 rounded-lg"
>
{expandedSections.has("findings") ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
<span className="font-medium text-sm">
All Findings ({findings.length})
</span>
</button>
{expandedSections.has("findings") && (
<div className="mt-2 space-y-2">
{findings.map((finding, index) => (
<div key={index} className="border rounded-lg p-3 bg-gray-50">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
{getSeverityIcon(finding.severity)}
<div>
<span className="font-medium text-sm">
{finding.category}
</span>
<span className="text-xs text-gray-500 ml-1">
#{finding.check_id}
</span>
</div>
</div>
<span className="text-xs text-gray-600">
{Math.round(finding.confidence * 100)}% confidence
</span>
</div>
<p className="text-sm text-gray-700 mb-2">
{finding.message}
</p>
{finding.mitigation && (
<div className="bg-white rounded p-2 border">
<span className="text-xs font-medium text-gray-600">
Recommended:
</span>
<p className="text-xs text-gray-700 mt-1">
{finding.mitigation}
</p>
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Notes */}
{notes && (
<div className="border-t pt-3">
<div className="flex items-start gap-2">
<FileText className="w-4 h-4 text-gray-400 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-700 text-sm mb-1">
Notes
</h4>
<p className="text-sm text-gray-600 italic">{notes}</p>
</div>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,356 @@
import { Message, MessageContent } from "@/components/ai-elements/message";
import {
PromptInput,
PromptInputActionAddAttachments,
PromptInputActionMenu,
PromptInputActionMenuContent,
PromptInputActionMenuTrigger,
PromptInputAttachment,
PromptInputAttachments,
PromptInputBody,
PromptInputHeader,
type PromptInputMessage,
PromptInputSubmit,
PromptInputTextarea,
PromptInputFooter,
PromptInputTools,
} from "@/components/ai-elements/prompt-input";
import { Action, Actions } from "@/components/ai-elements/actions";
import { Fragment, useState, useEffect } from "react";
import { useChat } from "@ai-sdk/react";
import { Response } from "@/components/ai-elements/response";
import {
CopyIcon,
RefreshCcwIcon,
MessageCircle,
Bot,
AlertCircle,
PaperclipIcon,
User,
} from "lucide-react";
import { AuditReport } from "./AuditReport";
import { WebSearchResults } from "./WebSearchResults";
import { Loader } from "@/components/ai-elements/loader";
import { DefaultChatTransport } from "ai";
interface ChatTabProps {
selectedTema: string | null;
}
export function ChatTab({ selectedTema }: ChatTabProps) {
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const {
messages,
sendMessage,
status,
regenerate,
error: chatError,
} = useChat({
transport: new DefaultChatTransport({
api: "/api/v1/agent/chat",
headers: {
tema: selectedTema || "",
},
}),
onError: (error) => {
setError(`Error en el chat: ${error.message}`);
},
});
// Clear error when starting new conversation
useEffect(() => {
if (status === "streaming") {
setError(null);
}
}, [status]);
const handleSubmit = (message: PromptInputMessage) => {
const hasText = Boolean(message.text?.trim());
const hasAttachments = Boolean(message.files?.length);
if (!(hasText || hasAttachments)) {
return;
}
setError(null);
sendMessage(
{
text: message.text || "Enviado con archivos adjuntos",
files: message.files,
},
{
body: {
dataroom: selectedTema,
context: `Usuario está consultando sobre el dataroom: ${selectedTema}`,
},
},
);
setInput("");
};
if (!selectedTema) {
return (
<div className="flex flex-col items-center justify-center h-64">
<MessageCircle className="w-12 h-12 text-gray-400 mb-4" />
<p className="text-gray-500">
Selecciona un dataroom para iniciar el chat
</p>
</div>
);
}
return (
<div className="flex flex-col h-[638px] max-h-[638px]">
{/* Chat Content */}
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="max-w-4xl mx-auto w-full space-y-6 p-6">
{/* Welcome Message */}
{messages.length === 0 && (
<div className="flex items-start gap-3 mb-6">
<div className="p-2 bg-blue-100 rounded-full">
<Bot className="w-4 h-4 text-blue-600" />
</div>
<div className="flex-1 bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-800">
¡Hola! Soy tu asistente de IA para el dataroom{" "}
<strong>{selectedTema}</strong>. Puedes hacerme preguntas
sobre los documentos almacenados aquí.
</p>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="flex items-start gap-3 mb-4">
<div className="p-2 bg-red-100 rounded-full">
<AlertCircle className="w-4 h-4 text-red-600" />
</div>
<div className="flex-1 bg-red-50 rounded-lg p-4 border border-red-200">
<p className="text-sm text-red-800">{error}</p>
</div>
</div>
)}
{/* Chat Messages */}
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, i) => {
switch (part.type) {
case "text":
return (
<Fragment key={`${message.id}-${i}`}>
{message.role === "user" ? (
<div className="flex items-start gap-3 justify-end">
<div className="flex-1">
<Message
from={message.role}
className="max-w-none"
>
<MessageContent>
<Response>{part.text}</Response>
</MessageContent>
</Message>
</div>
<div className="p-2 rounded-full flex-shrink-0 mt-1 bg-gray-100">
<User className="w-4 h-4 text-gray-600" />
</div>
</div>
) : (
<div className="flex items-start gap-3">
<div className="p-2 rounded-full flex-shrink-0 mt-1 bg-blue-100">
<Bot className="w-4 h-4 text-blue-600" />
</div>
<div className="flex-1">
<Message
from={message.role}
className="max-w-none"
>
<MessageContent>
<Response>{part.text}</Response>
</MessageContent>
</Message>
</div>
</div>
)}
{message.role === "assistant" &&
i === message.parts.length - 1 && (
<div className="ml-12">
<Actions className="mt-2">
<Action
onClick={() => regenerate()}
label="Regenerar"
disabled={status === "streaming"}
>
<RefreshCcwIcon className="size-3" />
</Action>
<Action
onClick={() =>
navigator.clipboard.writeText(part.text)
}
label="Copiar"
>
<CopyIcon className="size-3" />
</Action>
</Actions>
</div>
)}
</Fragment>
);
case "tool-build_audit_report":
switch (part.state) {
case "input-available":
return (
<div
key={`${message.id}-${i}`}
className="flex items-center gap-2 p-4 bg-blue-50 rounded-lg border border-blue-200"
>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
<span className="text-sm text-blue-700">
Generando reporte de auditoría...
</span>
</div>
);
case "output-available":
return (
<div
key={`${message.id}-${i}`}
className="mt-4 w-full"
>
<div className="max-w-full overflow-hidden">
<AuditReport data={part.output} />
</div>
</div>
);
case "output-error":
return (
<div
key={`${message.id}-${i}`}
className="p-4 bg-red-50 border border-red-200 rounded-lg"
>
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-600" />
<span className="text-sm font-medium text-red-800">
Error generando reporte de auditoría
</span>
</div>
<p className="text-sm text-red-600 mt-1">
{part.errorText}
</p>
</div>
);
default:
return null;
}
case "tool-search_web_information":
switch (part.state) {
case "input-available":
return (
<div
key={`${message.id}-${i}`}
className="flex items-center gap-2 p-4 bg-green-50 rounded-lg border border-green-200"
>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600"></div>
<span className="text-sm text-green-700">
Searching the web...
</span>
</div>
);
case "output-available":
return (
<div
key={`${message.id}-${i}`}
className="mt-4 w-full"
>
<div className="max-w-full overflow-hidden">
<WebSearchResults data={part.output} />
</div>
</div>
);
case "output-error":
return (
<div
key={`${message.id}-${i}`}
className="p-4 bg-red-50 border border-red-200 rounded-lg"
>
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-600" />
<span className="text-sm font-medium text-red-800">
Error searching the web
</span>
</div>
<p className="text-sm text-red-600 mt-1">
{part.errorText}
</p>
</div>
);
default:
return null;
}
default:
return null;
}
})}
</div>
))}
{status === "streaming" && <Loader />}
{status === "loading" && <Loader />}
</div>
</div>
{/* Chat Input */}
<div className="border-t border-gray-200 p-3 bg-gray-50/50 flex-shrink-0">
<PromptInput
onSubmit={handleSubmit}
className="max-w-4xl mx-auto border-2 border-gray-200 rounded-xl focus-within:border-slate-500 transition-colors duration-200 bg-white"
globalDrop
multiple
>
<PromptInputHeader className="p-2 pb-0">
<PromptInputAttachments>
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
</PromptInputHeader>
<PromptInputBody>
<PromptInputTextarea
onChange={(e) => setInput(e.target.value)}
value={input}
placeholder={`Pregunta algo sobre ${selectedTema}...`}
disabled={status === "streaming" || status === "loading"}
className="min-h-[60px] resize-none border-0 focus:ring-0 transition-all duration-200 text-base px-4 py-3 bg-white rounded-xl"
/>
</PromptInputBody>
<PromptInputFooter className="mt-3 flex justify-between items-center">
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger>
<PaperclipIcon className="size-4" />
</PromptInputActionMenuTrigger>
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
</PromptInputTools>
<PromptInputSubmit
disabled={
(!input.trim() && !status) ||
status === "streaming" ||
status === "loading"
}
status={status}
className={`rounded-full px-6 py-2 font-medium transition-all duration-200 flex items-center gap-2 ${
(!input.trim() && !status) ||
status === "streaming" ||
status === "loading"
? "bg-gray-300 cursor-not-allowed text-gray-500"
: "bg-blue-600 hover:bg-blue-700 text-white"
}`}
/>
</PromptInputFooter>
</PromptInput>
</div>
</div>
);
}

View File

@@ -1,540 +0,0 @@
import { useEffect, useState } from 'react'
import { useFileStore } from '@/stores/fileStore'
import { api } from '@/services/api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import { Checkbox } from '@/components/ui/checkbox'
import { FileUpload } from './FileUpload'
import { DeleteConfirmDialog } from './DeleteConfirmDialog'
import { PDFPreviewModal } from './PDFPreviewModal'
import { CollectionVerifier } from './CollectionVerifier'
import { ChunkViewerModal } from './ChunkViewerModal'
import { ChunkingConfigModalLandingAI, type LandingAIConfig } from './ChunkingConfigModalLandingAI'
import {
Upload,
Download,
Trash2,
Search,
FileText,
Eye,
MessageSquare,
Scissors
} from 'lucide-react'
interface DashboardProps {
onProcessingChange?: (isProcessing: boolean) => void
}
export function Dashboard({ onProcessingChange }: DashboardProps = {}) {
const {
selectedTema,
files,
setFiles,
loading,
setLoading,
selectedFiles,
toggleFileSelection,
selectAllFiles,
clearSelection
} = useFileStore()
const [searchTerm, setSearchTerm] = useState('')
const [uploadDialogOpen, setUploadDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [fileToDelete, setFileToDelete] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
const [downloading, setDownloading] = useState(false)
// Estados para el modal de preview de PDF
const [previewModalOpen, setPreviewModalOpen] = useState(false)
const [previewFileUrl, setPreviewFileUrl] = useState<string | null>(null)
const [previewFileName, setPreviewFileName] = useState('')
const [previewFileTema, setPreviewFileTema] = useState<string | undefined>(undefined)
const [loadingPreview, setLoadingPreview] = useState(false)
// Estados para el modal de chunks
const [chunkViewerOpen, setChunkViewerOpen] = useState(false)
const [chunkFileName, setChunkFileName] = useState('')
const [chunkFileTema, setChunkFileTema] = useState('')
// Estados para chunking
const [chunkingConfigOpen, setChunkingConfigOpen] = useState(false)
const [chunkingFileName, setChunkingFileName] = useState('')
const [chunkingFileTema, setChunkingFileTema] = useState('')
const [chunkingCollectionName, setChunkingCollectionName] = useState('')
const [processing, setProcessing] = useState(false)
useEffect(() => {
loadFiles()
}, [selectedTema])
const loadFiles = async () => {
try {
setLoading(true)
const response = await api.getFiles(selectedTema || undefined)
setFiles(response.files)
} catch (error) {
console.error('Error loading files:', error)
} finally {
setLoading(false)
}
}
const handleUploadSuccess = () => {
loadFiles()
}
// Eliminar archivo individual
const handleDeleteSingle = async (filename: string) => {
setFileToDelete(filename)
setDeleteDialogOpen(true)
}
// Eliminar archivos seleccionados
const handleDeleteMultiple = () => {
if (selectedFiles.size === 0) return
setFileToDelete(null)
setDeleteDialogOpen(true)
}
// Confirmar eliminación
const confirmDelete = async () => {
if (!fileToDelete && selectedFiles.size === 0) return
setDeleting(true)
try {
if (fileToDelete) {
// Eliminar archivo individual
await api.deleteFile(fileToDelete, selectedTema || undefined)
} else {
// Eliminar archivos seleccionados
const filesToDelete = Array.from(selectedFiles)
await api.deleteFiles(filesToDelete, selectedTema || undefined)
clearSelection()
}
// Recargar archivos
await loadFiles()
setDeleteDialogOpen(false)
setFileToDelete(null)
} catch (error) {
console.error('Error deleting files:', error)
} finally {
setDeleting(false)
}
}
// Descargar archivo individual
const handleDownloadSingle = async (filename: string) => {
try {
setDownloading(true)
await api.downloadFile(filename, selectedTema || undefined)
} catch (error) {
console.error('Error downloading file:', error)
} finally {
setDownloading(false)
}
}
// Descargar archivos seleccionados
const handleDownloadMultiple = async () => {
if (selectedFiles.size === 0) return
try {
setDownloading(true)
const filesToDownload = Array.from(selectedFiles)
const zipName = selectedTema ? `${selectedTema}_archivos` : 'archivos_seleccionados'
await api.downloadMultipleFiles(filesToDownload, selectedTema || undefined, zipName)
} catch (error) {
console.error('Error downloading files:', error)
} finally {
setDownloading(false)
}
}
// Abrir preview de PDF
const handlePreviewFile = async (filename: string, tema?: string) => {
// Solo permitir preview de archivos PDF
if (!filename.toLowerCase().endsWith('.pdf')) {
console.log('Solo se pueden previsualizar archivos PDF')
return
}
try {
setLoadingPreview(true)
setPreviewFileName(filename)
setPreviewFileTema(tema)
// Obtener la URL temporal (SAS) para el archivo
const url = await api.getPreviewUrl(filename, tema)
setPreviewFileUrl(url)
setPreviewModalOpen(true)
} catch (error) {
console.error('Error obteniendo URL de preview:', error)
alert('Error al cargar la vista previa del archivo')
} finally {
setLoadingPreview(false)
}
}
// Manejar descarga desde el modal de preview
const handleDownloadFromPreview = async () => {
if (previewFileName) {
await handleDownloadSingle(previewFileName)
}
}
// Abrir modal de chunks
const handleViewChunks = (filename: string, tema: string) => {
if (!tema) {
alert('No hay tema seleccionado. Por favor selecciona un tema primero.')
return
}
setChunkFileName(filename)
setChunkFileTema(tema)
setChunkViewerOpen(true)
}
// Handlers para chunking
const handleStartChunking = (filename: string, tema: string) => {
if (!tema) {
alert('No hay tema seleccionado. Por favor selecciona un tema primero.')
return
}
setChunkingFileName(filename)
setChunkingFileTema(tema)
setChunkingCollectionName(tema) // Usar el tema como nombre de colección
setChunkingConfigOpen(true)
}
const handleProcessWithLandingAI = async (config: LandingAIConfig) => {
setProcessing(true)
onProcessingChange?.(true)
setChunkingConfigOpen(false)
try {
const result = await api.processWithLandingAI(config)
// Mensaje detallado
let message = `Completado\n\n`
message += `• Modo: ${result.mode === 'quick' ? 'Rápido' : 'Con Extracción'}\n`
message += `• Chunks procesados: ${result.total_chunks}\n`
message += `• Chunks agregados: ${result.chunks_added}\n`
message += `• Colección: ${result.collection_name}\n`
message += `• Tiempo: ${result.processing_time_seconds}s\n`
if (result.schema_used) {
message += `• Schema usado: ${result.schema_used}\n`
}
if (result.extracted_data) {
message += `\nDatos extraídos disponibles en metadata`
}
alert(message)
// Recargar archivos
loadFiles()
} catch (error: any) {
console.error('Error processing with LandingAI:', error)
alert(`❌ Error: ${error.message}`)
} finally {
setProcessing(false)
onProcessingChange?.(false)
}
}
const filteredFiles = files.filter(file =>
file.name.toLowerCase().includes(searchTerm.toLowerCase())
)
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
// Preparar datos para el modal de confirmación
const getDeleteDialogProps = () => {
if (fileToDelete) {
return {
title: 'Eliminar archivo',
description: `¿Estás seguro de que quieres eliminar "${fileToDelete}"? Esta acción no se puede deshacer.`,
fileList: [fileToDelete]
}
} else {
const filesToDelete = Array.from(selectedFiles)
return {
title: `Eliminar ${filesToDelete.length} archivos`,
description: `¿Estás seguro de que quieres eliminar ${filesToDelete.length} archivo${filesToDelete.length !== 1 ? 's' : ''}? Esta acción no se puede deshacer.`,
fileList: filesToDelete
}
}
}
return (
<div className="flex flex-col h-full bg-white">
{/* Processing Banner */}
{processing && (
<div className="bg-blue-50 border-b border-blue-200 px-6 py-3">
<div className="flex items-center justify-center gap-3">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
<p className="text-sm font-medium text-blue-900">
Procesando archivo con LandingAI... Por favor no navegues ni realices otras acciones.
</p>
</div>
</div>
)}
{/* Header */}
<div className="border-b border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-2xl font-semibold text-gray-900">
{selectedTema ? `Tema: ${selectedTema}` : 'Todos los archivos'}
</h2>
<p className="text-sm text-gray-600 mt-1">
{filteredFiles.length} archivo{filteredFiles.length !== 1 ? 's' : ''}
</p>
</div>
<div className="flex gap-2">
<Button onClick={() => setUploadDialogOpen(true)} disabled={processing}>
<Upload className="w-4 h-4 mr-2" />
Subir archivo
</Button>
</div>
</div>
{/* Search and Actions */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Buscar archivos..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
disabled={processing}
/>
</div>
{selectedFiles.size > 0 && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDownloadMultiple}
disabled={downloading || processing}
>
<Download className="w-4 h-4 mr-2" />
{downloading ? 'Descargando...' : `Descargar (${selectedFiles.size})`}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDeleteMultiple}
disabled={processing}
>
<Trash2 className="w-4 h-4 mr-2" />
Eliminar ({selectedFiles.size})
</Button>
</div>
)}
</div>
</div>
{/* Table */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-64">
<p className="text-gray-500">Cargando archivos...</p>
</div>
) : filteredFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64">
<FileText className="w-12 h-12 text-gray-400 mb-4" />
<p className="text-gray-500">
{searchTerm ? 'No se encontraron archivos' : 'No hay archivos en este tema'}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={selectedFiles.size === filteredFiles.length && filteredFiles.length > 0}
onCheckedChange={(checked: boolean) => {
if (checked) {
selectAllFiles()
} else {
clearSelection()
}
}}
/>
</TableHead>
<TableHead>Nombre</TableHead>
<TableHead>Tamaño</TableHead>
<TableHead>Fecha</TableHead>
<TableHead>Tema</TableHead>
<TableHead className="w-32">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFiles.map((file) => {
const isPDF = file.name.toLowerCase().endsWith('.pdf')
return (
<TableRow key={file.full_path}>
<TableCell>
<Checkbox
checked={selectedFiles.has(file.name)}
onCheckedChange={() => toggleFileSelection(file.name)}
/>
</TableCell>
<TableCell className="font-medium">
{isPDF ? (
<button
onClick={() => handlePreviewFile(file.name, file.tema || undefined)}
className="text-blue-600 hover:text-blue-800 hover:underline text-left transition-colors"
disabled={loadingPreview}
>
{file.name}
</button>
) : (
<span>{file.name}</span>
)}
</TableCell>
<TableCell>{formatFileSize(file.size)}</TableCell>
<TableCell>{formatDate(file.last_modified)}</TableCell>
<TableCell>
<span className="px-2 py-1 bg-gray-100 rounded-md text-sm">
{file.tema || 'General'}
</span>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleDownloadSingle(file.name)}
disabled={downloading}
title="Descargar archivo"
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
title="Procesar con chunking"
onClick={() => handleStartChunking(file.name, file.tema)}
>
<Scissors className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
title="Ver chunks"
onClick={() => handleViewChunks(file.name, file.tema)}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
title="Generar preguntas"
>
<MessageSquare className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteSingle(file.name)}
title="Eliminar archivo"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
{/* Upload Dialog */}
<FileUpload
open={uploadDialogOpen}
onOpenChange={setUploadDialogOpen}
onSuccess={handleUploadSuccess}
/>
{/* Delete Confirmation Dialog */}
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={confirmDelete}
loading={deleting}
{...getDeleteDialogProps()}
/>
{/* PDF Preview Modal */}
<PDFPreviewModal
open={previewModalOpen}
onOpenChange={setPreviewModalOpen}
fileUrl={previewFileUrl}
fileName={previewFileName}
onDownload={handleDownloadFromPreview}
/>
{/* Collection Verifier - Verifica/crea colección cuando se selecciona un tema */}
<CollectionVerifier
tema={selectedTema}
onVerified={(exists) => {
console.log(`Collection ${selectedTema} exists: ${exists}`)
}}
/>
{/* Chunk Viewer Modal */}
<ChunkViewerModal
isOpen={chunkViewerOpen}
onClose={() => setChunkViewerOpen(false)}
fileName={chunkFileName}
tema={chunkFileTema}
/>
{/* Modal de configuración de chunking con LandingAI */}
<ChunkingConfigModalLandingAI
isOpen={chunkingConfigOpen}
onClose={() => setChunkingConfigOpen(false)}
fileName={chunkingFileName}
tema={chunkingFileTema}
collectionName={chunkingCollectionName}
onProcess={handleProcessWithLandingAI}
/>
</div>
)
}

View File

@@ -0,0 +1,286 @@
import { useState, useEffect } from "react";
import {
FileText,
Database,
Activity,
TrendingUp,
AlertCircle,
CheckCircle,
Loader2,
} from "lucide-react";
import { api } from "@/services/api";
interface DashboardTabProps {
selectedTema: string | null;
}
interface DataroomInfo {
name: string;
collection: string;
storage: string;
file_count: number;
total_size_bytes: number;
total_size_mb: number;
collection_exists: boolean;
vector_count: number | null;
collection_info: {
vectors_count: number;
indexed_vectors_count: number;
points_count: number;
segments_count: number;
status: string;
} | null;
file_types: Record<string, number>;
recent_files: Array<{
name: string;
size_mb: number;
last_modified: string;
}>;
}
export function DashboardTab({ selectedTema }: DashboardTabProps) {
const [dataroomInfo, setDataroomInfo] = useState<DataroomInfo | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (selectedTema) {
fetchDataroomInfo();
}
}, [selectedTema]);
const fetchDataroomInfo = async () => {
if (!selectedTema) return;
setLoading(true);
setError(null);
try {
const info = await api.getDataroomInfo(selectedTema);
setDataroomInfo(info);
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Error desconocido";
setError(`Error cargando información: ${errorMessage}`);
console.error("Error fetching dataroom info:", err);
} finally {
setLoading(false);
}
};
const formatFileTypes = (fileTypes: Record<string, number>) => {
const entries = Object.entries(fileTypes);
if (entries.length === 0) return "Sin archivos";
return entries
.sort(([, a], [, b]) => b - a) // Sort by count descending
.slice(0, 3) // Take top 3
.map(([ext, count]) => `${ext.toUpperCase()}: ${count}`)
.join(", ");
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 MB";
const mb = bytes / (1024 * 1024);
if (mb < 1) return `${(bytes / 1024).toFixed(1)} KB`;
return `${mb.toFixed(1)} MB`;
};
if (!selectedTema) {
return (
<div className="flex flex-col items-center justify-center h-64">
<Activity className="w-12 h-12 text-gray-400 mb-4" />
<p className="text-gray-500">
Selecciona un dataroom para ver las métricas
</p>
</div>
);
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center h-64">
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mb-4" />
<p className="text-gray-600">Cargando métricas...</p>
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-red-800">Error</p>
<p className="text-sm text-red-600">{error}</p>
</div>
</div>
</div>
);
}
if (!dataroomInfo) {
return (
<div className="flex flex-col items-center justify-center h-64">
<AlertCircle className="w-12 h-12 text-gray-400 mb-4" />
<p className="text-gray-500">
No se pudo cargar la información del dataroom
</p>
</div>
);
}
return (
<div className="p-6">
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Métricas del Dataroom: {selectedTema}
</h3>
<p className="text-sm text-gray-600">
Vista general del estado y actividad del dataroom
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Files Count Card */}
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<FileText className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-600">Archivos</p>
<p className="text-2xl font-bold text-gray-900">
{dataroomInfo.file_count}
</p>
<p className="text-xs text-gray-500 mt-1">
{formatFileTypes(dataroomInfo.file_types)}
</p>
</div>
</div>
</div>
{/* Storage Usage Card */}
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg">
<Database className="w-5 h-5 text-green-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-600">
Almacenamiento
</p>
<p className="text-2xl font-bold text-gray-900">
{dataroomInfo.total_size_mb.toFixed(1)} MB
</p>
<p className="text-xs text-gray-500 mt-1">
{formatBytes(dataroomInfo.total_size_bytes)}
</p>
</div>
</div>
</div>
{/* Vector Collections Card */}
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-lg">
<Activity className="w-5 h-5 text-purple-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-600">Vectores</p>
<p className="text-2xl font-bold text-gray-900">
{dataroomInfo.vector_count ?? 0}
</p>
<p className="text-xs text-gray-500 mt-1">
{dataroomInfo.collection_exists
? "Vectores indexados"
: "Sin vectores"}
</p>
</div>
</div>
</div>
{/* Collection Status Card */}
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-100 rounded-lg">
<TrendingUp className="w-5 h-5 text-orange-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-600">Estado</p>
<div className="flex items-center gap-2">
<p className="text-2xl font-bold text-gray-900">
{dataroomInfo.collection_exists ? "Activo" : "Inactivo"}
</p>
{dataroomInfo.collection_exists ? (
<CheckCircle className="w-6 h-6 text-green-600" />
) : (
<AlertCircle className="w-6 h-6 text-yellow-600" />
)}
</div>
{dataroomInfo.collection_info ? (
<p className="text-xs text-gray-500 mt-1">
{dataroomInfo.collection_info.indexed_vectors_count}/
{dataroomInfo.collection_info.vectors_count} vectores
indexados
</p>
) : (
<p className="text-xs text-gray-500 mt-1">
{dataroomInfo.collection_exists
? "Colección sin datos"
: "Sin colección"}
</p>
)}
</div>
</div>
</div>
</div>
{/* Recent Files Section */}
{dataroomInfo.recent_files.length > 0 && (
<div className="mt-8">
<h4 className="text-md font-semibold text-gray-900 mb-4">
Archivos Recientes
</h4>
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="divide-y divide-gray-200">
{dataroomInfo.recent_files.map((file, index) => (
<div
key={index}
className="p-4 flex items-center justify-between hover:bg-gray-50"
>
<div className="flex items-center gap-3">
<FileText className="w-4 h-4 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-900">
{file.name}
</p>
<p className="text-xs text-gray-500">
{new Date(file.last_modified).toLocaleDateString(
"es-ES",
{
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
},
)}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm text-gray-600">
{file.size_mb.toFixed(2)} MB
</p>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,185 @@
import { useState } from "react";
import { useFileStore } from "@/stores/fileStore";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Expand, Minimize2 } from "lucide-react";
import { FilesTab } from "./FilesTab";
import { DashboardTab } from "./DashboardTab";
import { ChatTab } from "./ChatTab";
interface DataroomViewProps {
onProcessingChange?: (isProcessing: boolean) => void;
}
export function DataroomView({ onProcessingChange }: DataroomViewProps = {}) {
const { selectedTema } = useFileStore();
const [processing, setProcessing] = useState(false);
const [fullscreenTab, setFullscreenTab] = useState<string | null>(null);
const [currentTab, setCurrentTab] = useState("overview");
const handleProcessingChange = (isProcessing: boolean) => {
setProcessing(isProcessing);
onProcessingChange?.(isProcessing);
};
const openFullscreen = (tabValue: string) => {
setFullscreenTab(tabValue);
};
const closeFullscreen = () => {
setFullscreenTab(null);
};
const renderTabContent = (tabValue: string, isFullscreen = false) => {
const className = isFullscreen ? "h-[calc(100vh-8rem)] flex flex-col" : "";
switch (tabValue) {
case "overview":
return (
<div className={className}>
<DashboardTab selectedTema={selectedTema} />
</div>
);
case "files":
return (
<div className={className}>
<FilesTab
selectedTema={selectedTema}
processing={processing}
onProcessingChange={handleProcessingChange}
/>
</div>
);
case "chat":
return (
<div className={className}>
<ChatTab selectedTema={selectedTema} />
</div>
);
default:
return null;
}
};
const getTabTitle = (tabValue: string) => {
switch (tabValue) {
case "overview":
return "Overview";
case "files":
return "Files";
case "chat":
return "Chat";
default:
return "";
}
};
return (
<div className="flex flex-col h-full bg-white">
<div className="border-b border-gray-200 px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold text-gray-900 mb-2">
{selectedTema
? `Dataroom: ${selectedTema}`
: "Selecciona un dataroom"}
</h2>
<p className="text-sm text-gray-600">
{selectedTema
? "Gestiona archivos, consulta métricas y chatea con IA sobre el contenido"
: "Selecciona un dataroom de la barra lateral para comenzar"}
</p>
</div>
</div>
</div>
<Tabs
value={currentTab}
onValueChange={setCurrentTab}
className="flex flex-col flex-1"
>
<div className="border-b border-gray-200 px-6 py-2">
<TabsList className="flex h-10 w-full items-center gap-2 bg-transparent p-0 justify-between">
<div className="flex items-center gap-2">
<TabsTrigger
value="overview"
className="rounded-md px-4 py-2 text-sm font-medium text-gray-600 transition data-[state=active]:bg-gray-900 data-[state=active]:text-white data-[state=active]:shadow"
>
Overview
</TabsTrigger>
<TabsTrigger
value="files"
className="rounded-md px-4 py-2 text-sm font-medium text-gray-600 transition data-[state=active]:bg-gray-900 data-[state=active]:text-white data-[state=active]:shadow"
>
Files
</TabsTrigger>
<TabsTrigger
value="chat"
className="rounded-md px-4 py-2 text-sm font-medium text-gray-600 transition data-[state=active]:bg-gray-900 data-[state=active]:text-white data-[state=active]:shadow"
>
Chat
</TabsTrigger>
</div>
<Button
variant="outline"
size="sm"
onClick={() => openFullscreen(currentTab)}
className="ml-auto"
>
<Expand className="h-4 w-4" />
<span className="sr-only">Open fullscreen</span>
</Button>
</TabsList>
</div>
<TabsContent value="overview" className="mt-0 flex-1">
{renderTabContent("overview")}
</TabsContent>
<TabsContent value="files" className="mt-0 flex flex-1 flex-col">
{renderTabContent("files")}
</TabsContent>
<TabsContent value="chat" className="mt-0 flex-1">
{renderTabContent("chat")}
</TabsContent>
</Tabs>
<Dialog
open={fullscreenTab !== null}
onOpenChange={(open: boolean) => !open && closeFullscreen()}
>
<DialogContent className="max-w-[100vw] max-h-[100vh] w-[100vw] h-[100vh] m-0 rounded-none [&>button]:hidden">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<DialogTitle className="text-xl font-semibold">
{selectedTema
? `${getTabTitle(fullscreenTab || "")} - ${selectedTema}`
: getTabTitle(fullscreenTab || "")}
</DialogTitle>
<Button
variant="outline"
size="sm"
onClick={closeFullscreen}
className="h-8 w-8 p-0"
>
<Minimize2 className="h-4 w-4" />
<span className="sr-only">Exit fullscreen</span>
</Button>
</DialogHeader>
<div className="flex-1 overflow-hidden">
{fullscreenTab && renderTabContent(fullscreenTab, true)}
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,517 @@
import { useState, useEffect } from "react";
import { useFileStore } from "@/stores/fileStore";
import { api } from "@/services/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { FileUpload } from "./FileUpload";
import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
import { PDFPreviewModal } from "./PDFPreviewModal";
import { ChunkViewerModal } from "./ChunkViewerModal";
import {
ChunkingConfigModalLandingAI,
type LandingAIConfig,
} from "./ChunkingConfigModalLandingAI";
import {
Upload,
Download,
Trash2,
Search,
FileText,
Eye,
MessageSquare,
Scissors,
} from "lucide-react";
interface FilesTabProps {
selectedTema: string | null;
processing: boolean;
onProcessingChange?: (isProcessing: boolean) => void;
}
export function FilesTab({
selectedTema,
processing,
onProcessingChange,
}: FilesTabProps) {
const {
files,
setFiles,
loading,
setLoading,
selectedFiles,
toggleFileSelection,
selectAllFiles,
clearSelection,
} = useFileStore();
const [searchTerm, setSearchTerm] = useState("");
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [fileToDelete, setFileToDelete] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [downloading, setDownloading] = useState(false);
// Estados para el modal de preview de PDF
const [previewModalOpen, setPreviewModalOpen] = useState(false);
const [previewFileUrl, setPreviewFileUrl] = useState<string | null>(null);
const [previewFileName, setPreviewFileName] = useState("");
const [previewFileTema, setPreviewFileTema] = useState<string | undefined>(
undefined,
);
const [loadingPreview, setLoadingPreview] = useState(false);
// Estados para el modal de chunks
const [chunkViewerOpen, setChunkViewerOpen] = useState(false);
const [chunkFileName, setChunkFileName] = useState("");
const [chunkFileTema, setChunkFileTema] = useState("");
// Estados para chunking
const [chunkingConfigOpen, setChunkingConfigOpen] = useState(false);
const [chunkingFileName, setChunkingFileName] = useState("");
const [chunkingFileTema, setChunkingFileTema] = useState("");
const [chunkingCollectionName, setChunkingCollectionName] = useState("");
// Load files when component mounts or selectedTema changes
useEffect(() => {
loadFiles();
}, [selectedTema]);
const loadFiles = async () => {
// Don't load files if no dataroom is selected
if (!selectedTema) {
setFiles([]);
return;
}
try {
setLoading(true);
const response = await api.getFiles(selectedTema);
setFiles(response.files);
} catch (error) {
console.error("Error loading files:", error);
} finally {
setLoading(false);
}
};
const handleUploadSuccess = () => {
loadFiles();
};
const handleDeleteFile = (filename: string) => {
setFileToDelete(filename);
setDeleteDialogOpen(true);
};
const handleDeleteSelected = () => {
if (selectedFiles.size === 0) return;
setFileToDelete(null);
setDeleteDialogOpen(true);
};
const confirmDelete = async () => {
try {
setDeleting(true);
if (fileToDelete) {
// Eliminar archivo individual
await api.deleteFile(fileToDelete, selectedTema || undefined);
} else {
// Eliminar archivos seleccionados
const filesToDelete = Array.from(selectedFiles);
await api.deleteFiles(filesToDelete, selectedTema || undefined);
clearSelection();
}
await loadFiles();
setDeleteDialogOpen(false);
setFileToDelete(null);
} catch (error) {
console.error("Error deleting files:", error);
} finally {
setDeleting(false);
}
};
const handleDownloadFile = async (filename: string) => {
try {
setDownloading(true);
await api.downloadFile(filename, selectedTema || undefined);
} catch (error) {
console.error("Error downloading file:", error);
} finally {
setDownloading(false);
}
};
const handleDownloadSelected = async () => {
if (selectedFiles.size === 0) return;
try {
setDownloading(true);
const filesToDownload = Array.from(selectedFiles);
const zipName = selectedTema
? `${selectedTema}_archivos`
: "archivos_seleccionados";
await api.downloadMultipleFiles(
filesToDownload,
selectedTema || undefined,
zipName,
);
clearSelection();
} catch (error) {
console.error("Error downloading files:", error);
} finally {
setDownloading(false);
}
};
const handlePreviewFile = async (filename: string) => {
try {
setLoadingPreview(true);
const url = await api.getPreviewUrl(filename, selectedTema || undefined);
setPreviewFileUrl(url);
setPreviewFileName(filename);
setPreviewFileTema(selectedTema || undefined);
setPreviewModalOpen(true);
} catch (error) {
console.error("Error getting preview URL:", error);
} finally {
setLoadingPreview(false);
}
};
const handleDownloadFromPreview = () => {
if (previewFileName) {
handleDownloadFile(previewFileName);
}
};
const handleViewChunks = (filename: string) => {
setChunkFileName(filename);
setChunkFileTema(selectedTema || "");
setChunkViewerOpen(true);
};
const handleStartChunking = (filename: string) => {
setChunkingFileName(filename);
setChunkingFileTema(selectedTema || "");
setChunkingCollectionName(selectedTema || "");
setChunkingConfigOpen(true);
};
const handleChunkingProcess = async (config: LandingAIConfig) => {
try {
onProcessingChange?.(true);
const processConfig = {
file_name: chunkingFileName,
tema: chunkingFileTema,
collection_name: chunkingCollectionName,
mode: config.mode,
schema_id: config.schema_id,
include_chunk_types: config.include_chunk_types,
max_tokens_per_chunk: config.max_tokens_per_chunk,
merge_small_chunks: config.merge_small_chunks,
};
await api.processWithLandingAI(processConfig);
console.log("Procesamiento con LandingAI completado");
} catch (error) {
console.error("Error en procesamiento con LandingAI:", error);
throw error;
} finally {
onProcessingChange?.(false);
}
};
// Filtrar archivos por término de búsqueda
const filteredFiles = files.filter((file) =>
file.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
const totalFiles = filteredFiles.length;
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString("es-ES", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const getDeleteDialogProps = () => {
if (fileToDelete) {
return {
title: "Eliminar archivo",
message: `¿Estás seguro de que deseas eliminar el archivo "${fileToDelete}"?`,
fileList: [fileToDelete],
};
} else {
const filesToDelete = Array.from(selectedFiles);
return {
title: "Eliminar archivos seleccionados",
message: `¿Estás seguro de que deseas eliminar ${filesToDelete.length} archivo${filesToDelete.length > 1 ? "s" : ""}?`,
fileList: filesToDelete,
};
}
};
if (!selectedTema) {
return (
<div className="flex flex-col items-center justify-center h-64">
<FileText className="w-12 h-12 text-gray-400 mb-4" />
<p className="text-gray-500">
Selecciona un dataroom para ver sus archivos
</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{processing && (
<div className="bg-blue-50 border-b border-blue-200 px-6 py-3">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
<span className="text-sm text-blue-800">
Procesando archivos con LandingAI...
</span>
</div>
</div>
)}
<div className="border-b border-gray-200 px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Buscar archivos..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
<div className="flex items-center gap-2">
{selectedFiles.size > 0 && (
<>
<Button
variant="outline"
size="sm"
onClick={handleDownloadSelected}
disabled={downloading}
className="gap-2"
>
<Download className="w-4 h-4" />
Descargar ({selectedFiles.size})
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDeleteSelected}
disabled={deleting}
className="gap-2 text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
Eliminar ({selectedFiles.size})
</Button>
</>
)}
<Button
onClick={() => setUploadDialogOpen(true)}
disabled={loading}
className="gap-2"
>
<Upload className="w-4 h-4" />
Subir archivo
</Button>
</div>
</div>
</div>
<div className="flex-1 overflow-auto">
<div className="p-6">
{loading ? (
<div className="flex items-center justify-center h-64">
<p className="text-gray-500">Cargando archivos...</p>
</div>
) : filteredFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64">
<FileText className="w-12 h-12 text-gray-400 mb-4" />
<p className="text-gray-500">
{!selectedTema
? "Selecciona un dataroom para ver sus archivos"
: searchTerm
? "No se encontraron archivos"
: "No hay archivos en este dataroom"}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={
selectedFiles.size === filteredFiles.length &&
filteredFiles.length > 0
}
onCheckedChange={(checked) => {
if (checked) {
selectAllFiles();
} else {
clearSelection();
}
}}
/>
</TableHead>
<TableHead>Archivo</TableHead>
<TableHead>Tamaño</TableHead>
<TableHead>Modificado</TableHead>
<TableHead className="text-right">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFiles.map((file) => (
<TableRow key={file.name}>
<TableCell>
<Checkbox
checked={selectedFiles.has(file.name)}
onCheckedChange={() => toggleFileSelection(file.name)}
/>
</TableCell>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-gray-400" />
{file.name}
</div>
</TableCell>
<TableCell>{formatFileSize(file.size)}</TableCell>
<TableCell>{formatDate(file.last_modified)}</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handlePreviewFile(file.name)}
disabled={loadingPreview}
className="h-8 w-8 p-0"
title="Vista previa"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleViewChunks(file.name)}
className="h-8 w-8 p-0"
title="Ver chunks"
>
<MessageSquare className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleStartChunking(file.name)}
className="h-8 w-8 p-0"
title="Procesar con LandingAI"
>
<Scissors className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDownloadFile(file.name)}
disabled={downloading}
className="h-8 w-8 p-0"
title="Descargar"
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteFile(file.name)}
disabled={deleting}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
title="Eliminar"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
{/* File Upload Modal */}
<FileUpload
open={uploadDialogOpen}
onOpenChange={setUploadDialogOpen}
onSuccess={handleUploadSuccess}
/>
{/* Delete Confirmation Dialog */}
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={confirmDelete}
loading={deleting}
{...getDeleteDialogProps()}
/>
{/* PDF Preview Modal */}
<PDFPreviewModal
open={previewModalOpen}
onOpenChange={setPreviewModalOpen}
fileUrl={previewFileUrl}
fileName={previewFileName}
onDownload={handleDownloadFromPreview}
/>
{/* Chunk Viewer Modal */}
<ChunkViewerModal
isOpen={chunkViewerOpen}
onClose={() => setChunkViewerOpen(false)}
fileName={chunkFileName}
tema={chunkFileTema}
/>
{/* Modal de configuración de chunking con LandingAI */}
<ChunkingConfigModalLandingAI
isOpen={chunkingConfigOpen}
onClose={() => setChunkingConfigOpen(false)}
onProcess={handleChunkingProcess}
fileName={chunkingFileName}
tema={chunkingFileTema}
collectionName={chunkingCollectionName}
/>
</div>
);
}

View File

@@ -1,69 +1,96 @@
import { useState } from 'react'
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Menu } from 'lucide-react'
import { Sidebar } from './Sidebar'
import { Dashboard } from './Dashboard'
import { SchemaManagement } from '@/pages/SchemaManagement'
import { useState } from "react";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Menu } from "lucide-react";
import { Sidebar } from "./Sidebar";
import { DataroomView } from "./DataroomView";
import { SchemaManagement } from "@/pages/SchemaManagement";
import { cn } from "@/lib/utils";
type View = 'dashboard' | 'schemas'
type View = "dataroom" | "schemas";
export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false)
const [currentView, setCurrentView] = useState<View>('dashboard')
const [isProcessing, setIsProcessing] = useState(false)
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [currentView, setCurrentView] = useState<View>("dataroom");
const [isProcessing, setIsProcessing] = useState(false);
const handleNavigateToSchemas = () => {
if (isProcessing) {
alert('No puedes navegar mientras se está procesando un archivo. Por favor espera a que termine.')
return
alert(
"No puedes navegar mientras se está procesando un archivo. Por favor espera a que termine.",
);
return;
}
setCurrentView('schemas')
setSidebarOpen(false)
}
setCurrentView("schemas");
setSidebarOpen(false);
};
const handleNavigateToDashboard = () => {
if (isProcessing) {
alert('No puedes navegar mientras se está procesando un archivo. Por favor espera a que termine.')
return
alert(
"No puedes navegar mientras se está procesando un archivo. Por favor espera a que termine.",
);
return;
}
setCurrentView('dashboard')
}
setCurrentView("dataroom");
};
return (
<div className="h-screen flex bg-gray-50">
{/* Desktop Sidebar */}
<div className="hidden md:flex md:w-64 md:flex-col">
<Sidebar onNavigateToSchemas={handleNavigateToSchemas} disabled={isProcessing} />
<div
className={cn(
"hidden md:flex md:flex-col transition-all duration-300",
isSidebarCollapsed ? "md:w-20" : "md:w-64",
)}
>
<Sidebar
onNavigateToSchemas={handleNavigateToSchemas}
disabled={isProcessing}
collapsed={isSidebarCollapsed}
onToggleCollapse={() => setIsSidebarCollapsed((prev) => !prev)}
/>
</div>
{/* Mobile Sidebar */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden fixed top-4 left-4 z-40" disabled={isProcessing}>
<Button
variant="ghost"
size="icon"
className="md:hidden fixed top-4 left-4 z-40"
disabled={isProcessing}
>
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64 p-0">
<Sidebar onNavigateToSchemas={handleNavigateToSchemas} disabled={isProcessing} />
<Sidebar
onNavigateToSchemas={handleNavigateToSchemas}
disabled={isProcessing}
/>
</SheetContent>
</Sheet>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
{currentView === 'dashboard' ? (
<Dashboard onProcessingChange={setIsProcessing} />
{currentView === "dataroom" ? (
<DataroomView onProcessingChange={setIsProcessing} />
) : (
<div className="flex-1 overflow-auto">
<SchemaManagement />
<div className="fixed bottom-6 right-6">
<Button onClick={handleNavigateToDashboard} disabled={isProcessing}>
{isProcessing ? 'Procesando...' : 'Volver al Dashboard'}
<Button
onClick={handleNavigateToDashboard}
disabled={isProcessing}
>
{isProcessing ? "Procesando..." : "Volver al Dataroom"}
</Button>
</div>
</div>
)}
</div>
</div>
)
}
);
}

View File

@@ -1,172 +1,450 @@
import { useEffect, useState } from 'react'
import { useFileStore } from '@/stores/fileStore'
import { api } from '@/services/api'
import { Button } from '@/components/ui/button'
import { FolderIcon, FileText, Trash2, Database } from 'lucide-react'
import { useEffect, useState, type ReactElement } from "react";
import { useFileStore } from "@/stores/fileStore";
import { api } from "@/services/api";
import { Button } from "@/components/ui/button";
import {
FolderIcon,
FileText,
Trash2,
Database,
ChevronLeft,
ChevronRight,
RefreshCcw,
Plus,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface SidebarProps {
onNavigateToSchemas?: () => void
disabled?: boolean
onNavigateToSchemas?: () => void;
disabled?: boolean;
collapsed?: boolean;
onToggleCollapse?: () => void;
}
export function Sidebar({ onNavigateToSchemas, disabled = false }: SidebarProps = {}) {
export function Sidebar({
onNavigateToSchemas,
disabled = false,
collapsed = false,
onToggleCollapse,
}: SidebarProps = {}) {
const {
temas,
selectedTema,
setTemas,
setSelectedTema,
loading,
setLoading
} = useFileStore()
setLoading,
} = useFileStore();
const [deletingTema, setDeletingTema] = useState<string | null>(null)
const [deletingTema, setDeletingTema] = useState<string | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newDataroomName, setNewDataroomName] = useState("");
const [creatingDataroom, setCreatingDataroom] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const renderWithTooltip = (label: string, element: ReactElement) => {
if (!collapsed) {
return element;
}
return (
<Tooltip>
<TooltipTrigger asChild>{element}</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
);
};
const handleCreateDialogOpenChange = (open: boolean) => {
setCreateDialogOpen(open);
if (!open) {
setNewDataroomName("");
setCreateError(null);
}
};
const handleCreateDataroom = async () => {
const trimmed = newDataroomName.trim();
if (!trimmed) {
setCreateError("El nombre es obligatorio");
return;
}
setCreatingDataroom(true);
setCreateError(null);
try {
const result = await api.createDataroom({ name: trimmed });
// Refresh the datarooms list (this will load all datarooms including the new one)
await loadTemas();
// Select the newly created dataroom
setSelectedTema(trimmed);
// Close dialog and show success
handleCreateDialogOpenChange(false);
} catch (error) {
console.error("Error creating dataroom:", error);
setCreateError(
error instanceof Error
? error.message
: "No se pudo crear el dataroom. Inténtalo nuevamente.",
);
} finally {
setCreatingDataroom(false);
}
};
useEffect(() => {
loadTemas()
}, [])
loadTemas();
}, []);
const loadTemas = async () => {
try {
setLoading(true)
const response = await api.getTemas()
setTemas(response.temas)
setLoading(true);
const response = await api.getDatarooms();
// Extract dataroom names from the response with better error handling
let dataroomNames: string[] = [];
if (response && response.datarooms && Array.isArray(response.datarooms)) {
dataroomNames = response.datarooms
.filter((dataroom) => dataroom && dataroom.name)
.map((dataroom) => dataroom.name);
}
setTemas(dataroomNames);
// Auto-select first dataroom if none is selected and datarooms are available
if (!selectedTema && dataroomNames.length > 0) {
setSelectedTema(dataroomNames[0]);
}
} catch (error) {
console.error('Error loading temas:', error)
console.error("Error loading datarooms:", error);
// Fallback to legacy getTemas if dataroom endpoint fails
try {
const legacyResponse = await api.getTemas();
const legacyTemas = Array.isArray(legacyResponse?.temas)
? legacyResponse.temas.filter(Boolean)
: [];
setTemas(legacyTemas);
// Auto-select first legacy tema if none is selected
if (!selectedTema && legacyTemas.length > 0) {
setSelectedTema(legacyTemas[0]);
}
} catch (legacyError) {
console.error("Error loading legacy temas:", legacyError);
// Ensure we always set an array, never undefined or null
setTemas([]);
}
} finally {
setLoading(false)
setLoading(false);
}
}
};
const handleTemaSelect = (tema: string | null) => {
setSelectedTema(tema)
}
setSelectedTema(tema);
};
const handleDeleteTema = async (tema: string, e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation() // Evitar que se seleccione el tema al hacer clic en el icono
const handleDeleteTema = async (
tema: string,
e: React.MouseEvent<HTMLButtonElement>,
) => {
e.stopPropagation(); // Evitar que se seleccione el tema al hacer clic en el icono
const confirmed = window.confirm(
`¿Estás seguro de que deseas eliminar el tema "${tema}"?\n\n` +
`Esto eliminará:\n` +
`Todos los archivos del tema en Azure Blob Storage\n` +
`La colección "${tema}" en Qdrant (si existe)\n\n` +
`Esta acción no se puede deshacer.`
)
`¿Estás seguro de que deseas eliminar el dataroom "${tema}"?\n\n` +
`Esto eliminará:\n` +
`El dataroom de la base de datos\n` +
`Todos los archivos del tema en Azure Blob Storage\n` +
`• La colección "${tema}" en Qdrant (si existe)\n\n` +
`Esta acción no se puede deshacer.`,
);
if (!confirmed) return
if (!confirmed) return;
try {
setDeletingTema(tema)
setDeletingTema(tema);
// 1. Eliminar todos los archivos del tema en Azure Blob Storage
await api.deleteTema(tema)
// 2. Intentar eliminar la colección en Qdrant (si existe)
// 1. Delete the dataroom (this will also delete the vector collection)
try {
const collectionExists = await api.checkCollectionExists(tema)
if (collectionExists.exists) {
await api.deleteCollection(tema)
console.log(`Colección "${tema}" eliminada de Qdrant`)
}
await api.deleteDataroom(tema);
} catch (error) {
console.warn(`No se pudo eliminar la colección "${tema}" de Qdrant:`, error)
// Continuar aunque falle la eliminación de la colección
console.error(`Error deleting dataroom "${tema}":`, error);
// If dataroom deletion fails, fall back to legacy deletion
// Eliminar todos los archivos del tema en Azure Blob Storage
await api.deleteTema(tema);
// Intentar eliminar la colección en Qdrant (si existe)
try {
const collectionExists = await api.checkCollectionExists(tema);
if (collectionExists.exists) {
await api.deleteCollection(tema);
}
} catch (collectionError) {
console.warn(
`No se pudo eliminar la colección "${tema}" de Qdrant:`,
collectionError,
);
}
}
// 3. Actualizar la lista de temas
await loadTemas()
// 2. Actualizar la lista de temas
await loadTemas();
// 4. Si el tema eliminado estaba seleccionado, deseleccionar
// 3. Si el tema eliminado estaba seleccionado, deseleccionar
if (selectedTema === tema) {
setSelectedTema(null)
setSelectedTema(null);
}
} catch (error) {
console.error(`Error eliminando tema "${tema}":`, error)
alert(`Error al eliminar el tema: ${error instanceof Error ? error.message : 'Error desconocido'}`)
console.error(`Error eliminando dataroom "${tema}":`, error);
alert(
`Error al eliminar el dataroom: ${error instanceof Error ? error.message : "Error desconocido"}`,
);
} finally {
setDeletingTema(null)
setDeletingTema(null);
}
}
};
return (
<div className="bg-white border-r border-gray-200 flex flex-col h-full">
{/* Header */}
<div className="p-6 border-b border-gray-200">
<h1 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
<FileText className="h-6 w-6" />
DoRa Luma
</h1>
</div>
{/* Temas List */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-1">
<h2 className="text-sm font-medium text-gray-500 mb-3">Collections</h2>
{/* Todos los archivos */}
<Button
variant={selectedTema === null ? "secondary" : "ghost"}
className="w-full justify-start"
onClick={() => handleTemaSelect(null)}
disabled={disabled}
<TooltipProvider delayDuration={100}>
<div className="bg-slate-800 border-r border-slate-700 flex flex-col h-full transition-[width] duration-300">
{/* Header */}
<div
className={cn(
"border-b border-slate-700 flex items-center gap-3",
collapsed ? "p-4" : "p-6",
)}
>
<div
className={cn(
"flex items-center gap-2 text-slate-100 flex-1",
collapsed ? "justify-center" : "justify-start",
)}
>
<FolderIcon className="mr-2 h-4 w-4" />
Todos los archivos
</Button>
<FileText className="h-6 w-6" />
{!collapsed && <h1 className="text-xl font-semibold">Luma</h1>}
</div>
{onToggleCollapse && (
<Button
variant="ghost"
size="icon"
className="text-slate-400 hover:text-slate-100"
onClick={onToggleCollapse}
disabled={disabled}
aria-label={
collapsed ? "Expandir barra lateral" : "Contraer barra lateral"
}
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</Button>
)}
</div>
{/* Lista de temas */}
{loading ? (
<div className="text-sm text-gray-500 px-3 py-2">Cargando...</div>
) : (
temas.map((tema) => (
<div key={tema} className="relative group">
{/* Temas List */}
<div className={cn("flex-1 overflow-y-auto p-4", collapsed && "px-2")}>
<div className="space-y-1">
<div
className={cn(
"mb-3 flex items-center",
collapsed ? "justify-center" : "justify-between",
)}
>
{!collapsed && (
<h2 className="text-sm font-medium text-slate-300">
Datarooms
</h2>
)}
{renderWithTooltip(
"Crear dataroom",
<Button
variant={selectedTema === tema ? "secondary" : "ghost"}
className="w-full justify-start pr-10"
onClick={() => handleTemaSelect(tema)}
disabled={deletingTema === tema || disabled}
variant="ghost"
size="sm"
className={cn(
"gap-2 bg-slate-700/50 text-slate-200 hover:bg-slate-600 hover:text-slate-100 border border-slate-600",
collapsed
? "h-10 w-10 p-0 justify-center rounded-full"
: "",
)}
onClick={() => handleCreateDialogOpenChange(true)}
disabled={disabled || creatingDataroom}
>
<FolderIcon className="mr-2 h-4 w-4" />
{tema}
</Button>
<button
onClick={(e) => handleDeleteTema(tema, e)}
disabled={deletingTema === tema || disabled}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
title="Eliminar tema y colección"
>
<Trash2 className="h-4 w-4 text-red-600" />
</button>
<Plus className="h-4 w-4" />
{!collapsed && <span>Crear dataroom</span>}
</Button>,
)}
</div>
{/* Lista de temas */}
{loading ? (
<div className="text-sm text-slate-400 px-3 py-2 text-center">
{collapsed ? "..." : "Cargando..."}
</div>
))
) : Array.isArray(temas) && temas.length > 0 ? (
temas.map((tema) => (
<div key={tema} className="relative group">
{renderWithTooltip(
tema,
<Button
variant={selectedTema === tema ? "secondary" : "ghost"}
className={cn(
"w-full justify-start text-slate-300 hover:bg-slate-700 hover:text-slate-100",
selectedTema === tema && "bg-slate-700 text-slate-100",
collapsed ? "px-0 justify-center" : "pr-10",
)}
onClick={() => handleTemaSelect(tema)}
disabled={deletingTema === tema || disabled}
>
<FolderIcon
className={cn("h-4 w-4", !collapsed && "mr-2")}
/>
<span className={cn("truncate", collapsed && "sr-only")}>
{tema}
</span>
</Button>,
)}
{!collapsed && (
<button
onClick={(e) => handleDeleteTema(tema, e)}
disabled={deletingTema === tema || disabled}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-red-500/20 opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
title="Eliminar dataroom y colección"
>
<Trash2 className="h-4 w-4 text-red-400" />
</button>
)}
</div>
))
) : (
<div className="text-sm text-slate-400 px-3 py-2 text-center">
{Array.isArray(temas) && temas.length === 0
? "No hay datarooms"
: "Cargando datarooms..."}
</div>
)}
</div>
</div>
{/* Footer */}
<div
className={cn(
"p-4 border-t border-slate-700 space-y-2",
collapsed && "flex flex-col items-center gap-2",
)}
>
{onNavigateToSchemas &&
renderWithTooltip(
"Gestionar Schemas",
<Button
variant="default"
size="sm"
onClick={onNavigateToSchemas}
disabled={disabled}
className={cn(
"w-full justify-start bg-slate-700 text-slate-100 hover:bg-slate-600",
collapsed && "px-0 justify-center",
)}
>
<Database className={cn("h-4 w-4", !collapsed && "mr-2")} />
<span className={cn(collapsed && "sr-only")}>
Gestionar Schemas
</span>
</Button>,
)}
{renderWithTooltip(
"Actualizar datarooms",
<Button
variant="ghost"
size="sm"
onClick={loadTemas}
disabled={loading || disabled}
className={cn(
"w-full justify-start bg-slate-700/50 text-slate-200 hover:bg-slate-600 hover:text-slate-100 border border-slate-600",
collapsed && "px-0 justify-center",
)}
>
<RefreshCcw className={cn("mr-2 h-4 w-4", collapsed && "mr-0")} />
<span className={cn(collapsed && "sr-only")}>
Actualizar datarooms
</span>
</Button>,
)}
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-200 space-y-2">
{onNavigateToSchemas && (
<Button
variant="default"
size="sm"
onClick={onNavigateToSchemas}
disabled={disabled}
className="w-full"
>
<Database className="mr-2 h-4 w-4" />
Gestionar Schemas
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={loadTemas}
disabled={loading || disabled}
className="w-full"
<Dialog
open={createDialogOpen}
onOpenChange={handleCreateDialogOpenChange}
>
<DialogContent
className="max-w-sm"
aria-describedby="create-dataroom-description"
>
Actualizar temas
</Button>
</div>
</div>
)
}
<DialogHeader>
<DialogTitle>Crear dataroom</DialogTitle>
<DialogDescription id="create-dataroom-description">
Define un nombre único para organizar tus archivos.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="dataroom-name">Nombre del dataroom</Label>
<Input
id="dataroom-name"
value={newDataroomName}
onChange={(e) => {
setNewDataroomName(e.target.value);
if (createError) {
setCreateError(null);
}
}}
placeholder="Ej: normativa, contratos, fiscal..."
autoFocus
/>
{createError && (
<p className="text-sm text-red-500">{createError}</p>
)}
</div>
</div>
<DialogFooter className="mt-4">
<Button
variant="outline"
onClick={() => handleCreateDialogOpenChange(false)}
disabled={creatingDataroom}
>
Cancelar
</Button>
<Button
onClick={handleCreateDataroom}
disabled={creatingDataroom || newDataroomName.trim() === ""}
>
{creatingDataroom ? "Creando..." : "Crear dataroom"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,206 @@
import React, { useState } from "react";
import {
Globe,
ExternalLink,
Search,
ChevronDown,
ChevronRight,
Info,
Star,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface SearchResult {
title: string;
url: string;
content: string;
score?: number;
}
interface WebSearchData {
query: string;
results: SearchResult[];
summary: string;
total_results: number;
}
interface WebSearchResultsProps {
data: WebSearchData;
}
const getScoreColor = (score?: number) => {
if (!score) return "text-gray-500";
if (score >= 0.8) return "text-green-600";
if (score >= 0.6) return "text-yellow-600";
return "text-gray-500";
};
const getScoreStars = (score?: number) => {
if (!score) return 0;
return Math.round(score * 5);
};
const truncateContent = (content: string, maxLength: number = 200) => {
if (content.length <= maxLength) return content;
return content.slice(0, maxLength) + "...";
};
export const WebSearchResults: React.FC<WebSearchResultsProps> = ({ data }) => {
const [expandedResults, setExpandedResults] = useState<Set<number>>(new Set());
const [showAllResults, setShowAllResults] = useState(false);
const { query, results, summary, total_results } = data;
const toggleResult = (index: number) => {
const newExpanded = new Set(expandedResults);
if (newExpanded.has(index)) {
newExpanded.delete(index);
} else {
newExpanded.add(index);
}
setExpandedResults(newExpanded);
};
const visibleResults = showAllResults ? results : results.slice(0, 3);
return (
<div className="w-full bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-green-50 to-emerald-50 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Globe className="w-6 h-6 text-green-600" />
<div>
<h3 className="font-semibold text-gray-900">Web Search Results</h3>
<div className="flex items-center gap-2 text-xs text-gray-600">
<Search className="w-3 h-3" />
<span>"{query}"</span>
</div>
</div>
</div>
<div className="text-right">
<div className="text-sm font-medium text-gray-900">{results.length}</div>
<div className="text-xs text-gray-600">
of {total_results} results
</div>
</div>
</div>
</div>
<div className="p-4 space-y-4">
{/* Summary */}
{summary && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium text-blue-900 text-sm mb-1">
Summary
</h4>
<p className="text-sm text-blue-800">{summary}</p>
</div>
</div>
</div>
)}
{/* Search Results */}
<div className="space-y-3">
{visibleResults.map((result, index) => {
const isExpanded = expandedResults.has(index);
const stars = getScoreStars(result.score);
return (
<div key={index} className="border border-gray-200 rounded-lg overflow-hidden">
<div className="p-3">
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 text-sm mb-1 line-clamp-2">
{result.title}
</h4>
<div className="flex items-center gap-2">
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1 truncate"
>
<ExternalLink className="w-3 h-3 flex-shrink-0" />
{new URL(result.url).hostname}
</a>
{result.score && (
<div className="flex items-center gap-1">
<div className="flex">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={cn(
"w-3 h-3",
i < stars
? "text-yellow-400 fill-current"
: "text-gray-300"
)}
/>
))}
</div>
<span className={cn("text-xs font-medium", getScoreColor(result.score))}>
{Math.round((result.score || 0) * 100)}%
</span>
</div>
)}
</div>
</div>
</div>
<div className="text-sm text-gray-700">
{isExpanded ? result.content : truncateContent(result.content)}
</div>
{result.content.length > 200 && (
<button
onClick={() => toggleResult(index)}
className="mt-2 flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 font-medium"
>
{isExpanded ? (
<>
<ChevronDown className="w-3 h-3" />
Show less
</>
) : (
<>
<ChevronRight className="w-3 h-3" />
Read more
</>
)}
</button>
)}
</div>
</div>
);
})}
</div>
{/* Show More/Less Button */}
{results.length > 3 && (
<div className="text-center">
<button
onClick={() => setShowAllResults(!showAllResults)}
className="text-sm text-blue-600 hover:text-blue-800 font-medium px-4 py-2 rounded-lg hover:bg-blue-50 transition-colors"
>
{showAllResults
? "Show fewer results"
: `Show ${results.length - 3} more results`}
</button>
</div>
)}
{/* No Results */}
{results.length === 0 && (
<div className="text-center py-6">
<Search className="w-8 h-8 text-gray-400 mx-auto mb-2" />
<p className="text-sm text-gray-600">No results found for "{query}"</p>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,65 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { ComponentProps } from "react";
export type ActionsProps = ComponentProps<"div">;
export const Actions = ({ className, children, ...props }: ActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type ActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const Action = ({
tooltip,
children,
label,
className,
variant = "ghost",
size = "sm",
...props
}: ActionProps) => {
const button = (
<Button
className={cn(
"relative size-9 p-1.5 text-muted-foreground hover:text-foreground",
className
)}
size={size}
type="button"
variant={variant}
{...props}
>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};

View File

@@ -0,0 +1,147 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { type LucideIcon, XIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";
export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div
className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
className
)}
{...props}
/>
);
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactHeader = ({
className,
...props
}: ArtifactHeaderProps) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/50 px-4 py-3",
className
)}
{...props}
/>
);
export type ArtifactCloseProps = ComponentProps<typeof Button>;
export const ArtifactClose = ({
className,
children,
size = "sm",
variant = "ghost",
...props
}: ArtifactCloseProps) => (
<Button
className={cn(
"size-8 p-0 text-muted-foreground hover:text-foreground",
className
)}
size={size}
type="button"
variant={variant}
{...props}
>
{children ?? <XIcon className="size-4" />}
<span className="sr-only">Close</span>
</Button>
);
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
<p
className={cn("font-medium text-foreground text-sm", className)}
{...props}
/>
);
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
export const ArtifactDescription = ({
className,
...props
}: ArtifactDescriptionProps) => (
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
);
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactActions = ({
className,
...props
}: ArtifactActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props} />
);
export type ArtifactActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
icon?: LucideIcon;
};
export const ArtifactAction = ({
tooltip,
label,
icon: Icon,
children,
className,
size = "sm",
variant = "ghost",
...props
}: ArtifactActionProps) => {
const button = (
<Button
className={cn(
"size-8 p-0 text-muted-foreground hover:text-foreground",
className
)}
size={size}
type="button"
variant={variant}
{...props}
>
{Icon ? <Icon className="size-4" /> : children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>;
export const ArtifactContent = ({
className,
...props
}: ArtifactContentProps) => (
<div className={cn("flex-1 overflow-auto p-4", className)} {...props} />
);

View File

@@ -0,0 +1,212 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, useContext, useEffect, useState } from "react";
type BranchContextType = {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
};
const BranchContext = createContext<BranchContextType | null>(null);
const useBranch = () => {
const context = useContext(BranchContext);
if (!context) {
throw new Error("Branch components must be used within Branch");
}
return context;
};
export type BranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const Branch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: BranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
const goToPrevious = () => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
};
const goToNext = () => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
};
const contextValue: BranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
};
return (
<BranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</BranchContext.Provider>
);
};
export type BranchMessagesProps = HTMLAttributes<HTMLDivElement>;
export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
const { currentBranch, setBranches, branches } = useBranch();
const childrenArray = Array.isArray(children) ? children : [children];
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const BranchSelector = ({
className,
from,
...props
}: BranchSelectorProps) => {
const { totalBranches } = useBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<div
className={cn(
"flex items-center gap-2 self-end px-10",
from === "assistant" ? "justify-start" : "justify-end",
className
)}
{...props}
/>
);
};
export type BranchPreviousProps = ComponentProps<typeof Button>;
export const BranchPrevious = ({
className,
children,
...props
}: BranchPreviousProps) => {
const { goToPrevious, totalBranches } = useBranch();
return (
<Button
aria-label="Previous branch"
className={cn(
"size-7 shrink-0 rounded-full text-muted-foreground transition-colors",
"hover:bg-accent hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50",
className
)}
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type BranchNextProps = ComponentProps<typeof Button>;
export const BranchNext = ({
className,
children,
...props
}: BranchNextProps) => {
const { goToNext, totalBranches } = useBranch();
return (
<Button
aria-label="Next branch"
className={cn(
"size-7 shrink-0 rounded-full text-muted-foreground transition-colors",
"hover:bg-accent hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-50",
className
)}
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type BranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const BranchPage = ({ className, ...props }: BranchPageProps) => {
const { currentBranch, totalBranches } = useBranch();
return (
<span
className={cn(
"font-medium text-muted-foreground text-xs tabular-nums",
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</span>
);
};

View File

@@ -0,0 +1,22 @@
import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react";
import type { ReactNode } from "react";
import "@xyflow/react/dist/style.css";
type CanvasProps = ReactFlowProps & {
children?: ReactNode;
};
export const Canvas = ({ children, ...props }: CanvasProps) => (
<ReactFlow
deleteKeyCode={["Backspace", "Delete"]}
fitView
panOnDrag={false}
panOnScroll
selectionOnDrag={true}
zoomOnDoubleClick={false}
{...props}
>
<Background bgColor="var(--sidebar)" />
{children}
</ReactFlow>
);

View File

@@ -0,0 +1,228 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import {
BrainIcon,
ChevronDownIcon,
DotIcon,
type LucideIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useMemo } from "react";
type ChainOfThoughtContextValue = {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
};
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
null
);
const useChainOfThought = () => {
const context = useContext(ChainOfThoughtContext);
if (!context) {
throw new Error(
"ChainOfThought components must be used within ChainOfThought"
);
}
return context;
};
export type ChainOfThoughtProps = ComponentProps<"div"> & {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
};
export const ChainOfThought = memo(
({
className,
open,
defaultOpen = false,
onOpenChange,
children,
...props
}: ChainOfThoughtProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const chainOfThoughtContext = useMemo(
() => ({ isOpen, setIsOpen }),
[isOpen, setIsOpen]
);
return (
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
<div
className={cn("not-prose max-w-prose space-y-4", className)}
{...props}
>
{children}
</div>
</ChainOfThoughtContext.Provider>
);
}
);
export type ChainOfThoughtHeaderProps = ComponentProps<
typeof CollapsibleTrigger
>;
export const ChainOfThoughtHeader = memo(
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
const { isOpen, setIsOpen } = useChainOfThought();
return (
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
<BrainIcon className="size-4" />
<span className="flex-1 text-left">
{children ?? "Chain of Thought"}
</span>
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</CollapsibleTrigger>
</Collapsible>
);
}
);
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
icon?: LucideIcon;
label: ReactNode;
description?: ReactNode;
status?: "complete" | "active" | "pending";
};
export const ChainOfThoughtStep = memo(
({
className,
icon: Icon = DotIcon,
label,
description,
status = "complete",
children,
...props
}: ChainOfThoughtStepProps) => {
const statusStyles = {
complete: "text-muted-foreground",
active: "text-foreground",
pending: "text-muted-foreground/50",
};
return (
<div
className={cn(
"flex gap-2 text-sm",
statusStyles[status],
"fade-in-0 slide-in-from-top-2 animate-in",
className
)}
{...props}
>
<div className="relative mt-0.5">
<Icon className="size-4" />
<div className="-mx-px absolute top-7 bottom-0 left-1/2 w-px bg-border" />
</div>
<div className="flex-1 space-y-2">
<div>{label}</div>
{description && (
<div className="text-muted-foreground text-xs">{description}</div>
)}
{children}
</div>
</div>
);
}
);
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
export const ChainOfThoughtSearchResults = memo(
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
<div className={cn("flex items-center gap-2", className)} {...props} />
)
);
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
export const ChainOfThoughtSearchResult = memo(
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
<Badge
className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
variant="secondary"
{...props}
>
{children}
</Badge>
)
);
export type ChainOfThoughtContentProps = ComponentProps<
typeof CollapsibleContent
>;
export const ChainOfThoughtContent = memo(
({ className, children, ...props }: ChainOfThoughtContentProps) => {
const { isOpen } = useChainOfThought();
return (
<Collapsible open={isOpen}>
<CollapsibleContent
className={cn(
"mt-2 space-y-3",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
{children}
</CollapsibleContent>
</Collapsible>
);
}
);
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
caption?: string;
};
export const ChainOfThoughtImage = memo(
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
<div className={cn("mt-2 space-y-2", className)} {...props}>
<div className="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
{children}
</div>
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
</div>
)
);
ChainOfThought.displayName = "ChainOfThought";
ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
ChainOfThoughtImage.displayName = "ChainOfThoughtImage";

View File

@@ -0,0 +1,179 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { Element } from "hast";
import { CheckIcon, CopyIcon } from "lucide-react";
import {
type ComponentProps,
createContext,
type HTMLAttributes,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki";
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
type CodeBlockContextType = {
code: string;
};
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
const lineNumberTransformer: ShikiTransformer = {
name: "line-numbers",
line(node: Element, line: number) {
node.children.unshift({
type: "element",
tagName: "span",
properties: {
className: [
"inline-block",
"min-w-10",
"mr-4",
"text-right",
"select-none",
"text-muted-foreground",
],
},
children: [{ type: "text", value: String(line) }],
});
},
};
export async function highlightCode(
code: string,
language: BundledLanguage,
showLineNumbers = false
) {
const transformers: ShikiTransformer[] = showLineNumbers
? [lineNumberTransformer]
: [];
return await Promise.all([
codeToHtml(code, {
lang: language,
theme: "one-light",
transformers,
}),
codeToHtml(code, {
lang: language,
theme: "one-dark-pro",
transformers,
}),
]);
}
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const [html, setHtml] = useState<string>("");
const [darkHtml, setDarkHtml] = useState<string>("");
const mounted = useRef(false);
useEffect(() => {
highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
if (!mounted.current) {
setHtml(light);
setDarkHtml(dark);
mounted.current = true;
}
});
return () => {
mounted.current = false;
};
}, [code, language, showLineNumbers]);
return (
<CodeBlockContext.Provider value={{ code }}>
<div
className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
)}
{...props}
>
<div className="relative">
<div
className="overflow-hidden dark:hidden [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: html }}
/>
<div
className="hidden overflow-hidden dark:block [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: darkHtml }}
/>
{children && (
<div className="absolute top-2 right-2 flex items-center gap-2">
{children}
</div>
)}
</div>
</div>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
};
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};

View File

@@ -0,0 +1,176 @@
"use client";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ToolUIPart } from "ai";
import {
type ComponentProps,
createContext,
type ReactNode,
useContext,
} from "react";
type ToolUIPartApproval =
| {
id: string;
approved?: never;
reason?: never;
}
| {
id: string;
approved: boolean;
reason?: string;
}
| {
id: string;
approved: true;
reason?: string;
}
| {
id: string;
approved: true;
reason?: string;
}
| {
id: string;
approved: false;
reason?: string;
}
| undefined;
type ConfirmationContextValue = {
approval: ToolUIPartApproval;
state: ToolUIPart["state"];
};
const ConfirmationContext = createContext<ConfirmationContextValue | null>(
null
);
const useConfirmation = () => {
const context = useContext(ConfirmationContext);
if (!context) {
throw new Error("Confirmation components must be used within Confirmation");
}
return context;
};
export type ConfirmationProps = ComponentProps<typeof Alert> & {
approval?: ToolUIPartApproval;
state: ToolUIPart["state"];
};
export const Confirmation = ({
className,
approval,
state,
...props
}: ConfirmationProps) => {
if (!approval || state === "input-streaming" || state === "input-available") {
return null;
}
return (
<ConfirmationContext.Provider value={{ approval, state }}>
<Alert className={cn("flex flex-col gap-2", className)} {...props} />
</ConfirmationContext.Provider>
);
};
export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>;
export const ConfirmationTitle = ({
className,
...props
}: ConfirmationTitleProps) => (
<AlertDescription className={cn("inline", className)} {...props} />
);
export type ConfirmationRequestProps = {
children?: ReactNode;
};
export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
const { state } = useConfirmation();
// Only show when approval is requested
if (state !== "approval-requested") {
return null;
}
return children;
};
export type ConfirmationAcceptedProps = {
children?: ReactNode;
};
export const ConfirmationAccepted = ({
children,
}: ConfirmationAcceptedProps) => {
const { approval, state } = useConfirmation();
// Only show when approved and in response states
if (
!approval?.approved ||
(state !== "approval-responded" &&
state !== "output-denied" &&
state !== "output-available")
) {
return null;
}
return children;
};
export type ConfirmationRejectedProps = {
children?: ReactNode;
};
export const ConfirmationRejected = ({
children,
}: ConfirmationRejectedProps) => {
const { approval, state } = useConfirmation();
// Only show when rejected and in response states
if (
approval?.approved !== false ||
(state !== "approval-responded" &&
state !== "output-denied" &&
state !== "output-available")
) {
return null;
}
return children;
};
export type ConfirmationActionsProps = ComponentProps<"div">;
export const ConfirmationActions = ({
className,
...props
}: ConfirmationActionsProps) => {
const { state } = useConfirmation();
// Only show when approval is requested
if (state !== "approval-requested") {
return null;
}
return (
<div
className={cn("flex items-center justify-end gap-2 self-end", className)}
{...props}
/>
);
};
export type ConfirmationActionProps = ComponentProps<typeof Button>;
export const ConfirmationAction = (props: ConfirmationActionProps) => (
<Button className="h-8 px-3 text-sm" type="button" {...props} />
);

View File

@@ -0,0 +1,28 @@
import type { ConnectionLineComponent } from "@xyflow/react";
const HALF = 0.5;
export const Connection: ConnectionLineComponent = ({
fromX,
fromY,
toX,
toY,
}) => (
<g>
<path
className="animated"
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
fill="none"
stroke="var(--color-ring)"
strokeWidth={1}
/>
<circle
cx={toX}
cy={toY}
fill="#fff"
r={3}
stroke="var(--color-ring)"
strokeWidth={1}
/>
</g>
);

View File

@@ -0,0 +1,408 @@
"use client";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import type { LanguageModelUsage } from "ai";
import { type ComponentProps, createContext, useContext } from "react";
import { getUsage } from "tokenlens";
const PERCENT_MAX = 100;
const ICON_RADIUS = 10;
const ICON_VIEWBOX = 24;
const ICON_CENTER = 12;
const ICON_STROKE_WIDTH = 2;
type ModelId = string;
type ContextSchema = {
usedTokens: number;
maxTokens: number;
usage?: LanguageModelUsage;
modelId?: ModelId;
};
const ContextContext = createContext<ContextSchema | null>(null);
const useContextValue = () => {
const context = useContext(ContextContext);
if (!context) {
throw new Error("Context components must be used within Context");
}
return context;
};
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
export const Context = ({
usedTokens,
maxTokens,
usage,
modelId,
...props
}: ContextProps) => (
<ContextContext.Provider
value={{
usedTokens,
maxTokens,
usage,
modelId,
}}
>
<HoverCard closeDelay={0} openDelay={0} {...props} />
</ContextContext.Provider>
);
const ContextIcon = () => {
const { usedTokens, maxTokens } = useContextValue();
const circumference = 2 * Math.PI * ICON_RADIUS;
const usedPercent = usedTokens / maxTokens;
const dashOffset = circumference * (1 - usedPercent);
return (
<svg
aria-label="Model context usage"
height="20"
role="img"
style={{ color: "currentcolor" }}
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
width="20"
>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.25"
r={ICON_RADIUS}
stroke="currentColor"
strokeWidth={ICON_STROKE_WIDTH}
/>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
fill="none"
opacity="0.7"
r={ICON_RADIUS}
stroke="currentColor"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={dashOffset}
strokeLinecap="round"
strokeWidth={ICON_STROKE_WIDTH}
style={{ transformOrigin: "center", transform: "rotate(-90deg)" }}
/>
</svg>
);
};
export type ContextTriggerProps = ComponentProps<typeof Button>;
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
const { usedTokens, maxTokens } = useContextValue();
const usedPercent = usedTokens / maxTokens;
const renderedPercent = new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
}).format(usedPercent);
return (
<HoverCardTrigger asChild>
{children ?? (
<Button type="button" variant="ghost" {...props}>
<span className="font-medium text-muted-foreground">
{renderedPercent}
</span>
<ContextIcon />
</Button>
)}
</HoverCardTrigger>
);
};
export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
export const ContextContent = ({
className,
...props
}: ContextContentProps) => (
<HoverCardContent
className={cn("min-w-60 divide-y overflow-hidden p-0", className)}
{...props}
/>
);
export type ContextContentHeaderProps = ComponentProps<"div">;
export const ContextContentHeader = ({
children,
className,
...props
}: ContextContentHeaderProps) => {
const { usedTokens, maxTokens } = useContextValue();
const usedPercent = usedTokens / maxTokens;
const displayPct = new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
}).format(usedPercent);
const used = new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(usedTokens);
const total = new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(maxTokens);
return (
<div className={cn("w-full space-y-2 p-3", className)} {...props}>
{children ?? (
<>
<div className="flex items-center justify-between gap-3 text-xs">
<p>{displayPct}</p>
<p className="font-mono text-muted-foreground">
{used} / {total}
</p>
</div>
<div className="space-y-2">
<Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
</div>
</>
)}
</div>
);
};
export type ContextContentBodyProps = ComponentProps<"div">;
export const ContextContentBody = ({
children,
className,
...props
}: ContextContentBodyProps) => (
<div className={cn("w-full p-3", className)} {...props}>
{children}
</div>
);
export type ContextContentFooterProps = ComponentProps<"div">;
export const ContextContentFooter = ({
children,
className,
...props
}: ContextContentFooterProps) => {
const { modelId, usage } = useContextValue();
const costUSD = modelId
? getUsage({
modelId,
usage: {
input: usage?.inputTokens ?? 0,
output: usage?.outputTokens ?? 0,
},
}).costUSD?.totalUSD
: undefined;
const totalCost = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(costUSD ?? 0);
return (
<div
className={cn(
"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs",
className
)}
{...props}
>
{children ?? (
<>
<span className="text-muted-foreground">Total cost</span>
<span>{totalCost}</span>
</>
)}
</div>
);
};
export type ContextInputUsageProps = ComponentProps<"div">;
export const ContextInputUsage = ({
className,
children,
...props
}: ContextInputUsageProps) => {
const { usage, modelId } = useContextValue();
const inputTokens = usage?.inputTokens ?? 0;
if (children) {
return children;
}
if (!inputTokens) {
return null;
}
const inputCost = modelId
? getUsage({
modelId,
usage: { input: inputTokens, output: 0 },
}).costUSD?.totalUSD
: undefined;
const inputCostText = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(inputCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Input</span>
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
</div>
);
};
export type ContextOutputUsageProps = ComponentProps<"div">;
export const ContextOutputUsage = ({
className,
children,
...props
}: ContextOutputUsageProps) => {
const { usage, modelId } = useContextValue();
const outputTokens = usage?.outputTokens ?? 0;
if (children) {
return children;
}
if (!outputTokens) {
return null;
}
const outputCost = modelId
? getUsage({
modelId,
usage: { input: 0, output: outputTokens },
}).costUSD?.totalUSD
: undefined;
const outputCostText = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(outputCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Output</span>
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
</div>
);
};
export type ContextReasoningUsageProps = ComponentProps<"div">;
export const ContextReasoningUsage = ({
className,
children,
...props
}: ContextReasoningUsageProps) => {
const { usage, modelId } = useContextValue();
const reasoningTokens = usage?.reasoningTokens ?? 0;
if (children) {
return children;
}
if (!reasoningTokens) {
return null;
}
const reasoningCost = modelId
? getUsage({
modelId,
usage: { reasoningTokens },
}).costUSD?.totalUSD
: undefined;
const reasoningCostText = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(reasoningCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Reasoning</span>
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
</div>
);
};
export type ContextCacheUsageProps = ComponentProps<"div">;
export const ContextCacheUsage = ({
className,
children,
...props
}: ContextCacheUsageProps) => {
const { usage, modelId } = useContextValue();
const cacheTokens = usage?.cachedInputTokens ?? 0;
if (children) {
return children;
}
if (!cacheTokens) {
return null;
}
const cacheCost = modelId
? getUsage({
modelId,
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
}).costUSD?.totalUSD
: undefined;
const cacheCostText = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cacheCost ?? 0);
return (
<div
className={cn("flex items-center justify-between text-xs", className)}
{...props}
>
<span className="text-muted-foreground">Cache</span>
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
</div>
);
};
const TokensWithCost = ({
tokens,
costText,
}: {
tokens?: number;
costText?: string;
}) => (
<span>
{tokens === undefined
? "—"
: new Intl.NumberFormat("en-US", {
notation: "compact",
}).format(tokens)}
{costText ? (
<span className="ml-2 text-muted-foreground"> {costText}</span>
) : null}
</span>
);

View File

@@ -0,0 +1,18 @@
"use client";
import { cn } from "@/lib/utils";
import { Controls as ControlsPrimitive } from "@xyflow/react";
import type { ComponentProps } from "react";
export type ControlsProps = ComponentProps<typeof ControlsPrimitive>;
export const Controls = ({ className, ...props }: ControlsProps) => (
<ControlsPrimitive
className={cn(
"gap-px overflow-hidden rounded-md border bg-card p-1 shadow-none!",
"[&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!",
className
)}
{...props}
/>
);

View File

@@ -0,0 +1,100 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ArrowDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-auto", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};

View File

@@ -0,0 +1,140 @@
import {
BaseEdge,
type EdgeProps,
getBezierPath,
getSimpleBezierPath,
type InternalNode,
type Node,
Position,
useInternalNode,
} from "@xyflow/react";
const Temporary = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
}: EdgeProps) => {
const [edgePath] = getSimpleBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
return (
<BaseEdge
className="stroke-1 stroke-ring"
id={id}
path={edgePath}
style={{
strokeDasharray: "5, 5",
}}
/>
);
};
const getHandleCoordsByPosition = (
node: InternalNode<Node>,
handlePosition: Position
) => {
// Choose the handle type based on position - Left is for target, Right is for source
const handleType = handlePosition === Position.Left ? "target" : "source";
const handle = node.internals.handleBounds?.[handleType]?.find(
(h) => h.position === handlePosition
);
if (!handle) {
return [0, 0] as const;
}
let offsetX = handle.width / 2;
let offsetY = handle.height / 2;
// this is a tiny detail to make the markerEnd of an edge visible.
// The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset
// when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position
switch (handlePosition) {
case Position.Left:
offsetX = 0;
break;
case Position.Right:
offsetX = handle.width;
break;
case Position.Top:
offsetY = 0;
break;
case Position.Bottom:
offsetY = handle.height;
break;
default:
throw new Error(`Invalid handle position: ${handlePosition}`);
}
const x = node.internals.positionAbsolute.x + handle.x + offsetX;
const y = node.internals.positionAbsolute.y + handle.y + offsetY;
return [x, y] as const;
};
const getEdgeParams = (
source: InternalNode<Node>,
target: InternalNode<Node>
) => {
const sourcePos = Position.Right;
const [sx, sy] = getHandleCoordsByPosition(source, sourcePos);
const targetPos = Position.Left;
const [tx, ty] = getHandleCoordsByPosition(target, targetPos);
return {
sx,
sy,
tx,
ty,
sourcePos,
targetPos,
};
};
const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
const sourceNode = useInternalNode(source);
const targetNode = useInternalNode(target);
if (!(sourceNode && targetNode)) {
return null;
}
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
sourceNode,
targetNode
);
const [edgePath] = getBezierPath({
sourceX: sx,
sourceY: sy,
sourcePosition: sourcePos,
targetX: tx,
targetY: ty,
targetPosition: targetPos,
});
return (
<>
<BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} />
<circle fill="var(--primary)" r="4">
<animateMotion dur="2s" path={edgePath} repeatCount="indefinite" />
</circle>
</>
);
};
export const Edge = {
Temporary,
Animated,
};

View File

@@ -0,0 +1,24 @@
import { cn } from "@/lib/utils";
import type { Experimental_GeneratedImage } from "ai";
export type ImageProps = Experimental_GeneratedImage & {
className?: string;
alt?: string;
};
export const Image = ({
base64,
uint8Array,
mediaType,
...props
}: ImageProps) => (
<img
{...props}
alt={props.alt}
className={cn(
"h-auto max-w-full overflow-hidden rounded-md",
props.className
)}
src={`data:${mediaType};base64,${base64}`}
/>
);

View File

@@ -0,0 +1,287 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
Carousel,
type CarouselApi,
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import {
type ComponentProps,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
export type InlineCitationProps = ComponentProps<"span">;
export const InlineCitation = ({
className,
...props
}: InlineCitationProps) => (
<span
className={cn("group inline items-center gap-1", className)}
{...props}
/>
);
export type InlineCitationTextProps = ComponentProps<"span">;
export const InlineCitationText = ({
className,
...props
}: InlineCitationTextProps) => (
<span
className={cn("transition-colors group-hover:bg-accent", className)}
{...props}
/>
);
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
export const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} />
);
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
sources: string[];
};
export const InlineCitationCardTrigger = ({
sources,
className,
...props
}: InlineCitationCardTriggerProps) => (
<HoverCardTrigger asChild>
<Badge
className={cn("ml-1 rounded-full", className)}
variant="secondary"
{...props}
>
{sources[0] ? (
<>
{new URL(sources[0]).hostname}{" "}
{sources.length > 1 && `+${sources.length - 1}`}
</>
) : (
"unknown"
)}
</Badge>
</HoverCardTrigger>
);
export type InlineCitationCardBodyProps = ComponentProps<"div">;
export const InlineCitationCardBody = ({
className,
...props
}: InlineCitationCardBodyProps) => (
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
);
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
const useCarouselApi = () => {
const context = useContext(CarouselApiContext);
return context;
};
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
export const InlineCitationCarousel = ({
className,
children,
...props
}: InlineCitationCarouselProps) => {
const [api, setApi] = useState<CarouselApi>();
return (
<CarouselApiContext.Provider value={api}>
<Carousel className={cn("w-full", className)} setApi={setApi} {...props}>
{children}
</Carousel>
</CarouselApiContext.Provider>
);
};
export type InlineCitationCarouselContentProps = ComponentProps<"div">;
export const InlineCitationCarouselContent = (
props: InlineCitationCarouselContentProps
) => <CarouselContent {...props} />;
export type InlineCitationCarouselItemProps = ComponentProps<"div">;
export const InlineCitationCarouselItem = ({
className,
...props
}: InlineCitationCarouselItemProps) => (
<CarouselItem
className={cn("w-full space-y-2 p-4 pl-8", className)}
{...props}
/>
);
export type InlineCitationCarouselHeaderProps = ComponentProps<"div">;
export const InlineCitationCarouselHeader = ({
className,
...props
}: InlineCitationCarouselHeaderProps) => (
<div
className={cn(
"flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2",
className
)}
{...props}
/>
);
export type InlineCitationCarouselIndexProps = ComponentProps<"div">;
export const InlineCitationCarouselIndex = ({
children,
className,
...props
}: InlineCitationCarouselIndexProps) => {
const api = useCarouselApi();
const [current, setCurrent] = useState(0);
const [count, setCount] = useState(0);
useEffect(() => {
if (!api) {
return;
}
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on("select", () => {
setCurrent(api.selectedScrollSnap() + 1);
});
}, [api]);
return (
<div
className={cn(
"flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs",
className
)}
{...props}
>
{children ?? `${current}/${count}`}
</div>
);
};
export type InlineCitationCarouselPrevProps = ComponentProps<"button">;
export const InlineCitationCarouselPrev = ({
className,
...props
}: InlineCitationCarouselPrevProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollPrev();
}
}, [api]);
return (
<button
aria-label="Previous"
className={cn("shrink-0", className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowLeftIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationCarouselNextProps = ComponentProps<"button">;
export const InlineCitationCarouselNext = ({
className,
...props
}: InlineCitationCarouselNextProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollNext();
}
}, [api]);
return (
<button
aria-label="Next"
className={cn("shrink-0", className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowRightIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationSourceProps = ComponentProps<"div"> & {
title?: string;
url?: string;
description?: string;
};
export const InlineCitationSource = ({
title,
url,
description,
className,
children,
...props
}: InlineCitationSourceProps) => (
<div className={cn("space-y-1", className)} {...props}>
{title && (
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
)}
{url && (
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
)}
{description && (
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
{description}
</p>
)}
{children}
</div>
);
export type InlineCitationQuoteProps = ComponentProps<"blockquote">;
export const InlineCitationQuote = ({
children,
className,
...props
}: InlineCitationQuoteProps) => (
<blockquote
className={cn(
"border-muted border-l-2 pl-3 text-muted-foreground text-sm italic",
className
)}
{...props}
>
{children}
</blockquote>
);

View File

@@ -0,0 +1,96 @@
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "react";
type LoaderIconProps = {
size?: number;
};
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
<svg
height={size}
strokeLinejoin="round"
style={{ color: "currentcolor" }}
viewBox="0 0 16 16"
width={size}
>
<title>Loader</title>
<g clipPath="url(#clip0_2393_1490)">
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
<path
d="M8 16V12"
opacity="0.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 1.52783L5.64887 4.7639"
opacity="0.9"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 1.52783L10.3511 4.7639"
opacity="0.1"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 14.472L10.3511 11.236"
opacity="0.4"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 14.472L5.64887 11.236"
opacity="0.6"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 5.52783L11.8043 6.7639"
opacity="0.2"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 10.472L4.19583 9.23598"
opacity="0.7"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 10.4722L11.8043 9.2361"
opacity="0.3"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 5.52783L4.19583 6.7639"
opacity="0.8"
stroke="currentColor"
strokeWidth="1.5"
/>
</g>
<defs>
<clipPath id="clip0_2393_1490">
<rect fill="white" height="16" width="16" />
</clipPath>
</defs>
</svg>
);
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
size?: number;
};
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
<div
className={cn(
"inline-flex animate-spin items-center justify-center",
className
)}
{...props}
>
<LoaderIcon size={size} />
</div>
);

View File

@@ -0,0 +1,335 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ButtonGroup,
ButtonGroupText,
} from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[80%] gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex flex-col gap-2 overflow-hidden rounded-lg text-sm",
"group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
type MessageBranchContextType = {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
};
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch"
);
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
const goToPrevious = () => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
};
const goToNext = () => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
};
const contextValue: MessageBranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
};
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({
children,
...props
}: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = Array.isArray(children) ? children : [children];
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const MessageBranchSelector = ({
className,
from,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
className,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
);
MessageResponse.displayName = "MessageResponse";
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
)}
{...props}
>
{children}
</div>
);

View File

@@ -0,0 +1,200 @@
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogTrigger,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { ComponentProps } from "react";
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
export const ModelSelector = (props: ModelSelectorProps) => (
<Dialog {...props} />
);
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
<DialogTrigger {...props} />
);
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent>;
export const ModelSelectorContent = ({
className,
children,
...props
}: ModelSelectorContentProps) => (
<DialogContent className={cn("p-0", className)} {...props}>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
{children}
</Command>
</DialogContent>
);
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
<CommandDialog {...props} />
);
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
export const ModelSelectorInput = ({
className,
...props
}: ModelSelectorInputProps) => (
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
);
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
export const ModelSelectorList = (props: ModelSelectorListProps) => (
<CommandList {...props} />
);
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
<CommandEmpty {...props} />
);
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
<CommandGroup {...props} />
);
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
<CommandItem {...props} />
);
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
<CommandShortcut {...props} />
);
export type ModelSelectorSeparatorProps = ComponentProps<
typeof CommandSeparator
>;
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
<CommandSeparator {...props} />
);
export type ModelSelectorLogoProps = Omit<
ComponentProps<"img">,
"src" | "alt"
> & {
provider:
| "moonshotai-cn"
| "lucidquery"
| "moonshotai"
| "zai-coding-plan"
| "alibaba"
| "xai"
| "vultr"
| "nvidia"
| "upstage"
| "groq"
| "github-copilot"
| "mistral"
| "vercel"
| "nebius"
| "deepseek"
| "alibaba-cn"
| "google-vertex-anthropic"
| "venice"
| "chutes"
| "cortecs"
| "github-models"
| "togetherai"
| "azure"
| "baseten"
| "huggingface"
| "opencode"
| "fastrouter"
| "google"
| "google-vertex"
| "cloudflare-workers-ai"
| "inception"
| "wandb"
| "openai"
| "zhipuai-coding-plan"
| "perplexity"
| "openrouter"
| "zenmux"
| "v0"
| "iflowcn"
| "synthetic"
| "deepinfra"
| "zhipuai"
| "submodel"
| "zai"
| "inference"
| "requesty"
| "morph"
| "lmstudio"
| "anthropic"
| "aihubmix"
| "fireworks-ai"
| "modelscope"
| "llama"
| "scaleway"
| "amazon-bedrock"
| "cerebras"
| (string & {});
};
export const ModelSelectorLogo = ({
provider,
className,
...props
}: ModelSelectorLogoProps) => (
<img
{...props}
alt={`${provider} logo`}
className={cn("size-3", className)}
height={12}
src={`https://models.dev/logos/${provider}.svg`}
width={12}
/>
);
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
export const ModelSelectorLogoGroup = ({
className,
...props
}: ModelSelectorLogoGroupProps) => (
<div
className={cn(
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 [&>img]:ring-border",
className
)}
{...props}
/>
);
export type ModelSelectorNameProps = ComponentProps<"span">;
export const ModelSelectorName = ({
className,
...props
}: ModelSelectorNameProps) => (
<span className={cn("flex-1 truncate text-left", className)} {...props} />
);

View File

@@ -0,0 +1,71 @@
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { Handle, Position } from "@xyflow/react";
import type { ComponentProps } from "react";
export type NodeProps = ComponentProps<typeof Card> & {
handles: {
target: boolean;
source: boolean;
};
};
export const Node = ({ handles, className, ...props }: NodeProps) => (
<Card
className={cn(
"node-container relative size-full h-auto w-sm gap-0 rounded-md p-0",
className
)}
{...props}
>
{handles.target && <Handle position={Position.Left} type="target" />}
{handles.source && <Handle position={Position.Right} type="source" />}
{props.children}
</Card>
);
export type NodeHeaderProps = ComponentProps<typeof CardHeader>;
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
<CardHeader
className={cn("gap-0.5 rounded-t-md border-b bg-secondary p-3!", className)}
{...props}
/>
);
export type NodeTitleProps = ComponentProps<typeof CardTitle>;
export const NodeTitle = (props: NodeTitleProps) => <CardTitle {...props} />;
export type NodeDescriptionProps = ComponentProps<typeof CardDescription>;
export const NodeDescription = (props: NodeDescriptionProps) => (
<CardDescription {...props} />
);
export type NodeActionProps = ComponentProps<typeof CardAction>;
export const NodeAction = (props: NodeActionProps) => <CardAction {...props} />;
export type NodeContentProps = ComponentProps<typeof CardContent>;
export const NodeContent = ({ className, ...props }: NodeContentProps) => (
<CardContent className={cn("p-3", className)} {...props} />
);
export type NodeFooterProps = ComponentProps<typeof CardFooter>;
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
<CardFooter
className={cn("rounded-b-md border-t bg-secondary p-3!", className)}
{...props}
/>
);

View File

@@ -0,0 +1,365 @@
"use client";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import {
ChevronDownIcon,
ExternalLinkIcon,
MessageCircleIcon,
} from "lucide-react";
import { type ComponentProps, createContext, useContext } from "react";
const providers = {
github: {
title: "Open in GitHub",
createUrl: (url: string) => url,
icon: (
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
),
},
scira: {
title: "Open in Scira",
createUrl: (q: string) =>
`https://scira.ai/?${new URLSearchParams({
q,
})}`,
icon: (
<svg
fill="none"
height="934"
viewBox="0 0 910 934"
width="910"
xmlns="http://www.w3.org/2000/svg"
>
<title>Scira AI</title>
<path
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
fill="currentColor"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="8"
/>
<path
d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z"
fill="currentColor"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="8"
/>
<path
d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="20"
/>
<path
d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="20"
/>
<path
d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z"
fill="currentColor"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="8"
/>
<path
d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z"
fill="currentColor"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="8"
/>
<path
d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="30"
/>
</svg>
),
},
chatgpt: {
title: "Open in ChatGPT",
createUrl: (prompt: string) =>
`https://chatgpt.com/?${new URLSearchParams({
hints: "search",
prompt,
})}`,
icon: (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>OpenAI</title>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
</svg>
),
},
claude: {
title: "Open in Claude",
createUrl: (q: string) =>
`https://claude.ai/new?${new URLSearchParams({
q,
})}`,
icon: (
<svg
fill="currentColor"
role="img"
viewBox="0 0 12 12"
xmlns="http://www.w3.org/2000/svg"
>
<title>Claude</title>
<path
clipRule="evenodd"
d="M2.3545 7.9775L4.7145 6.654L4.7545 6.539L4.7145 6.475H4.6L4.205 6.451L2.856 6.4145L1.6865 6.366L0.5535 6.305L0.268 6.2445L0 5.892L0.0275 5.716L0.2675 5.5555L0.6105 5.5855L1.3705 5.637L2.5095 5.716L3.3355 5.7645L4.56 5.892H4.7545L4.782 5.8135L4.715 5.7645L4.6635 5.716L3.4845 4.918L2.2085 4.074L1.5405 3.588L1.1785 3.3425L0.9965 3.1115L0.9175 2.6075L1.2455 2.2465L1.686 2.2765L1.7985 2.307L2.245 2.65L3.199 3.388L4.4445 4.3045L4.627 4.4565L4.6995 4.405L4.709 4.3685L4.627 4.2315L3.9495 3.0085L3.2265 1.7635L2.9045 1.2475L2.8195 0.938C2.78711 0.819128 2.76965 0.696687 2.7675 0.5735L3.1415 0.067L3.348 0L3.846 0.067L4.056 0.249L4.366 0.956L4.867 2.0705L5.6445 3.5855L5.8725 4.0345L5.994 4.4505L6.0395 4.578H6.1185V4.505L6.1825 3.652L6.301 2.6045L6.416 1.257L6.456 0.877L6.644 0.422L7.0175 0.176L7.3095 0.316L7.5495 0.6585L7.516 0.8805L7.373 1.806L7.0935 3.2575L6.9115 4.2285H7.0175L7.139 4.1075L7.6315 3.4545L8.4575 2.4225L8.8225 2.0125L9.2475 1.5605L9.521 1.345H10.0375L10.4175 1.9095L10.2475 2.4925L9.7155 3.166L9.275 3.737L8.643 4.587L8.248 5.267L8.2845 5.322L8.3785 5.312L9.8065 5.009L10.578 4.869L11.4985 4.7115L11.915 4.9055L11.9605 5.103L11.7965 5.5065L10.812 5.7495L9.6575 5.9805L7.938 6.387L7.917 6.402L7.9415 6.4325L8.716 6.5055L9.047 6.5235H9.858L11.368 6.636L11.763 6.897L12 7.216L11.9605 7.4585L11.353 7.7685L10.533 7.574L8.6185 7.119L7.9625 6.9545H7.8715V7.0095L8.418 7.5435L9.421 8.4485L10.6755 9.6135L10.739 9.9025L10.578 10.13L10.408 10.1055L9.3055 9.277L8.88 8.9035L7.917 8.0935H7.853V8.1785L8.075 8.503L9.2475 10.2635L9.3085 10.8035L9.2235 10.98L8.9195 11.0865L8.5855 11.0255L7.8985 10.063L7.191 8.9795L6.6195 8.008L6.5495 8.048L6.2125 11.675L6.0545 11.86L5.69 12L5.3865 11.7695L5.2255 11.396L5.3865 10.658L5.581 9.696L5.7385 8.931L5.8815 7.981L5.9665 7.665L5.9605 7.644L5.8905 7.653L5.1735 8.6365L4.0835 10.109L3.2205 11.0315L3.0135 11.1135L2.655 10.9285L2.6885 10.5975L2.889 10.303L4.083 8.785L4.803 7.844L5.268 7.301L5.265 7.222H5.2375L2.066 9.28L1.501 9.353L1.2575 9.125L1.288 8.752L1.4035 8.6305L2.3575 7.9745L2.3545 7.9775Z"
fillRule="evenodd"
/>
</svg>
),
},
t3: {
title: "Open in T3 Chat",
createUrl: (q: string) =>
`https://t3.chat/new?${new URLSearchParams({
q,
})}`,
icon: <MessageCircleIcon />,
},
v0: {
title: "Open in v0",
createUrl: (q: string) =>
`https://v0.app?${new URLSearchParams({
q,
})}`,
icon: (
<svg
fill="currentColor"
viewBox="0 0 147 70"
xmlns="http://www.w3.org/2000/svg"
>
<title>v0</title>
<path d="M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z" />
<path d="M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z" />
</svg>
),
},
cursor: {
title: "Open in Cursor",
createUrl: (text: string) => {
const url = new URL("https://cursor.com/link/prompt");
url.searchParams.set("text", text);
return url.toString();
},
icon: (
<svg
version="1.1"
viewBox="0 0 466.73 532.09"
xmlns="http://www.w3.org/2000/svg"
>
<title>Cursor</title>
<path
d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
fill="currentColor"
/>
</svg>
),
},
};
const OpenInContext = createContext<{ query: string } | undefined>(undefined);
const useOpenInContext = () => {
const context = useContext(OpenInContext);
if (!context) {
throw new Error("OpenIn components must be used within an OpenIn provider");
}
return context;
};
export type OpenInProps = ComponentProps<typeof DropdownMenu> & {
query: string;
};
export const OpenIn = ({ query, ...props }: OpenInProps) => (
<OpenInContext.Provider value={{ query }}>
<DropdownMenu {...props} />
</OpenInContext.Provider>
);
export type OpenInContentProps = ComponentProps<typeof DropdownMenuContent>;
export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
<DropdownMenuContent
align="start"
className={cn("w-[240px]", className)}
{...props}
/>
);
export type OpenInItemProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInItem = (props: OpenInItemProps) => (
<DropdownMenuItem {...props} />
);
export type OpenInLabelProps = ComponentProps<typeof DropdownMenuLabel>;
export const OpenInLabel = (props: OpenInLabelProps) => (
<DropdownMenuLabel {...props} />
);
export type OpenInSeparatorProps = ComponentProps<typeof DropdownMenuSeparator>;
export const OpenInSeparator = (props: OpenInSeparatorProps) => (
<DropdownMenuSeparator {...props} />
);
export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>;
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
<DropdownMenuTrigger {...props} asChild>
{children ?? (
<Button type="button" variant="outline">
Open in chat
<ChevronDownIcon className="size-4" />
</Button>
)}
</DropdownMenuTrigger>
);
export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.chatgpt.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.chatgpt.icon}</span>
<span className="flex-1">{providers.chatgpt.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
};
export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInClaude = (props: OpenInClaudeProps) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.claude.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.claude.icon}</span>
<span className="flex-1">{providers.claude.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
};
export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>;
export const OpenInT3 = (props: OpenInT3Props) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.t3.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.t3.icon}</span>
<span className="flex-1">{providers.t3.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
};
export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInScira = (props: OpenInSciraProps) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.scira.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.scira.icon}</span>
<span className="flex-1">{providers.scira.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
};
export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>;
export const OpenInv0 = (props: OpenInv0Props) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.v0.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.v0.icon}</span>
<span className="flex-1">{providers.v0.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
};
export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>;
export const OpenInCursor = (props: OpenInCursorProps) => {
const { query } = useOpenInContext();
return (
<DropdownMenuItem asChild {...props}>
<a
className="flex items-center gap-2"
href={providers.cursor.createUrl(query)}
rel="noopener"
target="_blank"
>
<span className="shrink-0">{providers.cursor.icon}</span>
<span className="flex-1">{providers.cursor.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
};

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
import { Panel as PanelPrimitive } from "@xyflow/react";
import type { ComponentProps } from "react";
type PanelProps = ComponentProps<typeof PanelPrimitive>;
export const Panel = ({ className, ...props }: PanelProps) => (
<PanelPrimitive
className={cn(
"m-4 overflow-hidden rounded-md border bg-card p-1",
className
)}
{...props}
/>
);

View File

@@ -0,0 +1,142 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { ChevronsUpDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { createContext, useContext } from "react";
import { Shimmer } from "./shimmer";
type PlanContextValue = {
isStreaming: boolean;
};
const PlanContext = createContext<PlanContextValue | null>(null);
const usePlan = () => {
const context = useContext(PlanContext);
if (!context) {
throw new Error("Plan components must be used within Plan");
}
return context;
};
export type PlanProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
};
export const Plan = ({
className,
isStreaming = false,
children,
...props
}: PlanProps) => (
<PlanContext.Provider value={{ isStreaming }}>
<Collapsible asChild data-slot="plan" {...props}>
<Card className={cn("shadow-none", className)}>{children}</Card>
</Collapsible>
</PlanContext.Provider>
);
export type PlanHeaderProps = ComponentProps<typeof CardHeader>;
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
<CardHeader
className={cn("flex items-start justify-between", className)}
data-slot="plan-header"
{...props}
/>
);
export type PlanTitleProps = Omit<
ComponentProps<typeof CardTitle>,
"children"
> & {
children: string;
};
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
const { isStreaming } = usePlan();
return (
<CardTitle data-slot="plan-title" {...props}>
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
</CardTitle>
);
};
export type PlanDescriptionProps = Omit<
ComponentProps<typeof CardDescription>,
"children"
> & {
children: string;
};
export const PlanDescription = ({
className,
children,
...props
}: PlanDescriptionProps) => {
const { isStreaming } = usePlan();
return (
<CardDescription
className={cn("text-balance", className)}
data-slot="plan-description"
{...props}
>
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
</CardDescription>
);
};
export type PlanActionProps = ComponentProps<typeof CardAction>;
export const PlanAction = (props: PlanActionProps) => (
<CardAction data-slot="plan-action" {...props} />
);
export type PlanContentProps = ComponentProps<typeof CardContent>;
export const PlanContent = (props: PlanContentProps) => (
<CollapsibleContent asChild>
<CardContent data-slot="plan-content" {...props} />
</CollapsibleContent>
);
export type PlanFooterProps = ComponentProps<"div">;
export const PlanFooter = (props: PlanFooterProps) => (
<CardFooter data-slot="plan-footer" {...props} />
);
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
<CollapsibleTrigger asChild>
<Button
className={cn("size-8", className)}
data-slot="plan-trigger"
size="icon"
variant="ghost"
{...props}
>
<ChevronsUpDownIcon className="size-4" />
<span className="sr-only">Toggle plan</span>
</Button>
</CollapsibleTrigger>
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,274 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
import type { ComponentProps } from "react";
export type QueueMessagePart = {
type: string;
text?: string;
url?: string;
filename?: string;
mediaType?: string;
};
export type QueueMessage = {
id: string;
parts: QueueMessagePart[];
};
export type QueueTodo = {
id: string;
title: string;
description?: string;
status?: "pending" | "completed";
};
export type QueueItemProps = ComponentProps<"li">;
export const QueueItem = ({ className, ...props }: QueueItemProps) => (
<li
className={cn(
"group flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors hover:bg-muted",
className
)}
{...props}
/>
);
export type QueueItemIndicatorProps = ComponentProps<"span"> & {
completed?: boolean;
};
export const QueueItemIndicator = ({
completed = false,
className,
...props
}: QueueItemIndicatorProps) => (
<span
className={cn(
"mt-0.5 inline-block size-2.5 rounded-full border",
completed
? "border-muted-foreground/20 bg-muted-foreground/10"
: "border-muted-foreground/50",
className
)}
{...props}
/>
);
export type QueueItemContentProps = ComponentProps<"span"> & {
completed?: boolean;
};
export const QueueItemContent = ({
completed = false,
className,
...props
}: QueueItemContentProps) => (
<span
className={cn(
"line-clamp-1 grow break-words",
completed
? "text-muted-foreground/50 line-through"
: "text-muted-foreground",
className
)}
{...props}
/>
);
export type QueueItemDescriptionProps = ComponentProps<"div"> & {
completed?: boolean;
};
export const QueueItemDescription = ({
completed = false,
className,
...props
}: QueueItemDescriptionProps) => (
<div
className={cn(
"ml-6 text-xs",
completed
? "text-muted-foreground/40 line-through"
: "text-muted-foreground",
className
)}
{...props}
/>
);
export type QueueItemActionsProps = ComponentProps<"div">;
export const QueueItemActions = ({
className,
...props
}: QueueItemActionsProps) => (
<div className={cn("flex gap-1", className)} {...props} />
);
export type QueueItemActionProps = Omit<
ComponentProps<typeof Button>,
"variant" | "size"
>;
export const QueueItemAction = ({
className,
...props
}: QueueItemActionProps) => (
<Button
className={cn(
"size-auto rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted-foreground/10 hover:text-foreground group-hover:opacity-100",
className
)}
size="icon"
type="button"
variant="ghost"
{...props}
/>
);
export type QueueItemAttachmentProps = ComponentProps<"div">;
export const QueueItemAttachment = ({
className,
...props
}: QueueItemAttachmentProps) => (
<div className={cn("mt-1 flex flex-wrap gap-2", className)} {...props} />
);
export type QueueItemImageProps = ComponentProps<"img">;
export const QueueItemImage = ({
className,
...props
}: QueueItemImageProps) => (
<img
alt=""
className={cn("h-8 w-8 rounded border object-cover", className)}
height={32}
width={32}
{...props}
/>
);
export type QueueItemFileProps = ComponentProps<"span">;
export const QueueItemFile = ({
children,
className,
...props
}: QueueItemFileProps) => (
<span
className={cn(
"flex items-center gap-1 rounded border bg-muted px-2 py-1 text-xs",
className
)}
{...props}
>
<PaperclipIcon size={12} />
<span className="max-w-[100px] truncate">{children}</span>
</span>
);
export type QueueListProps = ComponentProps<typeof ScrollArea>;
export const QueueList = ({
children,
className,
...props
}: QueueListProps) => (
<ScrollArea className={cn("-mb-1 mt-2", className)} {...props}>
<div className="max-h-40 pr-4">
<ul>{children}</ul>
</div>
</ScrollArea>
);
// QueueSection - collapsible section container
export type QueueSectionProps = ComponentProps<typeof Collapsible>;
export const QueueSection = ({
className,
defaultOpen = true,
...props
}: QueueSectionProps) => (
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
);
// QueueSectionTrigger - section header/trigger
export type QueueSectionTriggerProps = ComponentProps<"button">;
export const QueueSectionTrigger = ({
children,
className,
...props
}: QueueSectionTriggerProps) => (
<CollapsibleTrigger asChild>
<button
className={cn(
"group flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-left font-medium text-muted-foreground text-sm transition-colors hover:bg-muted",
className
)}
type="button"
{...props}
>
{children}
</button>
</CollapsibleTrigger>
);
// QueueSectionLabel - label content with icon and count
export type QueueSectionLabelProps = ComponentProps<"span"> & {
count?: number;
label: string;
icon?: React.ReactNode;
};
export const QueueSectionLabel = ({
count,
label,
icon,
className,
...props
}: QueueSectionLabelProps) => (
<span className={cn("flex items-center gap-2", className)} {...props}>
<ChevronDownIcon className="group-data-[state=closed]:-rotate-90 size-4 transition-transform" />
{icon}
<span>
{count} {label}
</span>
</span>
);
// QueueSectionContent - collapsible content area
export type QueueSectionContentProps = ComponentProps<
typeof CollapsibleContent
>;
export const QueueSectionContent = ({
className,
...props
}: QueueSectionContentProps) => (
<CollapsibleContent className={cn(className)} {...props} />
);
export type QueueProps = ComponentProps<"div">;
export const Queue = ({ className, ...props }: QueueProps) => (
<div
className={cn(
"flex flex-col gap-2 rounded-xl border border-border bg-background px-3 pt-2 pb-2 shadow-xs",
className
)}
{...props}
/>
);

View File

@@ -0,0 +1,178 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { Response } from "./response";
import { Shimmer } from "./shimmer";
type ReasoningContextValue = {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number;
};
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen = true,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const [duration, setDuration] = useControllableState({
prop: durationProp,
defaultProp: 0,
});
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const [startTime, setStartTime] = useState<number | null>(null);
// Track duration when streaming starts and ends
useEffect(() => {
if (isStreaming) {
if (startTime === null) {
setStartTime(Date.now());
}
} else if (startTime !== null) {
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
setStartTime(null);
}
}, [isStreaming, startTime, setDuration]);
// Auto-open when streaming starts, auto-close when streaming ends (once only)
useEffect(() => {
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
// Add a small delay before closing to allow user to see the content
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosed(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = (newOpen: boolean) => {
setIsOpen(newOpen);
};
return (
<ReasoningContext.Provider
value={{ isStreaming, isOpen, setIsOpen, duration }}
>
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
const getThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>;
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>;
}
return <p>Thought for {duration} seconds</p>;
};
export const ReasoningTrigger = memo(
({ className, children, ...props }: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<Response className="grid gap-2">{children}</Response>
</CollapsibleContent>
)
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";

View File

@@ -0,0 +1,22 @@
"use client";
import { cn } from "@/lib/utils";
import { type ComponentProps, memo } from "react";
import { Streamdown } from "streamdown";
type ResponseProps = ComponentProps<typeof Streamdown>;
export const Response = memo(
({ className, ...props }: ResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
);
Response.displayName = "Response";

View File

@@ -0,0 +1,64 @@
"use client";
import { cn } from "@/lib/utils";
import { motion } from "motion/react";
import {
type CSSProperties,
type ElementType,
type JSX,
memo,
useMemo,
} from "react";
export type TextShimmerProps = {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
};
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread]
);
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "linear",
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);

View File

@@ -0,0 +1,77 @@
"use client";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { BookIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
export type SourcesProps = ComponentProps<"div">;
export const Sources = ({ className, ...props }: SourcesProps) => (
<Collapsible
className={cn("not-prose mb-4 text-primary text-xs", className)}
{...props}
/>
);
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
count: number;
};
export const SourcesTrigger = ({
className,
count,
children,
...props
}: SourcesTriggerProps) => (
<CollapsibleTrigger
className={cn("flex items-center gap-2", className)}
{...props}
>
{children ?? (
<>
<p className="font-medium">Used {count} sources</p>
<ChevronDownIcon className="h-4 w-4" />
</>
)}
</CollapsibleTrigger>
);
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
export const SourcesContent = ({
className,
...props
}: SourcesContentProps) => (
<CollapsibleContent
className={cn(
"mt-3 flex w-fit flex-col gap-2",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type SourceProps = ComponentProps<"a">;
export const Source = ({ href, title, children, ...props }: SourceProps) => (
<a
className="flex items-center gap-2"
href={href}
rel="noreferrer"
target="_blank"
{...props}
>
{children ?? (
<>
<BookIcon className="h-4 w-4" />
<span className="block font-medium">{title}</span>
</>
)}
</a>
);

View File

@@ -0,0 +1,56 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ScrollArea,
ScrollBar,
} from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import type { ComponentProps } from "react";
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
export const Suggestions = ({
className,
children,
...props
}: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
{children}
</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
);
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
suggestion: string;
onClick?: (suggestion: string) => void;
};
export const Suggestion = ({
suggestion,
onClick,
className,
variant = "outline",
size = "sm",
children,
...props
}: SuggestionProps) => {
const handleClick = () => {
onClick?.(suggestion);
};
return (
<Button
className={cn("cursor-pointer rounded-full px-4", className)}
onClick={handleClick}
size={size}
type="button"
variant={variant}
{...props}
>
{children || suggestion}
</Button>
);
};

View File

@@ -0,0 +1,87 @@
"use client";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, SearchIcon } from "lucide-react";
import type { ComponentProps } from "react";
export type TaskItemFileProps = ComponentProps<"div">;
export const TaskItemFile = ({
children,
className,
...props
}: TaskItemFileProps) => (
<div
className={cn(
"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs",
className
)}
{...props}
>
{children}
</div>
);
export type TaskItemProps = ComponentProps<"div">;
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
{children}
</div>
);
export type TaskProps = ComponentProps<typeof Collapsible>;
export const Task = ({
defaultOpen = true,
className,
...props
}: TaskProps) => (
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
);
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
title: string;
};
export const TaskTrigger = ({
children,
className,
title,
...props
}: TaskTriggerProps) => (
<CollapsibleTrigger asChild className={cn("group", className)} {...props}>
{children ?? (
<div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
<SearchIcon className="size-4" />
<p className="text-sm">{title}</p>
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
</div>
)}
</CollapsibleTrigger>
);
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
export const TaskContent = ({
children,
className,
...props
}: TaskContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">
{children}
</div>
</CollapsibleContent>
);

View File

@@ -0,0 +1,163 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
ChevronDownIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement } from "react";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("not-prose mb-4 w-full rounded-md border", className)}
{...props}
/>
);
export type ToolHeaderProps = {
title?: string;
type: ToolUIPart["type"];
state: ToolUIPart["state"];
className?: string;
};
const getStatusBadge = (status: ToolUIPart["state"]) => {
const labels: Record<ToolUIPart["state"], string> = {
"input-streaming": "Pending",
"input-available": "Running",
"approval-requested": "Awaiting Approval",
"approval-responded": "Responded",
"output-available": "Completed",
"output-error": "Error",
"output-denied": "Denied",
};
const icons: Record<ToolUIPart["state"], ReactNode> = {
"input-streaming": <CircleIcon className="size-4" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
};
return (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{icons[status]}
{labels[status]}
</Badge>
);
};
export const ToolHeader = ({
className,
title,
type,
state,
...props
}: ToolHeaderProps) => (
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between gap-4 p-3",
className
)}
{...props}
>
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">
{title ?? type.split("-").slice(1).join("-")}
</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolUIPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden p-4", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolUIPart["output"];
errorText: ToolUIPart["errorText"];
};
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
let Output = <div>{output as ReactNode}</div>;
if (typeof output === "object" && !isValidElement(output)) {
Output = (
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
);
} else if (typeof output === "string") {
Output = <CodeBlock code={output} language="json" />;
}
return (
<div className={cn("space-y-2 p-4", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
)}
>
{errorText && <div>{errorText}</div>}
{Output}
</div>
</div>
);
};

View File

@@ -0,0 +1,16 @@
import { cn } from "@/lib/utils";
import { NodeToolbar, Position } from "@xyflow/react";
import type { ComponentProps } from "react";
type ToolbarProps = ComponentProps<typeof NodeToolbar>;
export const Toolbar = ({ className, ...props }: ToolbarProps) => (
<NodeToolbar
className={cn(
"flex items-center gap-1 rounded-sm border bg-background p-1.5",
className
)}
position={Position.Bottom}
{...props}
/>
);

View File

@@ -0,0 +1,263 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, useContext, useEffect, useState } from "react";
export type WebPreviewContextValue = {
url: string;
setUrl: (url: string) => void;
consoleOpen: boolean;
setConsoleOpen: (open: boolean) => void;
};
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
const useWebPreview = () => {
const context = useContext(WebPreviewContext);
if (!context) {
throw new Error("WebPreview components must be used within a WebPreview");
}
return context;
};
export type WebPreviewProps = ComponentProps<"div"> & {
defaultUrl?: string;
onUrlChange?: (url: string) => void;
};
export const WebPreview = ({
className,
children,
defaultUrl = "",
onUrlChange,
...props
}: WebPreviewProps) => {
const [url, setUrl] = useState(defaultUrl);
const [consoleOpen, setConsoleOpen] = useState(false);
const handleUrlChange = (newUrl: string) => {
setUrl(newUrl);
onUrlChange?.(newUrl);
};
const contextValue: WebPreviewContextValue = {
url,
setUrl: handleUrlChange,
consoleOpen,
setConsoleOpen,
};
return (
<WebPreviewContext.Provider value={contextValue}>
<div
className={cn(
"flex size-full flex-col rounded-lg border bg-card",
className
)}
{...props}
>
{children}
</div>
</WebPreviewContext.Provider>
);
};
export type WebPreviewNavigationProps = ComponentProps<"div">;
export const WebPreviewNavigation = ({
className,
children,
...props
}: WebPreviewNavigationProps) => (
<div
className={cn("flex items-center gap-1 border-b p-2", className)}
{...props}
>
{children}
</div>
);
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
tooltip?: string;
};
export const WebPreviewNavigationButton = ({
onClick,
disabled,
tooltip,
children,
...props
}: WebPreviewNavigationButtonProps) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="h-8 w-8 p-0 hover:text-foreground"
disabled={disabled}
onClick={onClick}
size="sm"
variant="ghost"
{...props}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
export const WebPreviewUrl = ({
value,
onChange,
onKeyDown,
...props
}: WebPreviewUrlProps) => {
const { url, setUrl } = useWebPreview();
const [inputValue, setInputValue] = useState(url);
// Sync input value with context URL when it changes externally
useEffect(() => {
setInputValue(url);
}, [url]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
onChange?.(event);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
const target = event.target as HTMLInputElement;
setUrl(target.value);
}
onKeyDown?.(event);
};
return (
<Input
className="h-8 flex-1 text-sm"
onChange={onChange ?? handleChange}
onKeyDown={handleKeyDown}
placeholder="Enter URL..."
value={value ?? inputValue}
{...props}
/>
);
};
export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
loading?: ReactNode;
};
export const WebPreviewBody = ({
className,
loading,
src,
...props
}: WebPreviewBodyProps) => {
const { url } = useWebPreview();
return (
<div className="flex-1">
<iframe
className={cn("size-full", className)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
src={(src ?? url) || undefined}
title="Preview"
{...props}
/>
{loading}
</div>
);
};
export type WebPreviewConsoleProps = ComponentProps<"div"> & {
logs?: Array<{
level: "log" | "warn" | "error";
message: string;
timestamp: Date;
}>;
};
export const WebPreviewConsole = ({
className,
logs = [],
children,
...props
}: WebPreviewConsoleProps) => {
const { consoleOpen, setConsoleOpen } = useWebPreview();
return (
<Collapsible
className={cn("border-t bg-muted/50 font-mono text-sm", className)}
onOpenChange={setConsoleOpen}
open={consoleOpen}
{...props}
>
<CollapsibleTrigger asChild>
<Button
className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50"
variant="ghost"
>
Console
<ChevronDownIcon
className={cn(
"h-4 w-4 transition-transform duration-200",
consoleOpen && "rotate-180"
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
"px-4 pb-4",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in"
)}
>
<div className="max-h-48 space-y-1 overflow-y-auto">
{logs.length === 0 ? (
<p className="text-muted-foreground">No console output</p>
) : (
logs.map((log, index) => (
<div
className={cn(
"text-xs",
log.level === "error" && "text-destructive",
log.level === "warn" && "text-yellow-600",
log.level === "log" && "text-foreground"
)}
key={`${log.timestamp.getTime()}-${index}`}
>
<span className="text-muted-foreground">
{log.timestamp.toLocaleTimeString()}
</span>{" "}
{log.message}
</div>
))
)}
{children}
</div>
</CollapsibleContent>
</Collapsible>
);
};

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,83 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "div"
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,241 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,182 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,42 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,170 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

Some files were not shown because too many files have changed in this diff Show More