fix(ui): improve agent debug and model test feedback

This commit is contained in:
Hyu
2026-09-03 14:51:01 +08:00
parent be0214cf4d
commit 7c64756203
7 changed files with 137 additions and 50 deletions
@@ -1,10 +1,14 @@
import quart import quart
from langbot.pkg.provider.modelmgr import errors as provider_errors
from ....authz import Permission, has_permission from ....authz import Permission, has_permission
from ....context import RequestContext from ....context import RequestContext
from ... import group from ... import group
MODEL_TEST_ERROR_CODE = 'model_test_failed'
@group.group_class('models/llm', '/api/v1/provider/models/llm') @group.group_class('models/llm', '/api/v1/provider/models/llm')
class LLMModelsRouterGroup(group.RouterGroup): class LLMModelsRouterGroup(group.RouterGroup):
async def initialize(self) -> None: async def initialize(self) -> None:
@@ -96,7 +100,14 @@ class LLMModelsRouterGroup(group.RouterGroup):
permission=Permission.PROVIDER_SECRET_MANAGE, permission=Permission.PROVIDER_SECRET_MANAGE,
) )
async def _(model_uuid: str, request_context: RequestContext) -> str: async def _(model_uuid: str, request_context: RequestContext) -> str:
await self.ap.llm_model_service.test_llm_model(request_context, model_uuid, await quart.request.json) try:
await self.ap.llm_model_service.test_llm_model(
request_context,
model_uuid,
await quart.request.json,
)
except (provider_errors.RequesterError, ValueError) as exc:
return self.http_status(400, MODEL_TEST_ERROR_CODE, str(exc))
return self.success() return self.success()
@@ -191,9 +202,14 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
permission=Permission.PROVIDER_SECRET_MANAGE, permission=Permission.PROVIDER_SECRET_MANAGE,
) )
async def _(model_uuid: str, request_context: RequestContext) -> str: async def _(model_uuid: str, request_context: RequestContext) -> str:
await self.ap.embedding_models_service.test_embedding_model( try:
request_context, model_uuid, await quart.request.json await self.ap.embedding_models_service.test_embedding_model(
) request_context,
model_uuid,
await quart.request.json,
)
except (provider_errors.RequesterError, ValueError) as exc:
return self.http_status(400, MODEL_TEST_ERROR_CODE, str(exc))
return self.success() return self.success()
@@ -288,5 +304,12 @@ class RerankModelsRouterGroup(group.RouterGroup):
permission=Permission.PROVIDER_SECRET_MANAGE, permission=Permission.PROVIDER_SECRET_MANAGE,
) )
async def _(model_uuid: str, request_context: RequestContext) -> str: async def _(model_uuid: str, request_context: RequestContext) -> str:
await self.ap.rerank_models_service.test_rerank_model(request_context, model_uuid, await quart.request.json) try:
await self.ap.rerank_models_service.test_rerank_model(
request_context,
model_uuid,
await quart.request.json,
)
except (provider_errors.RequesterError, ValueError) as exc:
return self.http_status(400, MODEL_TEST_ERROR_CODE, str(exc))
return self.success() return self.success()
+41
View File
@@ -12,6 +12,7 @@ import pytest
from unittest.mock import MagicMock, AsyncMock, Mock from unittest.mock import MagicMock, AsyncMock, Mock
from types import SimpleNamespace from types import SimpleNamespace
from langbot.pkg.provider.modelmgr.errors import RequesterError
from tests.factories import FakeApp from tests.factories import FakeApp
@@ -111,16 +112,19 @@ def fake_provider_app():
app.llm_model_service.create_llm_model = AsyncMock(return_value={'uuid': 'new-model-uuid'}) app.llm_model_service.create_llm_model = AsyncMock(return_value={'uuid': 'new-model-uuid'})
app.llm_model_service.update_llm_model = AsyncMock(return_value={}) app.llm_model_service.update_llm_model = AsyncMock(return_value={})
app.llm_model_service.delete_llm_model = AsyncMock() app.llm_model_service.delete_llm_model = AsyncMock()
app.llm_model_service.test_llm_model = AsyncMock()
# Embedding model service # Embedding model service
app.embedding_models_service = Mock() app.embedding_models_service = Mock()
app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[]) app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[])
app.embedding_models_service.create_embedding_model = AsyncMock(return_value={'uuid': 'new-embedding-uuid'}) app.embedding_models_service.create_embedding_model = AsyncMock(return_value={'uuid': 'new-embedding-uuid'})
app.embedding_models_service.test_embedding_model = AsyncMock()
# Rerank model service # Rerank model service
app.rerank_models_service = Mock() app.rerank_models_service = Mock()
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[]) app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[])
app.rerank_models_service.create_rerank_model = AsyncMock(return_value={'uuid': 'new-rerank-uuid'}) app.rerank_models_service.create_rerank_model = AsyncMock(return_value={'uuid': 'new-rerank-uuid'})
app.rerank_models_service.test_rerank_model = AsyncMock()
# Model manager # Model manager
app.model_mgr = Mock() app.model_mgr = Mock()
@@ -290,6 +294,43 @@ class TestModelEndpoints:
assert response.status_code == 200 assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.parametrize(
('service_name', 'method_name', 'path'),
[
('llm_model_service', 'test_llm_model', '/api/v1/provider/models/llm/_/test'),
(
'embedding_models_service',
'test_embedding_model',
'/api/v1/provider/models/embedding/_/test',
),
('rerank_models_service', 'test_rerank_model', '/api/v1/provider/models/rerank/_/test'),
],
)
async def test_model_test_returns_requester_error_details(
self,
quart_test_client,
fake_provider_app,
service_name,
method_name,
path,
):
test_model = getattr(getattr(fake_provider_app, service_name), method_name)
test_model.side_effect = RequesterError('API key invalid')
try:
response = await quart_test_client.post(
path,
headers={'Authorization': 'Bearer test_token'},
json={'name': 'broken-model'},
)
finally:
test_model.side_effect = None
assert response.status_code == 400
data = await response.get_json()
assert data['code'] == 'model_test_failed'
assert data['msg'] == '模型请求失败: API key invalid'
@pytest.mark.usefixtures('mock_circular_import_chain') @pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbeddingModelEndpoints: class TestEmbeddingModelEndpoints:
@@ -312,48 +312,55 @@ export default function AgentDebugPanel({
} }
> >
{entry.direction === 'error' && <AlertCircle />} {entry.direction === 'error' && <AlertCircle />}
<div className="mb-2 flex items-center justify-between gap-2"> <div className="col-start-2 min-w-0">
<Badge variant="outline">{entry.eventType}</Badge> <div className="mb-2 flex min-w-0 flex-wrap items-center justify-between gap-2">
<span className="text-xs text-muted-foreground"> <Badge
{entry.direction === 'output'
? t('agents.debugAgentOutput')
: entry.direction === 'error'
? t('common.error')
: t('agents.debugTestInput')}
</span>
</div>
<pre className="min-w-0 whitespace-pre-wrap break-words font-sans text-sm leading-relaxed">
{entry.text}
</pre>
{entry.detail && (
<Collapsible className="mt-3">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="sm">
{t('agents.debugErrorDetails')}
<ChevronDown className="size-3.5" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-2 font-mono text-xs text-muted-foreground">
{entry.detail}
</pre>
</CollapsibleContent>
</Collapsible>
)}
{(entry.errorCode?.endsWith('.config_invalid') ||
entry.errorCode === 'runner_execution_failed' ||
entry.errorCode === 'runner.timeout') &&
onOpenRunnerConfig && (
<Button
type="button"
variant="outline" variant="outline"
size="sm" className="max-w-full overflow-hidden text-ellipsis"
className="mt-3"
onClick={onOpenRunnerConfig}
> >
{t('agents.debugReviewRunnerConfig')} {entry.eventType}
</Button> </Badge>
<span className="shrink-0 text-xs text-muted-foreground">
{entry.direction === 'output'
? t('agents.debugAgentOutput')
: entry.direction === 'error'
? t('common.error')
: t('agents.debugTestInput')}
</span>
</div>
<pre className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-sans text-sm leading-relaxed">
{entry.text}
</pre>
{entry.detail && (
<Collapsible className="mt-3">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="sm">
{t('agents.debugErrorDetails')}
<ChevronDown className="size-3.5" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] rounded-md bg-muted p-2 font-mono text-xs text-muted-foreground">
{entry.detail}
</pre>
</CollapsibleContent>
</Collapsible>
)} )}
{(entry.errorCode?.endsWith('.config_invalid') ||
entry.errorCode === 'runner_execution_failed' ||
entry.errorCode === 'runner.timeout') &&
onOpenRunnerConfig && (
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
onClick={onOpenRunnerConfig}
>
{t('agents.debugReviewRunnerConfig')}
</Button>
)}
</div>
</Alert> </Alert>
))} ))}
</div> </div>
@@ -522,8 +522,9 @@ export default function ModelsPanel({
setTestResult({ success: true, duration }); setTestResult({ success: true, duration });
} catch (err) { } catch (err) {
console.error('Failed to test model', err); console.error('Failed to test model', err);
toast.error(t('models.testError') + ': ' + (err as CustomApiError).msg); const message = (err as CustomApiError).msg || t('models.testError');
setTestResult(null); toast.error(t('models.testError') + ': ' + message);
setTestResult({ success: false, message });
} finally { } finally {
setIsTesting(false); setIsTesting(false);
} }
@@ -437,6 +437,14 @@ export default function AddModelPopover({
)} )}
</Button> </Button>
</div> </div>
{testResult?.success === false && (
<p
role="alert"
className="rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive [overflow-wrap:anywhere]"
>
{testResult.message}
</p>
)}
</div> </div>
</div> </div>
) : ( ) : (
@@ -412,6 +412,14 @@ export default function ModelItem({
)} )}
</Button> </Button>
</div> </div>
{testResult?.success === false && (
<p
role="alert"
className="rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive [overflow-wrap:anywhere]"
>
{testResult.message}
</p>
)}
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -27,10 +27,9 @@ export interface ProviderModels {
rerank: RerankModel[]; rerank: RerankModel[];
} }
export interface TestResult { export type TestResult =
success: boolean; | { success: true; duration: number }
duration: number; | { success: false; message: string };
}
export type SelectedScannedModel = { export type SelectedScannedModel = {
model: ScannedProviderModel; model: ScannedProviderModel;