mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-10 08:05:22 +02:00
Merge branch 'main' into feat/speaches-integration
This commit is contained in:
commit
2eaaabd936
20 changed files with 357 additions and 45 deletions
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
".": "1.19.1"
|
||||
".": "1.19.2"
|
||||
}
|
||||
|
|
@ -1,5 +1,12 @@
|
|||
# Changelog
|
||||
|
||||
## [1.19.2](https://github.com/dograh-hq/dograh/compare/dograh-v1.19.1...dograh-v1.19.2) (2026-03-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* send auth credentials with validate service keys ([83f05ab](https://github.com/dograh-hq/dograh/commit/83f05ab1466d7fa1825b30eb87a267aa1da9ff26))
|
||||
|
||||
## [1.19.1](https://github.com/dograh-hq/dograh/compare/dograh-v1.19.0...dograh-v1.19.1) (2026-03-26)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -365,12 +365,29 @@ class CampaignClient(BaseDBClient):
|
|||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_completed_runs_for_report(self, campaign_id: int) -> list:
|
||||
async def get_completed_runs_for_report(
|
||||
self,
|
||||
campaign_id: int,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
) -> list:
|
||||
"""Get completed workflow runs for campaign report CSV.
|
||||
|
||||
Returns rows with only the columns needed for report generation.
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
conditions = [
|
||||
WorkflowRunModel.campaign_id == campaign_id,
|
||||
WorkflowRunModel.is_completed.is_(True),
|
||||
WorkflowRunModel.cost_info["call_duration_seconds"]
|
||||
.as_string()
|
||||
.isnot(None),
|
||||
]
|
||||
if start_date is not None:
|
||||
conditions.append(WorkflowRunModel.created_at >= start_date)
|
||||
if end_date is not None:
|
||||
conditions.append(WorkflowRunModel.created_at <= end_date)
|
||||
|
||||
query = (
|
||||
select(
|
||||
WorkflowRunModel.id,
|
||||
|
|
@ -381,13 +398,7 @@ class CampaignClient(BaseDBClient):
|
|||
WorkflowRunModel.logs,
|
||||
WorkflowRunModel.public_access_token,
|
||||
)
|
||||
.where(
|
||||
WorkflowRunModel.campaign_id == campaign_id,
|
||||
WorkflowRunModel.is_completed.is_(True),
|
||||
WorkflowRunModel.cost_info["call_duration_seconds"]
|
||||
.as_string()
|
||||
.isnot(None),
|
||||
)
|
||||
.where(*conditions)
|
||||
.order_by(WorkflowRunModel.created_at.desc())
|
||||
)
|
||||
result = await session.execute(query)
|
||||
|
|
|
|||
|
|
@ -465,7 +465,7 @@ class WorkflowClient(BaseDBClient):
|
|||
return
|
||||
|
||||
existing = workflow.call_disposition_codes or {}
|
||||
codes = existing.get("disposition_codes", [])
|
||||
codes = list(existing.get("disposition_codes", []))
|
||||
if disposition_code in codes:
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[project]
|
||||
name = "dograh-api"
|
||||
version = "1.19.1"
|
||||
version = "1.19.2"
|
||||
description = "Backend API for Dograh voice AI platform"
|
||||
requires-python = ">=3.12"
|
||||
|
|
|
|||
|
|
@ -706,13 +706,21 @@ async def get_campaign_source_download_url(
|
|||
async def download_campaign_report(
|
||||
campaign_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
start_date: Optional[datetime] = Query(
|
||||
None, description="Filter runs created on or after this datetime (ISO 8601)"
|
||||
),
|
||||
end_date: Optional[datetime] = Query(
|
||||
None, description="Filter runs created on or before this datetime (ISO 8601)"
|
||||
),
|
||||
) -> StreamingResponse:
|
||||
"""Download a CSV report of completed campaign runs."""
|
||||
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
output, filename = await generate_campaign_report_csv(campaign_id)
|
||||
output, filename = await generate_campaign_report_csv(
|
||||
campaign_id, start_date=start_date, end_date=end_date
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
output,
|
||||
|
|
|
|||
|
|
@ -125,7 +125,11 @@ async def update_user_configurations(
|
|||
|
||||
try:
|
||||
validator = UserConfigurationValidator()
|
||||
await validator.validate(user_configurations)
|
||||
await validator.validate(
|
||||
user_configurations,
|
||||
organization_id=user.selected_organization_id,
|
||||
created_by=user.provider_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=e.args[0])
|
||||
|
||||
|
|
@ -163,7 +167,11 @@ async def validate_user_configurations(
|
|||
):
|
||||
validator = UserConfigurationValidator()
|
||||
try:
|
||||
status = await validator.validate(configurations)
|
||||
status = await validator.validate(
|
||||
configurations,
|
||||
organization_id=user.selected_organization_id,
|
||||
created_by=user.provider_id,
|
||||
)
|
||||
await db_client.update_user_configuration_last_validated_at(user.id)
|
||||
return status
|
||||
except ValueError as e:
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ class SignalingManager:
|
|||
{
|
||||
"type": "error",
|
||||
"payload": {
|
||||
"error_type": "quota_exceeded",
|
||||
"error_type": quota_result.error_code,
|
||||
"message": quota_result.error_message,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import csv
|
||||
import io
|
||||
from typing import Any, List
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from api.constants import BACKEND_API_ENDPOINT
|
||||
from api.db import db_client
|
||||
|
|
@ -27,12 +28,18 @@ def _collect_extracted_variable_keys(runs: List[Any]) -> list[str]:
|
|||
return list(keys)
|
||||
|
||||
|
||||
async def generate_campaign_report_csv(campaign_id: int) -> tuple[io.StringIO, str]:
|
||||
async def generate_campaign_report_csv(
|
||||
campaign_id: int,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
) -> tuple[io.StringIO, str]:
|
||||
"""Generate a CSV report for a campaign.
|
||||
|
||||
Returns a tuple of (csv_output, filename).
|
||||
"""
|
||||
runs = await db_client.get_completed_runs_for_report(campaign_id)
|
||||
runs = await db_client.get_completed_runs_for_report(
|
||||
campaign_id, start_date=start_date, end_date=end_date
|
||||
)
|
||||
|
||||
# Collect dynamic extracted variable columns
|
||||
extracted_var_keys = _collect_extracted_variable_keys(runs)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ from api.schemas.user_configuration import (
|
|||
from api.services.configuration.registry import ServiceConfig, ServiceProviders
|
||||
from api.services.mps_service_key_client import mps_service_key_client
|
||||
|
||||
AuthContext = TypedDict(
|
||||
"AuthContext",
|
||||
{"organization_id": Optional[int], "created_by": Optional[str]},
|
||||
total=False,
|
||||
)
|
||||
|
||||
|
||||
class APIKeyStatus(TypedDict):
|
||||
model: str
|
||||
|
|
@ -43,7 +49,16 @@ class UserConfigurationValidator:
|
|||
ServiceProviders.SPEACHES.value: self._check_speaches_api_key,
|
||||
}
|
||||
|
||||
async def validate(self, configuration: UserConfiguration) -> APIKeyStatusResponse:
|
||||
async def validate(
|
||||
self,
|
||||
configuration: UserConfiguration,
|
||||
organization_id: Optional[int] = None,
|
||||
created_by: Optional[str] = None,
|
||||
) -> APIKeyStatusResponse:
|
||||
self._auth_context: AuthContext = {
|
||||
"organization_id": organization_id,
|
||||
"created_by": created_by,
|
||||
}
|
||||
status_list = []
|
||||
|
||||
status_list.extend(self._validate_service(configuration.llm, "llm"))
|
||||
|
|
@ -165,7 +180,12 @@ class UserConfigurationValidator:
|
|||
"You provided a Dograh API key (dgr...) instead of a service key. "
|
||||
"Please use a service key (mps...)."
|
||||
)
|
||||
return mps_service_key_client.validate_service_key(api_key)
|
||||
auth = getattr(self, "_auth_context", {})
|
||||
return mps_service_key_client.validate_service_key(
|
||||
api_key,
|
||||
organization_id=auth.get("organization_id"),
|
||||
created_by=auth.get("created_by"),
|
||||
)
|
||||
|
||||
def _check_sarvam_api_key(self, model: str, api_key: str) -> bool:
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ class MPSServiceKeyClient:
|
|||
"remaining_credits": data.get("remaining_credits", 0.0),
|
||||
}
|
||||
else:
|
||||
logger.error(
|
||||
logger.warning(
|
||||
f"Failed to check service key usage: {response.status_code} - {response.text}"
|
||||
)
|
||||
raise httpx.HTTPStatusError(
|
||||
|
|
@ -416,7 +416,12 @@ class MPSServiceKeyClient:
|
|||
response=response,
|
||||
)
|
||||
|
||||
def validate_service_key(self, service_key: str) -> bool:
|
||||
def validate_service_key(
|
||||
self,
|
||||
service_key: str,
|
||||
organization_id: Optional[int] = None,
|
||||
created_by: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Synchronously validate a Dograh service key by checking usage via MPS.
|
||||
|
||||
|
|
@ -427,7 +432,7 @@ class MPSServiceKeyClient:
|
|||
response = client.post(
|
||||
f"{self.base_url}/api/v1/service-keys/usage",
|
||||
json={"service_key": service_key},
|
||||
headers=self._get_headers(),
|
||||
headers=self._get_headers(organization_id, created_by),
|
||||
)
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class QuotaCheckResult:
|
|||
|
||||
has_quota: bool
|
||||
error_message: str = ""
|
||||
error_code: str = ""
|
||||
|
||||
|
||||
async def check_dograh_quota(user: UserModel) -> QuotaCheckResult:
|
||||
|
|
@ -76,6 +77,7 @@ async def check_dograh_quota(user: UserModel) -> QuotaCheckResult:
|
|||
)
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="quota_exceeded",
|
||||
error_message=(
|
||||
"You have exhausted your trial credits. "
|
||||
"Please email founders@dograh.com for additional Dograh credits "
|
||||
|
|
@ -89,8 +91,16 @@ async def check_dograh_quota(user: UserModel) -> QuotaCheckResult:
|
|||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check quota for Dograh key: {str(e)}")
|
||||
error_str = str(e)
|
||||
if "404" in error_str or "not found" in error_str.lower():
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="invalid_service_key",
|
||||
error_message="You have invalid keys in your model configuration. Please validate the service keys.",
|
||||
)
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="quota_check_failed",
|
||||
error_message="Could not verify Dograh credits. Please try again.",
|
||||
)
|
||||
|
||||
|
|
|
|||
85
api/tests/test_add_call_disposition_code.py
Normal file
85
api/tests/test_add_call_disposition_code.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""Test that add_call_disposition_code correctly persists changes.
|
||||
|
||||
The bug: `codes` is a reference to the list inside the JSON column value.
|
||||
Calling `codes.append()` mutates the in-memory column value in-place.
|
||||
When SQLAlchemy compares old vs new on commit, it sees them as equal
|
||||
because the old value was already mutated — so the change is silently dropped.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from api.db.workflow_client import WorkflowClient
|
||||
|
||||
|
||||
def _make_workflow_stub(initial_disposition_codes):
|
||||
"""Create a mock workflow that behaves like a SQLAlchemy model instance.
|
||||
|
||||
Tracks attribute assignments so we can verify the new value is genuinely
|
||||
different from the original (which is what SQLAlchemy needs to detect a change).
|
||||
"""
|
||||
workflow = MagicMock()
|
||||
# Store the initial value and track what gets assigned
|
||||
workflow.call_disposition_codes = initial_disposition_codes
|
||||
workflow._assigned_values = {}
|
||||
|
||||
original_setattr = type(workflow).__setattr__
|
||||
|
||||
def tracking_setattr(self, name, value):
|
||||
if name == "call_disposition_codes":
|
||||
self._assigned_values[name] = value
|
||||
original_setattr(self, name, value)
|
||||
|
||||
type(workflow).__setattr__ = tracking_setattr
|
||||
return workflow
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with patch("api.db.workflow_client.BaseDBClient.__init__", return_value=None):
|
||||
c = WorkflowClient()
|
||||
c.async_session = MagicMock()
|
||||
return c
|
||||
|
||||
|
||||
def test_disposition_code_new_value_is_not_same_reference(client):
|
||||
"""The assigned list must NOT be the same object as the original.
|
||||
|
||||
If it is, SQLAlchemy won't detect the change because old == new
|
||||
(the old was mutated in-place).
|
||||
"""
|
||||
initial_codes = {"disposition_codes": ["existing_code"]}
|
||||
original_list = initial_codes["disposition_codes"]
|
||||
|
||||
workflow = MagicMock()
|
||||
workflow.call_disposition_codes = initial_codes
|
||||
|
||||
# Mock the session and query
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = workflow
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
client.async_session = MagicMock(return_value=mock_session)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(
|
||||
client.add_call_disposition_code(workflow_id=1, disposition_code="new_code")
|
||||
)
|
||||
|
||||
# Verify the disposition code was added
|
||||
assigned = workflow.call_disposition_codes
|
||||
assert "new_code" in assigned["disposition_codes"]
|
||||
|
||||
# THE CRITICAL CHECK: the list inside the assigned value must be a *different*
|
||||
# object from the original list. If it's the same object, SQLAlchemy's change
|
||||
# detection won't work because the "old" value was mutated in-place.
|
||||
assert assigned["disposition_codes"] is not original_list, (
|
||||
"The assigned disposition_codes list is the same object as the original. "
|
||||
"This means SQLAlchemy won't detect the change because the old value "
|
||||
"was mutated in-place via list.append()."
|
||||
)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "ui",
|
||||
"version": "1.19.1",
|
||||
"version": "1.19.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "NODE_OPTIONS='--enable-source-maps' next dev --turbopack",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Check, Clock, Download, Pause, Pencil, Play, RefreshCw, X } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ArrowLeft, CalendarIcon, Check, Clock, Download, Pause, Pencil, Play, RefreshCw, X } from 'lucide-react';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
|
@ -16,7 +17,11 @@ import {
|
|||
import type { CampaignResponse } from '@/client/types.gen';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { CampaignRuns } from '@/components/workflow-runs';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
|
@ -43,6 +48,13 @@ export default function CampaignDetailPage() {
|
|||
const [isExecutingAction, setIsExecutingAction] = useState(false);
|
||||
const [isDownloadingReport, setIsDownloadingReport] = useState(false);
|
||||
|
||||
// Report date range state
|
||||
const [reportStartDate, setReportStartDate] = useState<Date | undefined>(undefined);
|
||||
const [reportStartTime, setReportStartTime] = useState('00:00');
|
||||
const [reportEndDate, setReportEndDate] = useState<Date | undefined>(undefined);
|
||||
const [reportEndTime, setReportEndTime] = useState('23:59');
|
||||
const [isReportPopoverOpen, setIsReportPopoverOpen] = useState(false);
|
||||
|
||||
// Fetch campaign details
|
||||
const fetchCampaign = useCallback(async () => {
|
||||
if (!user) return;
|
||||
|
|
@ -113,16 +125,33 @@ export default function CampaignDetailPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// Build ISO datetime string from date + time
|
||||
const buildDateTime = (date: Date | undefined, time: string): string | undefined => {
|
||||
if (!date) return undefined;
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
const combined = new Date(date);
|
||||
combined.setHours(hours, minutes, 0, 0);
|
||||
return combined.toISOString();
|
||||
};
|
||||
|
||||
// Handle download report
|
||||
const handleDownloadReport = async () => {
|
||||
if (!user) return;
|
||||
setIsDownloadingReport(true);
|
||||
setIsReportPopoverOpen(false);
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const startDate = buildDateTime(reportStartDate, reportStartTime);
|
||||
const endDate = buildDateTime(reportEndDate, reportEndTime);
|
||||
|
||||
const response = await downloadCampaignReportApiV1CampaignCampaignIdReportGet({
|
||||
path: {
|
||||
campaign_id: campaignId,
|
||||
},
|
||||
query: {
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
|
|
@ -150,6 +179,13 @@ export default function CampaignDetailPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleClearDateRange = () => {
|
||||
setReportStartDate(undefined);
|
||||
setReportStartTime('00:00');
|
||||
setReportEndDate(undefined);
|
||||
setReportEndTime('23:59');
|
||||
};
|
||||
|
||||
// Handle start campaign
|
||||
const handleStart = async () => {
|
||||
if (!user) return;
|
||||
|
|
@ -368,10 +404,85 @@ export default function CampaignDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleDownloadReport} disabled={isDownloadingReport}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download Report
|
||||
</Button>
|
||||
<Popover open={isReportPopoverOpen} onOpenChange={setIsReportPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" disabled={isDownloadingReport}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download Report
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-4" align="end">
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">Filter by date range</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">From</Label>
|
||||
<div className="flex gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-[140px] justify-start text-left font-normal">
|
||||
<CalendarIcon className="mr-2 h-3.5 w-3.5" />
|
||||
{reportStartDate ? format(reportStartDate, 'MMM dd, yyyy') : 'Start date'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={reportStartDate}
|
||||
onSelect={setReportStartDate}
|
||||
disabled={(date) => reportEndDate ? date > reportEndDate : false}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Input
|
||||
type="time"
|
||||
value={reportStartTime}
|
||||
onChange={(e) => setReportStartTime(e.target.value)}
|
||||
className="w-[100px] h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">To</Label>
|
||||
<div className="flex gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-[140px] justify-start text-left font-normal">
|
||||
<CalendarIcon className="mr-2 h-3.5 w-3.5" />
|
||||
{reportEndDate ? format(reportEndDate, 'MMM dd, yyyy') : 'End date'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={reportEndDate}
|
||||
onSelect={setReportEndDate}
|
||||
disabled={(date) => reportStartDate ? date < reportStartDate : false}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Input
|
||||
type="time"
|
||||
value={reportEndTime}
|
||||
onChange={(e) => setReportEndTime(e.target.value)}
|
||||
className="w-[100px] h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={handleClearDateRange}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleDownloadReport} disabled={isDownloadingReport}>
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
{reportStartDate || reportEndDate ? 'Download Filtered' : 'Download All'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{renderActionButton()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ const BrowserCall = ({ workflowId, workflowRunId, initialContextVariables }: {
|
|||
apiKeyModalOpen,
|
||||
setApiKeyModalOpen,
|
||||
apiKeyError,
|
||||
apiKeyErrorCode,
|
||||
workflowConfigError,
|
||||
workflowConfigModalOpen,
|
||||
setWorkflowConfigModalOpen,
|
||||
|
|
@ -91,10 +92,14 @@ const BrowserCall = ({ workflowId, workflowRunId, initialContextVariables }: {
|
|||
};
|
||||
}, [isCompleted, auth.isAuthenticated, workflowId, workflowRunId]);
|
||||
|
||||
const navigateToApiKeys = () => {
|
||||
const navigateToCredits = () => {
|
||||
router.push('/api-keys');
|
||||
};
|
||||
|
||||
const navigateToModelConfig = () => {
|
||||
router.push('/model-configurations');
|
||||
};
|
||||
|
||||
const navigateToWorkflow = () => {
|
||||
router.push(`/workflow/${workflowId}`)
|
||||
}
|
||||
|
|
@ -161,7 +166,9 @@ const BrowserCall = ({ workflowId, workflowRunId, initialContextVariables }: {
|
|||
open={apiKeyModalOpen}
|
||||
onOpenChange={setApiKeyModalOpen}
|
||||
error={apiKeyError}
|
||||
onNavigateToApiKeys={navigateToApiKeys}
|
||||
errorCode={apiKeyErrorCode}
|
||||
onNavigateToCredits={navigateToCredits}
|
||||
onNavigateToModelConfig={navigateToModelConfig}
|
||||
/>
|
||||
|
||||
<WorkflowConfigErrorDialog
|
||||
|
|
|
|||
|
|
@ -7,23 +7,25 @@ interface ApiKeyErrorDialogProps {
|
|||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
error: string | null;
|
||||
onNavigateToApiKeys: () => void;
|
||||
errorCode: string | null;
|
||||
onNavigateToCredits: () => void;
|
||||
onNavigateToModelConfig: () => void;
|
||||
}
|
||||
|
||||
export const ApiKeyErrorDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
error,
|
||||
onNavigateToApiKeys
|
||||
errorCode,
|
||||
onNavigateToCredits,
|
||||
onNavigateToModelConfig,
|
||||
}: ApiKeyErrorDialogProps) => {
|
||||
// Check if this is a quota error based on the error message
|
||||
const isQuotaError = error?.toLowerCase().includes('insufficient') ||
|
||||
error?.toLowerCase().includes('credits') ||
|
||||
error?.toLowerCase().includes('quota');
|
||||
const isQuotaError = errorCode === 'quota_exceeded';
|
||||
|
||||
const title = isQuotaError ? "Insufficient Credits" : "API Configuration Error";
|
||||
const icon = isQuotaError ? <CreditCard className="h-5 w-5 text-orange-500" /> : <Key className="h-5 w-5 text-red-500" />;
|
||||
const buttonText = isQuotaError ? "Add Credits" : "Go to API Keys Settings";
|
||||
const buttonText = isQuotaError ? "Add Credits" : "Go to Model Configurations";
|
||||
const onNavigate = isQuotaError ? onNavigateToCredits : onNavigateToModelConfig;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -51,7 +53,7 @@ export const ApiKeyErrorDialog = ({
|
|||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onNavigateToApiKeys}>
|
||||
<Button onClick={onNavigate}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
|
|||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const [apiKeyModalOpen, setApiKeyModalOpen] = useState(false);
|
||||
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
|
||||
const [apiKeyErrorCode, setApiKeyErrorCode] = useState<string | null>(null);
|
||||
const [workflowConfigModalOpen, setWorkflowConfigModalOpen] = useState(false);
|
||||
const [workflowConfigError, setWorkflowConfigError] = useState<string | null>(null);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
|
|
@ -264,12 +265,15 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
|
|||
break;
|
||||
|
||||
case 'error':
|
||||
// Check if this is a quota exceeded error
|
||||
if (message.payload?.error_type === 'quota_exceeded') {
|
||||
// Check if this is a quota/service key error
|
||||
if (message.payload?.error_type === 'quota_exceeded' ||
|
||||
message.payload?.error_type === 'invalid_service_key' ||
|
||||
message.payload?.error_type === 'quota_check_failed') {
|
||||
// Log as info since it's a handled business logic case
|
||||
logger.info('Quota exceeded, showing user dialog:', message.payload.message);
|
||||
logger.info('Quota/service key error, showing user dialog:', message.payload.message);
|
||||
|
||||
// Set error state for display
|
||||
setApiKeyErrorCode(message.payload.error_type);
|
||||
setApiKeyError(message.payload.message || 'Service quota exceeded');
|
||||
setApiKeyModalOpen(true);
|
||||
|
||||
|
|
@ -545,6 +549,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
|
|||
|
||||
if (response.error) {
|
||||
setApiKeyModalOpen(true);
|
||||
setApiKeyErrorCode('invalid_api_key');
|
||||
let msg = 'API Key Error';
|
||||
const detail = (response.error as unknown as { detail?: { errors: { model: string; message: string }[] } }).detail;
|
||||
if (Array.isArray(detail)) {
|
||||
|
|
@ -685,6 +690,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
|
|||
apiKeyModalOpen,
|
||||
setApiKeyModalOpen,
|
||||
apiKeyError,
|
||||
apiKeyErrorCode,
|
||||
workflowConfigError,
|
||||
workflowConfigModalOpen,
|
||||
setWorkflowConfigModalOpen,
|
||||
|
|
|
|||
|
|
@ -3274,7 +3274,16 @@ export type DownloadCampaignReportApiV1CampaignCampaignIdReportGetData = {
|
|||
path: {
|
||||
campaign_id: number;
|
||||
};
|
||||
query?: never;
|
||||
query?: {
|
||||
/**
|
||||
* Filter runs created on or after this datetime (ISO 8601)
|
||||
*/
|
||||
start_date?: string | null;
|
||||
/**
|
||||
* Filter runs created on or before this datetime (ISO 8601)
|
||||
*/
|
||||
end_date?: string | null;
|
||||
};
|
||||
url: '/api/v1/campaign/{campaign_id}/report';
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -36,10 +36,26 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
|
|||
}
|
||||
}, [data, open]);
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = useCallback(() => {
|
||||
onSave({ condition: condition, label: label, transition_speech: transitionSpeech || undefined });
|
||||
onOpenChange(false);
|
||||
};
|
||||
}, [condition, label, transitionSpeech, onSave, onOpenChange]);
|
||||
|
||||
// Handle Cmd+S / Ctrl+S keyboard shortcut to save
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [open, handleSave]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue