mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
Merge remote-tracking branch 'origin/dev/4.11.x' into dev/4.11.x
# Conflicts: # uv.lock # web/src/i18n/locales/en-US.ts # web/src/i18n/locales/ja-JP.ts # web/src/i18n/locales/zh-Hans.ts # web/src/i18n/locales/zh-Hant.ts
This commit is contained in:
@@ -43,17 +43,24 @@ const pendingSpaceOAuthLogins = new Map<
|
||||
function getOrCreateSpaceOAuthLoginPromise(
|
||||
authCode: string,
|
||||
state: string,
|
||||
redirectUri: string,
|
||||
workspaceUuid?: string,
|
||||
launchAssertion?: string,
|
||||
): Promise<SpaceOAuthLoginResult> {
|
||||
const requestKey = `${authCode}:${state}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
|
||||
const requestKey = `${authCode}:${state}:${redirectUri}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
|
||||
const pendingRequest = pendingSpaceOAuthLogins.get(requestKey);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
const requestPromise = httpClient
|
||||
.exchangeSpaceOAuthCode(authCode, state, workspaceUuid, launchAssertion)
|
||||
.exchangeSpaceOAuthCode(
|
||||
authCode,
|
||||
state,
|
||||
redirectUri,
|
||||
workspaceUuid,
|
||||
launchAssertion,
|
||||
)
|
||||
.finally(() => {
|
||||
pendingSpaceOAuthLogins.delete(requestKey);
|
||||
});
|
||||
@@ -95,6 +102,7 @@ function SpaceOAuthCallbackContent() {
|
||||
const response = await getOrCreateSpaceOAuthLoginPromise(
|
||||
authCode,
|
||||
state,
|
||||
`${window.location.origin}/auth/space/callback`,
|
||||
workspaceUuid,
|
||||
launchAssertion,
|
||||
);
|
||||
@@ -195,7 +203,11 @@ function SpaceOAuthCallbackContent() {
|
||||
async (authCode: string, state: string) => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const response = await httpClient.bindSpaceAccount(authCode, state);
|
||||
const response = await httpClient.bindSpaceAccount(
|
||||
authCode,
|
||||
state,
|
||||
`${window.location.origin}/auth/space/callback?mode=bind`,
|
||||
);
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -27,12 +27,19 @@ import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
|
||||
import PluginProcessorDetailContent from './PluginProcessorDetailContent';
|
||||
import AgentCreateContent from './components/AgentCreateContent';
|
||||
import AgentDebugPanel from './components/AgentDebugPanel';
|
||||
import AgentMonitoringTab from './components/AgentMonitoringTab';
|
||||
import AgentFormComponent, {
|
||||
AgentFormHandle,
|
||||
RunnerStatus,
|
||||
} from './components/AgentFormComponent';
|
||||
|
||||
export default function AgentDetailContent({ id }: { id: string }) {
|
||||
export default function AgentDetailContent({
|
||||
id,
|
||||
pipelineRevision,
|
||||
}: {
|
||||
id: string;
|
||||
pipelineRevision?: string;
|
||||
}) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
@@ -130,7 +137,13 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
if (loading || !agent) return <EntityLoadState />;
|
||||
|
||||
if (agent.kind === 'pipeline') {
|
||||
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
|
||||
return (
|
||||
<PipelineDetailContent
|
||||
key={pipelineRevision}
|
||||
id={id}
|
||||
routeBase="/home/agents"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function saveBasicInfo(values: EntityBasicInfoValues) {
|
||||
@@ -270,6 +283,21 @@ export default function AgentDetailContent({ id }: { id: string }) {
|
||||
) : undefined
|
||||
}
|
||||
unsavedLabel={t('pipelines.unsavedChanges')}
|
||||
monitoring={
|
||||
currentWorkspace?.permissions.includes('resource.view')
|
||||
? {
|
||||
label: t('pipelines.monitoring.title'),
|
||||
workbenchLabel: t('pipelines.monitoring.workbench'),
|
||||
content: (
|
||||
<AgentMonitoringTab
|
||||
key={id}
|
||||
agentId={id}
|
||||
platformTools={platformTools}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<EntityBasicInfoDialog
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Brain, ChevronRight, RefreshCw, Wrench } from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import type {
|
||||
AgentPlatformTool,
|
||||
ProcessorRun,
|
||||
ProcessorRunEvent,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { executionSteps } from './debug-execution';
|
||||
import { ProcessorPayload } from './PluginProcessorTrace';
|
||||
import {
|
||||
formatRunDuration,
|
||||
processorRunDuration,
|
||||
} from './processor-run-timing';
|
||||
|
||||
const activeStatuses = new Set([
|
||||
'created',
|
||||
'pending',
|
||||
'queued',
|
||||
'claimed',
|
||||
'running',
|
||||
'waiting',
|
||||
]);
|
||||
const failedStatuses = new Set(['failed', 'timeout']);
|
||||
const json = (value: unknown) =>
|
||||
typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
||||
const textClass =
|
||||
'whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-sm';
|
||||
|
||||
export default function AgentMonitoringTab({
|
||||
agentId,
|
||||
platformTools,
|
||||
}: {
|
||||
agentId: string;
|
||||
platformTools: AgentPlatformTool[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [runs, setRuns] = useState<ProcessorRun[]>([]);
|
||||
const [total, setTotal] = useState<number>();
|
||||
const [cursor, setCursor] = useState<number | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [selected, setSelected] = useState<ProcessorRun>();
|
||||
const [events, setEvents] = useState<ProcessorRunEvent[]>([]);
|
||||
const [eventCursor, setEventCursor] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingTrace, setLoadingTrace] = useState(false);
|
||||
const [listError, setListError] = useState(false);
|
||||
const [traceError, setTraceError] = useState(false);
|
||||
const [paging, setPaging] = useState(false);
|
||||
const [traceRevision, setTraceRevision] = useState(0);
|
||||
const generation = useRef(0);
|
||||
const listBusy = useRef(false);
|
||||
const listInitialized = useRef(false);
|
||||
const traceBusy = useRef(false);
|
||||
const alive = useRef(true);
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
return () => {
|
||||
alive.current = false;
|
||||
generation.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (listBusy.current) return;
|
||||
listBusy.current = true;
|
||||
try {
|
||||
const page = await httpClient.getProcessorRuns(agentId);
|
||||
if (!alive.current) return;
|
||||
setRuns((current) => {
|
||||
const merged = new Map(page.items.map((run) => [run.run_id, run]));
|
||||
current.forEach((run) => {
|
||||
if (!merged.has(run.run_id)) merged.set(run.run_id, run);
|
||||
});
|
||||
return [...merged.values()];
|
||||
});
|
||||
if (!listInitialized.current) {
|
||||
setCursor(page.has_more ? page.next_cursor : null);
|
||||
listInitialized.current = true;
|
||||
}
|
||||
setTotal(page.total);
|
||||
setSelectedId((current) => current ?? page.items[0]?.run_id);
|
||||
setListError(false);
|
||||
} catch {
|
||||
if (alive.current) setListError(true);
|
||||
} finally {
|
||||
listBusy.current = false;
|
||||
if (alive.current) setLoading(false);
|
||||
}
|
||||
}, [agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const timer = window.setInterval(() => {
|
||||
if (!document.hidden) void refresh();
|
||||
}, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
const version = ++generation.current;
|
||||
setEvents([]);
|
||||
setSelected(undefined);
|
||||
setEventCursor(null);
|
||||
setLoadingTrace(true);
|
||||
setPaging(false);
|
||||
setTraceError(false);
|
||||
traceBusy.current = false;
|
||||
void httpClient
|
||||
.getProcessorRunEvents(agentId, selectedId)
|
||||
.then((page) => {
|
||||
if (generation.current !== version) return;
|
||||
setSelected(page.run);
|
||||
setEvents(page.items);
|
||||
setEventCursor(page.has_more ? page.next_cursor : null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (generation.current === version) setTraceError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (generation.current === version) setLoadingTrace(false);
|
||||
});
|
||||
return () => {
|
||||
generation.current += 1;
|
||||
};
|
||||
}, [agentId, selectedId, traceRevision]);
|
||||
|
||||
const nextEvents = useCallback(async () => {
|
||||
if (!selectedId || traceBusy.current || loadingTrace) return;
|
||||
const version = generation.current;
|
||||
traceBusy.current = true;
|
||||
setPaging(true);
|
||||
try {
|
||||
const page = await httpClient.getProcessorRunEvents(
|
||||
agentId,
|
||||
selectedId,
|
||||
eventCursor ?? events.at(-1)?.sequence,
|
||||
);
|
||||
if (generation.current !== version) return;
|
||||
setSelected(page.run);
|
||||
setEvents((current) => [
|
||||
...new Map(
|
||||
[...current, ...page.items].map((event) => [event.sequence, event]),
|
||||
).values(),
|
||||
]);
|
||||
setEventCursor(page.has_more ? page.next_cursor : null);
|
||||
setTraceError(false);
|
||||
} catch {
|
||||
if (generation.current === version) setTraceError(true);
|
||||
} finally {
|
||||
if (generation.current === version) {
|
||||
traceBusy.current = false;
|
||||
setPaging(false);
|
||||
}
|
||||
}
|
||||
}, [agentId, selectedId, eventCursor, events, loadingTrace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!selected ||
|
||||
!activeStatuses.has(selected.status) ||
|
||||
eventCursor !== null
|
||||
)
|
||||
return;
|
||||
const timer = window.setInterval(() => {
|
||||
if (!document.hidden) void nextEvents();
|
||||
}, 3000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [selected, eventCursor, nextEvents]);
|
||||
|
||||
async function moreRuns() {
|
||||
if (cursor === null || listBusy.current) return;
|
||||
listBusy.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const page = await httpClient.getProcessorRuns(agentId, cursor);
|
||||
if (!alive.current) return;
|
||||
setRuns((current) => [
|
||||
...new Map(
|
||||
[...current, ...page.items].map((run) => [run.run_id, run]),
|
||||
).values(),
|
||||
]);
|
||||
setCursor(page.has_more ? page.next_cursor : null);
|
||||
setListError(false);
|
||||
} catch {
|
||||
if (alive.current) setListError(true);
|
||||
} finally {
|
||||
listBusy.current = false;
|
||||
if (alive.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const labels = Object.fromEntries(
|
||||
platformTools.map((tool) => [tool.name, extractI18nObject(tool.label)]),
|
||||
);
|
||||
const steps = executionSteps(events);
|
||||
const duration = selected ? processorRunDuration(selected) : null;
|
||||
const failureReason =
|
||||
selected?.status_reason ||
|
||||
events.findLast((event) => event.type === 'run.failed')?.data.error;
|
||||
const models = [
|
||||
...new Set(
|
||||
events.flatMap((event) => {
|
||||
const message = event.data.message ?? event.data.chunk;
|
||||
return message &&
|
||||
typeof message === 'object' &&
|
||||
'model' in message &&
|
||||
typeof message.model === 'string' &&
|
||||
message.model
|
||||
? [message.model]
|
||||
: [];
|
||||
}),
|
||||
),
|
||||
];
|
||||
const stateLabel = (run: ProcessorRun) =>
|
||||
t(
|
||||
`agents.eventProcessor.status_${({ created: 'pending', claimed: 'queued' } as Record<string, string>)[run.status] ?? run.status}`,
|
||||
{
|
||||
defaultValue: run.status,
|
||||
},
|
||||
);
|
||||
const debugRun = (run: ProcessorRun) =>
|
||||
run.binding_id?.startsWith('debug:') || run.metadata.source === 'webui';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col gap-4"
|
||||
data-testid="agent-monitoring"
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('agents.monitoring.description')}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void refresh();
|
||||
setTraceRevision((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
{t('monitoring.refreshData')}
|
||||
</Button>
|
||||
</div>
|
||||
{listError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{t('monitoring.loadError')}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[minmax(16rem,0.8fr)_minmax(0,2fr)]">
|
||||
<section
|
||||
className="flex min-h-0 min-w-0 flex-col gap-2"
|
||||
aria-label={t('agents.eventProcessor.runs')}
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{t('agents.eventProcessor.runs')}
|
||||
{total !== undefined && (
|
||||
<span className="ml-2 text-muted-foreground">{total}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="max-h-72 min-h-0 overflow-y-auto rounded-md border lg:max-h-none lg:flex-1">
|
||||
{runs.map((run) => (
|
||||
<Button
|
||||
key={run.run_id}
|
||||
variant="ghost"
|
||||
aria-pressed={selectedId === run.run_id}
|
||||
onClick={() => setSelectedId(run.run_id)}
|
||||
className="h-auto w-full flex-col items-stretch gap-2 rounded-none border-b p-3 text-left font-normal last:border-b-0 aria-pressed:bg-accent"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{eventPatternLabel(run.metadata.event_type ?? '', t)}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
failedStatuses.has(run.status) ? 'destructive' : 'outline'
|
||||
}
|
||||
>
|
||||
{stateLabel(run)}
|
||||
</Badge>
|
||||
</span>
|
||||
{run.metadata.input?.text && (
|
||||
<span className="truncate text-sm">
|
||||
{run.metadata.input.text}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<time>
|
||||
{new Date(run.created_at * 1000).toLocaleString()}
|
||||
</time>
|
||||
{debugRun(run) && (
|
||||
<Badge variant="secondary">{t('agents.debugTab')}</Badge>
|
||||
)}
|
||||
{processorRunDuration(run) !== null && (
|
||||
<span>{formatRunDuration(processorRunDuration(run)!)}</span>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
{!runs.length && (
|
||||
<p className="p-4 text-sm text-muted-foreground">
|
||||
{loading ? t('common.loading') : t('agents.monitoring.empty')}
|
||||
</p>
|
||||
)}
|
||||
{cursor !== null && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
onClick={() => void moreRuns()}
|
||||
>
|
||||
{t('agents.eventProcessor.loadMore')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
aria-label={t('agents.monitoring.execution')}
|
||||
className="min-h-0 min-w-0 space-y-4 overflow-y-auto lg:pr-2"
|
||||
>
|
||||
{loadingTrace ? (
|
||||
<p role="status">{t('common.loading')}</p>
|
||||
) : (
|
||||
selected && (
|
||||
<>
|
||||
<div className="space-y-2 border-b pb-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="font-medium">
|
||||
{eventPatternLabel(selected.metadata.event_type ?? '', t)}
|
||||
</h2>
|
||||
<Badge
|
||||
variant={
|
||||
failedStatuses.has(selected.status)
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{stateLabel(selected)}
|
||||
</Badge>
|
||||
{debugRun(selected) && (
|
||||
<Badge variant="secondary">{t('agents.debugTab')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<time>
|
||||
{new Date(selected.created_at * 1000).toLocaleString()}
|
||||
</time>
|
||||
{duration !== null && (
|
||||
<span>
|
||||
{t('monitoring.llmCalls.duration')}:{' '}
|
||||
{formatRunDuration(duration)}
|
||||
</span>
|
||||
)}
|
||||
{models.length > 0 && <span>{models.join(', ')}</span>}
|
||||
{selected.usage?.total_tokens != null && (
|
||||
<span>
|
||||
{t('monitoring.llmCalls.totalTokens')}:{' '}
|
||||
{selected.usage.total_tokens.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="break-all text-xs text-muted-foreground">
|
||||
{selected.runner_id}
|
||||
</p>
|
||||
</div>
|
||||
{failedStatuses.has(selected.status) && failureReason && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className={textClass}>
|
||||
{json(failureReason)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Card className="gap-2 py-3">
|
||||
<CardHeader className="px-3">
|
||||
<CardTitle className="text-sm">
|
||||
{t('agents.monitoring.input')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 px-3">
|
||||
{selected.metadata.input?.text && (
|
||||
<p className={textClass}>
|
||||
{selected.metadata.input.text}
|
||||
</p>
|
||||
)}
|
||||
{(selected.metadata.input ??
|
||||
selected.metadata.input_event) != null ? (
|
||||
<ProcessorPayload
|
||||
title={t('agents.monitoring.eventData')}
|
||||
value={
|
||||
selected.metadata.input_event
|
||||
? {
|
||||
...selected.metadata.input,
|
||||
event: selected.metadata.input_event,
|
||||
}
|
||||
: selected.metadata.input
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('agents.monitoring.inputUnavailable')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<h3 className="text-sm font-medium">
|
||||
{t('agents.monitoring.execution')}
|
||||
</h3>
|
||||
{steps.map((step, index) => (
|
||||
<Card key={index} className="gap-2 py-3">
|
||||
<CardHeader className="px-3">
|
||||
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
|
||||
{step.kind === 'tool' ? (
|
||||
<>
|
||||
<Wrench className="size-4 shrink-0" />
|
||||
<span className="break-all">
|
||||
{labels[step.name] || step.name}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
step.status === 'failed'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{t(
|
||||
`agents.debugTool${step.status === 'running' ? (activeStatuses.has(selected.status) || eventCursor !== null ? 'Running' : 'Interrupted') : step.result && typeof step.result === 'object' && 'mock' in step.result && step.result.mock === true ? (step.status === 'failed' ? 'MockFailed' : 'Simulated') : step.status === 'failed' ? 'Failed' : 'Completed'}`,
|
||||
)}
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Brain className="size-4" />
|
||||
{t('agents.debugTextOutput')}
|
||||
</>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 px-3">
|
||||
{step.kind === 'message' ? (
|
||||
<>
|
||||
{step.text && (
|
||||
<p className={textClass}>{step.text}</p>
|
||||
)}
|
||||
{step.reasoning && (
|
||||
<ProcessorPayload
|
||||
title={t('agents.debugReasoning')}
|
||||
value={step.reasoning}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{step.error && (
|
||||
<p className={`${textClass} text-destructive`}>
|
||||
{step.error}
|
||||
</p>
|
||||
)}
|
||||
<ProcessorPayload
|
||||
title={t('agents.debugToolArguments')}
|
||||
value={step.parameters}
|
||||
/>
|
||||
{step.result !== undefined && (
|
||||
<ProcessorPayload
|
||||
title={t('agents.debugToolResult')}
|
||||
value={step.result}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{events
|
||||
.filter((event) => event.type === 'processor.log')
|
||||
.map((event) => (
|
||||
<Alert
|
||||
key={event.sequence}
|
||||
variant={
|
||||
event.data.level === 'error' ? 'destructive' : 'default'
|
||||
}
|
||||
>
|
||||
<AlertDescription className={textClass}>
|
||||
{String(event.data.text ?? '')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
{eventCursor !== null && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={paging}
|
||||
onClick={() => void nextEvents()}
|
||||
>
|
||||
{t('agents.eventProcessor.loadMore')}
|
||||
</Button>
|
||||
)}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="group">
|
||||
<ChevronRight className="size-4 group-data-[state=open]:rotate-90" />
|
||||
{t('agents.monitoring.rawEvents')}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre
|
||||
className={`${textClass} rounded-md bg-muted p-3 font-mono text-xs`}
|
||||
>
|
||||
{json({
|
||||
run_id: selected.run_id,
|
||||
usage: selected.usage,
|
||||
events,
|
||||
})}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{traceError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t('monitoring.loadError')}
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setTraceRevision((value) => value + 1)}
|
||||
>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import PipelineMigration, {
|
||||
migrationWorkspaceKey,
|
||||
} from '@/app/home/pipelines/PipelineMigration';
|
||||
import AgentDetailContent from './AgentDetailContent';
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
|
||||
if (detailId) {
|
||||
return <AgentDetailContent key={detailId} id={detailId} />;
|
||||
}
|
||||
const workspace = useCurrentWorkspace();
|
||||
const { refreshPipelines } = useSidebarData();
|
||||
const [revision, setRevision] = useState(0);
|
||||
const scopeKey = migrationWorkspaceKey(workspace);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('agents.selectFromSidebar')}</p>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{workspace && workspace.permissions.includes('resource.view') && (
|
||||
<PipelineMigration
|
||||
key={scopeKey}
|
||||
workspace={workspace}
|
||||
onComplete={() => {
|
||||
void refreshPipelines();
|
||||
setRevision((current) => current + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{detailId ? (
|
||||
<AgentDetailContent
|
||||
key={detailId}
|
||||
id={detailId}
|
||||
pipelineRevision={`${scopeKey}:${revision}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('agents.selectFromSidebar')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type PluginV4,
|
||||
type RunnerUsage,
|
||||
} from '@/app/infra/entities/plugin';
|
||||
import type { AsyncTask } from '@/app/infra/entities/api';
|
||||
import type { I18nObject } from '@/app/infra/entities/common';
|
||||
|
||||
export const RUNNER_COMPONENT_FILTER = 'Runner';
|
||||
@@ -54,6 +55,7 @@ export interface PendingRunnerInstall {
|
||||
interface InstallRunnerOptions {
|
||||
scope: string;
|
||||
onTaskCreated?: (taskId: number) => void;
|
||||
onProgress?: (task: AsyncTask) => void;
|
||||
}
|
||||
|
||||
function wait(ms: number) {
|
||||
@@ -250,7 +252,7 @@ export async function installMarketplaceRunner(
|
||||
};
|
||||
writePendingRunnerInstall(pending);
|
||||
options.onTaskCreated?.(taskId);
|
||||
return finishRunnerInstall(pending);
|
||||
return finishRunnerInstall(pending, options.onProgress);
|
||||
}
|
||||
|
||||
function extractPluginLabel(plugin: PluginV4) {
|
||||
@@ -267,6 +269,7 @@ function extractPluginLabel(plugin: PluginV4) {
|
||||
|
||||
async function finishRunnerInstall(
|
||||
pending: PendingRunnerInstall,
|
||||
onProgress?: (task: AsyncTask) => void,
|
||||
): Promise<InstalledRunner> {
|
||||
// A refreshed page receives a fresh observation window. The backend task is
|
||||
// authoritative; `startedAt` is display metadata, not a reason to abandon a
|
||||
@@ -275,6 +278,7 @@ async function finishRunnerInstall(
|
||||
let installCompleted = false;
|
||||
while (true) {
|
||||
const task = await httpClient.getAsyncTask(pending.taskId);
|
||||
onProgress?.(task);
|
||||
if (task.runtime.done) {
|
||||
if (task.runtime.exception) {
|
||||
clearPendingRunnerInstall(pending.scope, pending.taskId);
|
||||
@@ -323,8 +327,9 @@ async function finishRunnerInstall(
|
||||
|
||||
export async function resumePendingRunnerInstall(
|
||||
scope: string,
|
||||
onProgress?: (task: AsyncTask) => void,
|
||||
): Promise<InstalledRunner | null> {
|
||||
const pending = readPendingRunnerInstall(scope);
|
||||
if (!pending) return null;
|
||||
return finishRunnerInstall(pending);
|
||||
return finishRunnerInstall(pending, onProgress);
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ export default function PluginProcessorBindings({
|
||||
return (
|
||||
<Card
|
||||
key={binding.processor_uuid}
|
||||
className="gap-0 rounded-lg py-0 shadow-none hover:bg-accent"
|
||||
className="gap-0 rounded-lg py-0 shadow-none"
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<span
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Copy,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Workflow,
|
||||
ThumbsUp,
|
||||
@@ -117,18 +118,48 @@ interface BotSessionMonitorProps {
|
||||
botId: string;
|
||||
}
|
||||
|
||||
const SESSION_PAGE_SIZE = 20;
|
||||
const MESSAGE_PAGE_SIZE = 50;
|
||||
|
||||
const localDateBoundaryToISOString = (
|
||||
dateValue: string,
|
||||
endOfDay: boolean,
|
||||
): string => {
|
||||
const [year, month, day] = dateValue.split('-').map(Number);
|
||||
return new Date(
|
||||
year,
|
||||
month - 1,
|
||||
day,
|
||||
endOfDay ? 23 : 0,
|
||||
endOfDay ? 59 : 0,
|
||||
endOfDay ? 59 : 0,
|
||||
endOfDay ? 999 : 0,
|
||||
).toISOString();
|
||||
};
|
||||
|
||||
const BotSessionMonitor = forwardRef<
|
||||
BotSessionMonitorHandle,
|
||||
BotSessionMonitorProps
|
||||
>(function BotSessionMonitor({ botId }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [sessionTotal, setSessionTotal] = useState(0);
|
||||
const [sessionPage, setSessionPage] = useState(0);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [userQuery, setUserQuery] = useState('');
|
||||
const [appliedUserQuery, setAppliedUserQuery] = useState('');
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [messages, setMessages] = useState<SessionMessage[]>([]);
|
||||
const [messageTotal, setMessageTotal] = useState(0);
|
||||
const [messagePage, setMessagePage] = useState(0);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [sessionError, setSessionError] = useState(false);
|
||||
const [messageError, setMessageError] = useState(false);
|
||||
const [analysisError, setAnalysisError] = useState(false);
|
||||
const [copiedUserId, setCopiedUserId] = useState(false);
|
||||
const [feedbackMap, setFeedbackMap] = useState<
|
||||
Record<string, SessionFeedback>
|
||||
@@ -138,6 +169,8 @@ const BotSessionMonitor = forwardRef<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const sessionRequestIdRef = useRef(0);
|
||||
const messageRequestIdRef = useRef(0);
|
||||
const { admins, reload: reloadAdmins } = useBotAdmins(botId);
|
||||
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
|
||||
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
|
||||
@@ -204,16 +237,36 @@ const BotSessionMonitor = forwardRef<
|
||||
};
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
const requestId = ++sessionRequestIdRef.current;
|
||||
setLoadingSessions(true);
|
||||
setSessionError(false);
|
||||
setSessions([]);
|
||||
try {
|
||||
const response = await httpClient.getBotSessions(botId);
|
||||
const response = await httpClient.getBotSessions(botId, {
|
||||
limit: SESSION_PAGE_SIZE,
|
||||
offset: sessionPage * SESSION_PAGE_SIZE,
|
||||
startTime: startDate
|
||||
? localDateBoundaryToISOString(startDate, false)
|
||||
: undefined,
|
||||
endTime: endDate
|
||||
? localDateBoundaryToISOString(endDate, true)
|
||||
: undefined,
|
||||
userQuery: appliedUserQuery || undefined,
|
||||
});
|
||||
if (requestId !== sessionRequestIdRef.current) return;
|
||||
setSessions(response.sessions ?? []);
|
||||
setSessionTotal(response.total ?? 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to load sessions:', error);
|
||||
if (requestId === sessionRequestIdRef.current) {
|
||||
console.error('Failed to load sessions:', error);
|
||||
setSessionError(true);
|
||||
}
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
if (requestId === sessionRequestIdRef.current) {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
}
|
||||
}, [botId]);
|
||||
}, [appliedUserQuery, botId, endDate, sessionPage, startDate]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
@@ -224,27 +277,44 @@ const BotSessionMonitor = forwardRef<
|
||||
);
|
||||
|
||||
const loadMessages = useCallback(
|
||||
async (sessionId: string) => {
|
||||
async (sessionId: string, page: number) => {
|
||||
const requestId = ++messageRequestIdRef.current;
|
||||
setLoadingMessages(true);
|
||||
setMessageError(false);
|
||||
setAnalysisError(false);
|
||||
setMessages([]);
|
||||
setToolCalls([]);
|
||||
setFeedbackMap({});
|
||||
setExpandedToolCallIds({});
|
||||
try {
|
||||
const messagesRes = await httpClient.getSessionMessages(sessionId);
|
||||
const messagesRes = await httpClient.getSessionMessages(
|
||||
sessionId,
|
||||
MESSAGE_PAGE_SIZE,
|
||||
page * MESSAGE_PAGE_SIZE,
|
||||
botId,
|
||||
);
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
const sorted = (messagesRes.messages ?? []).sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
setMessages(sorted);
|
||||
setMessageTotal(messagesRes.total ?? 0);
|
||||
|
||||
try {
|
||||
const analysisRes = await httpClient.get<{
|
||||
const analysisRes = await httpClient.getSessionAnalysis<{
|
||||
tool_calls?: SessionToolCall[];
|
||||
}>(
|
||||
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`,
|
||||
);
|
||||
}>(sessionId, botId, {
|
||||
startTime: sorted[0]?.timestamp,
|
||||
endTime: sorted[sorted.length - 1]?.timestamp,
|
||||
});
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
setToolCalls(analysisRes?.tool_calls ?? []);
|
||||
} catch (analysisError) {
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
console.error('Failed to load session tool calls:', analysisError);
|
||||
setToolCalls([]);
|
||||
setAnalysisError(true);
|
||||
}
|
||||
|
||||
// Collect user message IDs for feedback matching
|
||||
@@ -259,6 +329,7 @@ const BotSessionMonitor = forwardRef<
|
||||
}>(
|
||||
`/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`,
|
||||
);
|
||||
if (requestId !== messageRequestIdRef.current) return;
|
||||
|
||||
const map: Record<string, SessionFeedback> = {};
|
||||
if (feedbackRes?.feedback) {
|
||||
@@ -273,9 +344,14 @@ const BotSessionMonitor = forwardRef<
|
||||
setFeedbackMap({});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load session messages:', error);
|
||||
if (requestId === messageRequestIdRef.current) {
|
||||
console.error('Failed to load session messages:', error);
|
||||
setMessageError(true);
|
||||
}
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
if (requestId === messageRequestIdRef.current) {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[botId],
|
||||
@@ -283,18 +359,34 @@ const BotSessionMonitor = forwardRef<
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
return () => {
|
||||
sessionRequestIdRef.current += 1;
|
||||
};
|
||||
}, [loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSessionId(null);
|
||||
setMessagePage(0);
|
||||
}, [appliedUserQuery, botId, endDate, sessionPage, startDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSessionId) {
|
||||
loadMessages(selectedSessionId);
|
||||
loadMessages(selectedSessionId, messagePage);
|
||||
} else {
|
||||
messageRequestIdRef.current += 1;
|
||||
setLoadingMessages(false);
|
||||
setMessageError(false);
|
||||
setAnalysisError(false);
|
||||
setMessages([]);
|
||||
setMessageTotal(0);
|
||||
setToolCalls([]);
|
||||
setExpandedToolCallIds({});
|
||||
setFeedbackMap({});
|
||||
}
|
||||
}, [selectedSessionId, loadMessages]);
|
||||
return () => {
|
||||
messageRequestIdRef.current += 1;
|
||||
};
|
||||
}, [selectedSessionId, messagePage, loadMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0 && toolCalls.length === 0) return;
|
||||
@@ -552,6 +644,19 @@ const BotSessionMonitor = forwardRef<
|
||||
const selectedSession = sessions.find(
|
||||
(s) => s.session_id === selectedSessionId,
|
||||
);
|
||||
const sessionPageCount = Math.max(
|
||||
1,
|
||||
Math.ceil(sessionTotal / SESSION_PAGE_SIZE),
|
||||
);
|
||||
const messagePageCount = Math.max(
|
||||
1,
|
||||
Math.ceil(messageTotal / MESSAGE_PAGE_SIZE),
|
||||
);
|
||||
|
||||
const applyUserSearch = () => {
|
||||
setSessionPage(0);
|
||||
setAppliedUserQuery(userQuery.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -575,6 +680,65 @@ const BotSessionMonitor = forwardRef<
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{t('bots.sessionMonitor.totalSessions', {
|
||||
defaultValue: '{{count}} sessions',
|
||||
count: sessionTotal,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-1.5 border-b shrink-0 space-y-1.5">
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={userQuery}
|
||||
onChange={(event) => setUserQuery(event.target.value)}
|
||||
onKeyDown={(event) =>
|
||||
event.key === 'Enter' && applyUserSearch()
|
||||
}
|
||||
aria-label={t('bots.sessionMonitor.userSearch', {
|
||||
defaultValue: 'User ID or name',
|
||||
})}
|
||||
placeholder={t('bots.sessionMonitor.userSearch', {
|
||||
defaultValue: 'User ID or name',
|
||||
})}
|
||||
className="h-7 min-w-0 flex-1 rounded border bg-background px-2 text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyUserSearch}
|
||||
className="h-7 rounded border px-2 text-[11px] hover:bg-accent"
|
||||
>
|
||||
{t('common.search', { defaultValue: 'Search' })}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
max={endDate || undefined}
|
||||
onChange={(event) => {
|
||||
setSessionPage(0);
|
||||
setStartDate(event.target.value);
|
||||
}}
|
||||
aria-label={t('bots.sessionMonitor.startDate', {
|
||||
defaultValue: 'Start date',
|
||||
})}
|
||||
className="h-7 min-w-0 rounded border bg-background px-1 text-[10px]"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
min={startDate || undefined}
|
||||
onChange={(event) => {
|
||||
setSessionPage(0);
|
||||
setEndDate(event.target.value);
|
||||
}}
|
||||
aria-label={t('bots.sessionMonitor.endDate', {
|
||||
defaultValue: 'End date',
|
||||
})}
|
||||
className="h-7 min-w-0 rounded border bg-background px-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Session List */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
@@ -582,6 +746,20 @@ const BotSessionMonitor = forwardRef<
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{t('bots.sessionMonitor.loading')}
|
||||
</div>
|
||||
) : sessionError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="p-3 space-y-2 text-sm text-destructive"
|
||||
>
|
||||
<p>{t('monitoring.loadError')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadSessions}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.noSessions')}
|
||||
@@ -601,7 +779,10 @@ const BotSessionMonitor = forwardRef<
|
||||
'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer',
|
||||
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
|
||||
)}
|
||||
onClick={() => setSelectedSessionId(session.session_id)}
|
||||
onClick={() => {
|
||||
setSelectedSessionId(session.session_id);
|
||||
setMessagePage(0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="text-sm font-medium truncate mr-2">
|
||||
@@ -637,6 +818,29 @@ const BotSessionMonitor = forwardRef<
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<div className="h-8 border-t px-1.5 flex items-center justify-between shrink-0 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.previous', { defaultValue: 'Previous' })}
|
||||
disabled={sessionPage === 0 || loadingSessions}
|
||||
onClick={() => setSessionPage((page) => Math.max(0, page - 1))}
|
||||
className="p-1 rounded hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
</button>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{sessionPage + 1} / {sessionPageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.next', { defaultValue: 'Next' })}
|
||||
disabled={sessionPage + 1 >= sessionPageCount || loadingSessions}
|
||||
onClick={() => setSessionPage((page) => page + 1)}
|
||||
className="p-1 rounded hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel: Messages */}
|
||||
@@ -726,10 +930,46 @@ const BotSessionMonitor = forwardRef<
|
||||
className="flex-1 px-4 py-4 overflow-y-auto min-h-0"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{analysisError && !loadingMessages && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-sm text-destructive space-y-2"
|
||||
>
|
||||
<p>
|
||||
{t('monitoring.toolCalls.title')}:{' '}
|
||||
{t('monitoring.loadError')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
loadMessages(selectedSessionId, messagePage)
|
||||
}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{loadingMessages ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.loading')}
|
||||
</div>
|
||||
) : messageError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-sm text-destructive space-y-2"
|
||||
>
|
||||
<p>{t('monitoring.loadError')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
loadMessages(selectedSessionId, messagePage)
|
||||
}
|
||||
className="rounded border px-2 py-1 text-foreground"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : timelineItems.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.noMessages')}
|
||||
@@ -975,6 +1215,33 @@ const BotSessionMonitor = forwardRef<
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="h-9 border-t px-3 flex items-center justify-center gap-3 shrink-0 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
disabled={messagePage === 0 || loadingMessages}
|
||||
onClick={() =>
|
||||
setMessagePage((page) => Math.max(0, page - 1))
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
{t('common.previous', { defaultValue: 'Previous' })}
|
||||
</button>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{messagePage + 1} / {messagePageCount} · {messageTotal}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
messagePage + 1 >= messagePageCount || loadingMessages
|
||||
}
|
||||
onClick={() => setMessagePage((page) => page + 1)}
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
{t('common.next', { defaultValue: 'Next' })}
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,17 @@ import {
|
||||
} from '@/components/ui/item';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
|
||||
import {
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
Layers,
|
||||
Fingerprint,
|
||||
Plus,
|
||||
Trash2,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
|
||||
import { PanelBody } from '../settings-dialog/panel-layout';
|
||||
|
||||
@@ -22,6 +32,16 @@ interface AccountSettingsPanelProps {
|
||||
onEmailResolved?: (email: string) => void;
|
||||
}
|
||||
|
||||
interface PasskeyItem {
|
||||
uuid: string;
|
||||
name: string;
|
||||
aaguid?: string;
|
||||
transports?: string;
|
||||
backed_up?: boolean;
|
||||
created_at?: string;
|
||||
last_used_at?: string;
|
||||
}
|
||||
|
||||
export default function AccountSettingsPanel({
|
||||
active,
|
||||
onEmailResolved,
|
||||
@@ -33,10 +53,14 @@ export default function AccountSettingsPanel({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
const [passkeys, setPasskeys] = useState<PasskeyItem[]>([]);
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const [registeringPasskey, setRegisteringPasskey] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
loadUserInfo();
|
||||
loadPasskeys();
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
@@ -55,6 +79,67 @@ export default function AccountSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPasskeys() {
|
||||
setPasskeyLoading(true);
|
||||
try {
|
||||
const list = await httpClient.getPasskeys();
|
||||
setPasskeys(list);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddPasskey = async () => {
|
||||
setRegisteringPasskey(true);
|
||||
try {
|
||||
const { options, challenge_token } =
|
||||
await httpClient.getPasskeyRegisterOptions(window.location.origin);
|
||||
const regResp = await startRegistration({ optionsJSON: options });
|
||||
const defaultName =
|
||||
prompt(t('account.passkeyNamePlaceholder')) || undefined;
|
||||
await httpClient.verifyPasskeyRegister(
|
||||
challenge_token,
|
||||
regResp,
|
||||
defaultName,
|
||||
);
|
||||
toast.success(t('account.passkeyAddedSuccess'));
|
||||
await loadPasskeys();
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'NotAllowedError') {
|
||||
// User cancelled
|
||||
} else {
|
||||
toast.error(error?.message || t('common.error'));
|
||||
}
|
||||
} finally {
|
||||
setRegisteringPasskey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePasskey = async (uuid: string) => {
|
||||
if (!confirm(t('account.deletePasskeyConfirm'))) return;
|
||||
try {
|
||||
await httpClient.deletePasskey(uuid);
|
||||
toast.success(t('account.passkeyDeleteSuccess'));
|
||||
await loadPasskeys();
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenamePasskey = async (uuid: string, currentName: string) => {
|
||||
const newName = prompt(t('account.passkeyName'), currentName);
|
||||
if (!newName || !newName.trim() || newName === currentName) return;
|
||||
try {
|
||||
await httpClient.renamePasskey(uuid, newName.trim());
|
||||
toast.success(t('account.passkeyRenameSuccess'));
|
||||
await loadPasskeys();
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBindSpace = async () => {
|
||||
setSpaceBindLoading(true);
|
||||
try {
|
||||
@@ -148,6 +233,105 @@ export default function AccountSettingsPanel({
|
||||
</ItemActions>
|
||||
)}
|
||||
</Item>
|
||||
|
||||
{/* Passkey Section */}
|
||||
<div className="pt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">
|
||||
{t('account.passkeySectionTitle')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('account.passkeySectionDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddPasskey}
|
||||
disabled={
|
||||
registeringPasskey || !systemInfo.allow_modify_login_info
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{registeringPasskey ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('account.addPasskey')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{passkeyLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : passkeys.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
|
||||
{t('account.noPasskeys')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{passkeys.map((pk) => (
|
||||
<Item
|
||||
key={pk.uuid}
|
||||
size="sm"
|
||||
variant="muted"
|
||||
className="rounded-lg"
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<Fingerprint className="h-4 w-4" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{pk.name}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{pk.created_at && (
|
||||
<span>
|
||||
{t('account.passkeyCreated', {
|
||||
date: new Date(
|
||||
pk.created_at,
|
||||
).toLocaleDateString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{pk.last_used_at && (
|
||||
<span className="ml-2">
|
||||
·{' '}
|
||||
{t('account.passkeyLastUsed', {
|
||||
date: new Date(
|
||||
pk.last_used_at,
|
||||
).toLocaleDateString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 cursor-pointer"
|
||||
onClick={() => handleRenamePasskey(pk.uuid, pk.name)}
|
||||
disabled={!systemInfo.allow_modify_login_info}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive cursor-pointer hover:text-destructive"
|
||||
onClick={() => handleDeletePasskey(pk.uuid)}
|
||||
disabled={!systemInfo.allow_modify_login_info}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { ControllerRenderProps } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { isJsonValue, isPromptValue } from './StructuredFieldValue';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Form,
|
||||
@@ -49,30 +50,10 @@ import {
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||
import { parseDynamicFormItemType } from './DynamicFormItemConfig';
|
||||
|
||||
/**
|
||||
* Resolve the value referenced by a `show_if.field` string.
|
||||
*
|
||||
* Fields prefixed with `__system.` are looked up in the caller-supplied
|
||||
* `systemContext` dictionary (e.g. `__system.is_wizard` → `systemContext.is_wizard`).
|
||||
* All other field names are resolved from the live form values first, then
|
||||
* fall back to `externalDependentValues`.
|
||||
*/
|
||||
function resolveShowIfValue(
|
||||
field: string,
|
||||
watchedValues: Record<string, unknown>,
|
||||
externalDependentValues?: Record<string, unknown>,
|
||||
systemContext?: Record<string, unknown>,
|
||||
): unknown {
|
||||
if (field.startsWith(SYSTEM_FIELD_PREFIX)) {
|
||||
const key = field.slice(SYSTEM_FIELD_PREFIX.length);
|
||||
return systemContext?.[key];
|
||||
}
|
||||
if (watchedValues[field] !== undefined) {
|
||||
return watchedValues[field];
|
||||
}
|
||||
return externalDependentValues?.[field];
|
||||
}
|
||||
import {
|
||||
resolveDisabledState,
|
||||
resolveShowIfValue,
|
||||
} from './DynamicFormConditions';
|
||||
|
||||
type DynamicFormValueSpec = Pick<
|
||||
IDynamicFormItemSchema,
|
||||
@@ -153,13 +134,10 @@ function getValueSchema(spec: DynamicFormValueSpec) {
|
||||
fallbacks: z.array(z.string()),
|
||||
reasoning: z.record(z.string()),
|
||||
});
|
||||
case DynamicFormItemType.JSON:
|
||||
return z.custom(isJsonValue);
|
||||
case DynamicFormItemType.PROMPT_EDITOR:
|
||||
return z.array(
|
||||
z.object({
|
||||
content: z.string(),
|
||||
role: z.string(),
|
||||
}),
|
||||
);
|
||||
return z.custom(isPromptValue);
|
||||
default:
|
||||
return z.string();
|
||||
}
|
||||
@@ -676,6 +654,7 @@ export default function DynamicFormComponent({
|
||||
};
|
||||
const fieldKey = config.id || config.name || `field-${index}`;
|
||||
|
||||
let isHiddenByCondition = false;
|
||||
if (config.show_if) {
|
||||
const dependValue = resolveShowIfValue(
|
||||
config.show_if.field,
|
||||
@@ -688,57 +667,45 @@ export default function DynamicFormComponent({
|
||||
config.show_if.operator === 'eq' &&
|
||||
dependValue !== config.show_if.value
|
||||
) {
|
||||
return null;
|
||||
isHiddenByCondition = true;
|
||||
}
|
||||
if (
|
||||
config.show_if.operator === 'neq' &&
|
||||
dependValue === config.show_if.value
|
||||
) {
|
||||
return null;
|
||||
isHiddenByCondition = true;
|
||||
}
|
||||
if (
|
||||
config.show_if.operator === 'in' &&
|
||||
Array.isArray(config.show_if.value) &&
|
||||
!config.show_if.value.includes(dependValue)
|
||||
) {
|
||||
return null;
|
||||
isHiddenByCondition = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ``disable_if`` mirrors ``show_if``'s evaluator but instead of
|
||||
// hiding the field, leaves it visible and inert. Use it when the
|
||||
// operator needs to see that the field exists yet cannot edit it
|
||||
// under the current runtime state (e.g. sandbox-bound fields when
|
||||
// Box is disabled).
|
||||
let isDisabledByCondition = false;
|
||||
if (config.disable_if) {
|
||||
const dependValue = resolveShowIfValue(
|
||||
config.disable_if.field,
|
||||
// Keep structured drafts mounted across conditional hiding so invalid
|
||||
// JSON cannot disappear from validation when Advanced Settings closes.
|
||||
if (
|
||||
isHiddenByCondition &&
|
||||
normalizedConfig.type !== DynamicFormItemType.JSON &&
|
||||
normalizedConfig.type !== DynamicFormItemType.PROMPT_EDITOR
|
||||
)
|
||||
return null;
|
||||
|
||||
// Keep locked fields visible and resolve only the applicable reason.
|
||||
const { isDisabledByCondition, disabledTooltip: tooltip } =
|
||||
resolveDisabledState(
|
||||
config,
|
||||
watchedValues as Record<string, unknown>,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
);
|
||||
const cond = config.disable_if;
|
||||
if (cond.operator === 'eq' && dependValue === cond.value) {
|
||||
isDisabledByCondition = true;
|
||||
} else if (cond.operator === 'neq' && dependValue !== cond.value) {
|
||||
isDisabledByCondition = true;
|
||||
} else if (
|
||||
cond.operator === 'in' &&
|
||||
Array.isArray(cond.value) &&
|
||||
cond.value.includes(dependValue)
|
||||
) {
|
||||
isDisabledByCondition = true;
|
||||
}
|
||||
}
|
||||
|
||||
// All fields are disabled when editing (creation_settings are
|
||||
// immutable) or when ``disable_if`` matches.
|
||||
const isFieldDisabled = !!isEditing || isDisabledByCondition;
|
||||
const disabledTooltip =
|
||||
isDisabledByCondition && config.disabled_tooltip
|
||||
? extractI18nObject(config.disabled_tooltip)
|
||||
: '';
|
||||
const disabledTooltip = tooltip ? extractI18nObject(tooltip) : '';
|
||||
const renderDisabledTooltipIcon = () =>
|
||||
disabledTooltip ? (
|
||||
<DisabledTooltipIcon text={disabledTooltip} />
|
||||
@@ -998,7 +965,10 @@ export default function DynamicFormComponent({
|
||||
setFormValue,
|
||||
});
|
||||
return (
|
||||
<FormItem className="min-w-0">
|
||||
<FormItem
|
||||
className={cn('min-w-0', isHiddenByCondition && 'hidden')}
|
||||
hidden={isHiddenByCondition}
|
||||
>
|
||||
<FormLabel className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 break-words">
|
||||
{extractI18nObject(config.label)}{' '}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
SYSTEM_FIELD_PREFIX,
|
||||
type IDynamicFormItemSchema,
|
||||
type IShowIfCondition,
|
||||
} from '@/app/infra/entities/form/dynamic';
|
||||
|
||||
/** System references use caller context; other fields prefer live form values. */
|
||||
export function resolveShowIfValue(
|
||||
field: string,
|
||||
watchedValues: Record<string, unknown>,
|
||||
externalDependentValues?: Record<string, unknown>,
|
||||
systemContext?: Record<string, unknown>,
|
||||
): unknown {
|
||||
if (field.startsWith(SYSTEM_FIELD_PREFIX)) {
|
||||
return systemContext?.[field.slice(SYSTEM_FIELD_PREFIX.length)];
|
||||
}
|
||||
if (watchedValues[field] !== undefined) {
|
||||
return watchedValues[field];
|
||||
}
|
||||
return externalDependentValues?.[field];
|
||||
}
|
||||
|
||||
export function matchesFormCondition(
|
||||
condition: IShowIfCondition,
|
||||
watchedValues: Record<string, unknown>,
|
||||
externalDependentValues?: Record<string, unknown>,
|
||||
systemContext?: Record<string, unknown>,
|
||||
): boolean {
|
||||
const value = resolveShowIfValue(
|
||||
condition.field,
|
||||
watchedValues,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
);
|
||||
switch (condition.operator) {
|
||||
case 'eq':
|
||||
return value === condition.value;
|
||||
case 'neq':
|
||||
return value !== condition.value;
|
||||
case 'in':
|
||||
return Array.isArray(condition.value) && condition.value.includes(value);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDisabledState(
|
||||
config: Pick<
|
||||
IDynamicFormItemSchema,
|
||||
'disable_if' | 'disabled_tooltip' | 'disabled_tooltip_overrides'
|
||||
>,
|
||||
watchedValues: Record<string, unknown>,
|
||||
externalDependentValues?: Record<string, unknown>,
|
||||
systemContext?: Record<string, unknown>,
|
||||
) {
|
||||
const matches = (condition: IShowIfCondition) =>
|
||||
matchesFormCondition(
|
||||
condition,
|
||||
watchedValues,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
);
|
||||
const isDisabledByCondition =
|
||||
!!config.disable_if && matches(config.disable_if);
|
||||
const disabledTooltip = isDisabledByCondition
|
||||
? (config.disabled_tooltip_overrides?.find((override) =>
|
||||
matches(override.when),
|
||||
)?.tooltip ?? config.disabled_tooltip)
|
||||
: undefined;
|
||||
return { isDisabledByCondition, disabledTooltip };
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { hasModelReasoningAbility } from '@/app/home/components/reasoning/model-reasoning';
|
||||
import {
|
||||
DynamicFormItemType,
|
||||
IDynamicFormItemSchema,
|
||||
IFileConfig,
|
||||
} from '@/app/infra/entities/form/dynamic';
|
||||
import StructuredFieldEditor from './StructuredFieldEditor';
|
||||
import PresetSelect from './PresetSelect';
|
||||
import { isSimplePrompt } from './StructuredFieldValue';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
@@ -190,8 +194,7 @@ export default function DynamicFormItemComponent({
|
||||
{model.abilities?.includes('func_call') && (
|
||||
<Wrench className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{(model.reasoning_capabilities?.supported === true ||
|
||||
model.abilities?.includes('reasoning')) && (
|
||||
{hasModelReasoningAbility(model) && (
|
||||
<BrainCircuit
|
||||
className="h-3 w-3 shrink-0 text-muted-foreground"
|
||||
aria-label={t('models.reasoningAbility')}
|
||||
@@ -490,14 +493,7 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
case DynamicFormItemType.JSON:
|
||||
return (
|
||||
<Textarea
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
className="min-h-[200px] font-mono text-sm"
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
);
|
||||
return <StructuredFieldEditor field={field} />;
|
||||
|
||||
case DynamicFormItemType.BOOLEAN:
|
||||
return (
|
||||
@@ -549,6 +545,9 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
case DynamicFormItemType.SELECT:
|
||||
if (config.allow_custom) {
|
||||
return <PresetSelect config={config} field={field} />;
|
||||
}
|
||||
const selectedOption = config.options?.find(
|
||||
(option) => option.name === field.value,
|
||||
);
|
||||
@@ -1265,11 +1264,7 @@ export default function DynamicFormItemComponent({
|
||||
) => {
|
||||
if (!modelUuid) return;
|
||||
const updated = { ...modelValue.reasoning };
|
||||
if (level === 'provider_default') {
|
||||
delete updated[modelUuid];
|
||||
} else {
|
||||
updated[modelUuid] = level;
|
||||
}
|
||||
updated[modelUuid] = level;
|
||||
updateValue({ reasoning: updated });
|
||||
};
|
||||
|
||||
@@ -1298,6 +1293,7 @@ export default function DynamicFormItemComponent({
|
||||
const model = llmModels.find(
|
||||
(candidate) => candidate.uuid === modelUuid,
|
||||
);
|
||||
if (!hasModelReasoningAbility(model)) return null;
|
||||
const currentLevel =
|
||||
modelValue.reasoning[modelUuid] || 'provider_default';
|
||||
const availableLevels = model?.reasoning_capabilities?.levels || [
|
||||
@@ -1872,6 +1868,9 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
case DynamicFormItemType.PROMPT_EDITOR: {
|
||||
if (!isSimplePrompt(field.value)) {
|
||||
return <StructuredFieldEditor field={field} prompt />;
|
||||
}
|
||||
// Guard: field.value may be undefined when the form resets or
|
||||
// initialValues haven't propagated yet. Fall back to a default
|
||||
// single system-prompt entry to prevent the .map() crash.
|
||||
|
||||
@@ -16,6 +16,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
||||
type: DynamicFormItemType;
|
||||
description?: I18nObject;
|
||||
options?: IDynamicFormItemOption[];
|
||||
allow_custom?: boolean;
|
||||
show_if?: IShowIfCondition;
|
||||
login_platform?: string;
|
||||
url?: string;
|
||||
@@ -32,6 +33,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
||||
this.type = params.type;
|
||||
this.description = params.description;
|
||||
this.options = params.options;
|
||||
this.allow_custom = params.allow_custom;
|
||||
this.show_if = params.show_if;
|
||||
this.login_platform = params.login_platform;
|
||||
this.url = params.url;
|
||||
@@ -56,7 +58,6 @@ export function parseDynamicFormItemType(value: string): DynamicFormItemType {
|
||||
[DynamicFormItemType.SELECT_KNOWLEDGE_BASES]:
|
||||
DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR,
|
||||
[DynamicFormItemType.NUMBER]: DynamicFormItemType.FLOAT,
|
||||
[DynamicFormItemType.JSON]: DynamicFormItemType.TEXT,
|
||||
};
|
||||
if (value in typeMap) {
|
||||
return typeMap[value];
|
||||
|
||||
@@ -80,6 +80,7 @@ export function normalizeDynamicFormFieldValue(
|
||||
}
|
||||
|
||||
const reasoningLevels = new Set([
|
||||
'provider_default',
|
||||
'disabled',
|
||||
'enabled',
|
||||
'minimal',
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const COMMON_N8N_CONFIG_FIELDS = new Set([
|
||||
'webhook-url',
|
||||
'auth-type',
|
||||
'timeout',
|
||||
'output-key',
|
||||
'response-handling',
|
||||
]);
|
||||
|
||||
export function shouldShowN8nConfigField(
|
||||
fieldName: string,
|
||||
authType: string,
|
||||
): boolean {
|
||||
if (COMMON_N8N_CONFIG_FIELDS.has(fieldName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(authType === 'basic' && fieldName.startsWith('basic-')) ||
|
||||
(authType === 'jwt' && fieldName.startsWith('jwt-')) ||
|
||||
(authType === 'header' && fieldName.startsWith('header-'))
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
|
||||
import { shouldShowN8nConfigField } from '@/app/home/components/dynamic-form/N8nAuthFieldVisibility';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
|
||||
/**
|
||||
@@ -181,29 +182,9 @@ export default function N8nAuthFormComponent({
|
||||
}, [form, itemConfigList]);
|
||||
|
||||
// 根据认证类型过滤表单项
|
||||
const filteredConfigList = itemConfigList.filter((config) => {
|
||||
// 始终显示webhook-url、auth-type、timeout和output-key
|
||||
if (
|
||||
['webhook-url', 'auth-type', 'timeout', 'output-key'].includes(
|
||||
config.name,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 根据认证类型显示相应的表单项
|
||||
if (authType === 'basic' && config.name.startsWith('basic-')) {
|
||||
return true;
|
||||
}
|
||||
if (authType === 'jwt' && config.name.startsWith('jwt-')) {
|
||||
return true;
|
||||
}
|
||||
if (authType === 'header' && config.name.startsWith('header-')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
const filteredConfigList = itemConfigList.filter((config) =>
|
||||
shouldShowN8nConfigField(config.name, authType),
|
||||
);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
import { ControllerRenderProps } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export default function PresetSelect({
|
||||
config,
|
||||
field,
|
||||
}: {
|
||||
config: IDynamicFormItemSchema;
|
||||
field: ControllerRenderProps;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const options = config.options?.filter((option) => option.name.trim()) ?? [];
|
||||
const presetIndex = options.findIndex(
|
||||
(option) => option.name === field.value,
|
||||
);
|
||||
// Selecting custom keeps the existing template until the user edits it.
|
||||
const [editingValue, setEditingValue] = useState<string | null>(null);
|
||||
const custom = presetIndex < 0 || editingValue === field.value;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<Select
|
||||
value={custom ? 'custom' : `preset-${presetIndex}`}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'custom') {
|
||||
setEditingValue(field.value ?? '');
|
||||
} else {
|
||||
setEditingValue(null);
|
||||
field.onChange(options[Number(value.slice(7))].name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
onBlur={field.onBlur}
|
||||
disabled={field.disabled}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option, index) => (
|
||||
<SelectItem
|
||||
key={option.name}
|
||||
value={`preset-${index}`}
|
||||
description={option.name}
|
||||
>
|
||||
{extractI18nObject(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">{t('common.customValue')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{custom && (
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
aria-label={`${extractI18nObject(config.label)} — ${t('common.customValue')}`}
|
||||
onChange={(event) => {
|
||||
setEditingValue(event.target.value);
|
||||
field.onChange(event.target.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ControllerRenderProps } from 'react-hook-form';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { parseStructuredDraft } from './StructuredFieldValue';
|
||||
|
||||
/** Draft text stays local: only explicitly parsed edits enter persisted state. */
|
||||
export default function StructuredFieldEditor({
|
||||
field,
|
||||
prompt = false,
|
||||
}: {
|
||||
field: ControllerRenderProps<any, any>;
|
||||
prompt?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const form = useFormContext();
|
||||
const [draft, setDraft] = useState(
|
||||
() => JSON.stringify(field.value, null, 2) ?? '',
|
||||
);
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const emitted = useRef(field.value);
|
||||
const input = useRef<HTMLTextAreaElement | null>(null);
|
||||
const error = t('agents.debugData.invalidField', {
|
||||
field: `${field.name} (JSON)`,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (JSON.stringify(field.value) !== JSON.stringify(emitted.current)) {
|
||||
emitted.current = field.value;
|
||||
setDraft(JSON.stringify(field.value, null, 2) ?? '');
|
||||
setInvalid(false);
|
||||
input.current?.setCustomValidity('');
|
||||
}
|
||||
}, [field.value]);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
ref={(element) => {
|
||||
input.current = element;
|
||||
field.ref(element);
|
||||
}}
|
||||
name={field.name}
|
||||
aria-label={`${field.name} (JSON)`}
|
||||
data-testid={`structured-editor-${field.name}`}
|
||||
aria-invalid={invalid}
|
||||
className="min-h-48 font-mono"
|
||||
value={draft}
|
||||
onBlur={field.onBlur}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setDraft(next);
|
||||
try {
|
||||
const value = parseStructuredDraft(next, prompt);
|
||||
event.target.setCustomValidity('');
|
||||
setInvalid(false);
|
||||
form.clearErrors(field.name);
|
||||
emitted.current = value;
|
||||
field.onChange(value);
|
||||
} catch {
|
||||
event.target.setCustomValidity(error);
|
||||
setInvalid(true);
|
||||
form.setError(field.name, { type: 'json', message: error });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{invalid && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
export type PromptMessage = {
|
||||
role: string;
|
||||
content?: string | null | JsonValue[];
|
||||
[key: string]: JsonValue | undefined;
|
||||
};
|
||||
|
||||
export function isJsonValue(value: unknown): value is JsonValue {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
||||
return true;
|
||||
if (typeof value === 'number') return Number.isFinite(value);
|
||||
if (Array.isArray(value)) return value.every(isJsonValue);
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
Object.values(value).every(isJsonValue)
|
||||
);
|
||||
}
|
||||
export function isPromptValue(value: unknown): value is PromptMessage[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(item) =>
|
||||
item !== null &&
|
||||
typeof item === 'object' &&
|
||||
!Array.isArray(item) &&
|
||||
typeof item.role === 'string' &&
|
||||
(!Object.prototype.hasOwnProperty.call(item, 'content') ||
|
||||
typeof item.content === 'string' ||
|
||||
item.content === null ||
|
||||
Array.isArray(item.content)) &&
|
||||
isJsonValue(item),
|
||||
)
|
||||
);
|
||||
}
|
||||
export function parseStructuredDraft(
|
||||
draft: string,
|
||||
prompt: boolean,
|
||||
): JsonValue {
|
||||
const value: unknown = JSON.parse(draft);
|
||||
if (!(prompt ? isPromptValue(value) : isJsonValue(value)))
|
||||
throw new TypeError('JSON');
|
||||
return value as JsonValue;
|
||||
}
|
||||
export function isSimplePrompt(
|
||||
value: unknown,
|
||||
): value is { role: string; content: string }[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every(
|
||||
(item, index) =>
|
||||
item &&
|
||||
typeof item.content === 'string' &&
|
||||
(index === 0
|
||||
? item.role === 'system'
|
||||
: ['user', 'assistant'].includes(item.role)) &&
|
||||
Object.keys(item).every((key) => key === 'role' || key === 'content'),
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -2364,12 +2364,12 @@ export default function HomeSidebar({
|
||||
localStorage.getItem('langbot_language');
|
||||
if (language === 'zh-Hans' || language === 'zh-Hant') {
|
||||
window.open(
|
||||
'https://link.langbot.app/zh/docs/guide',
|
||||
'https://langbot.app/docs/zh/insight/guide',
|
||||
'_blank',
|
||||
);
|
||||
} else {
|
||||
window.open(
|
||||
'https://link.langbot.app/en/docs/guide',
|
||||
'https://langbot.app/docs/en/insight/guide',
|
||||
'_blank',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/bots',
|
||||
description: t('bots.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://link.langbot.app/en/docs/platforms',
|
||||
zh_Hans: 'https://link.langbot.app/zh/docs/platforms',
|
||||
ja_JP: 'https://link.langbot.app/ja/docs/platforms',
|
||||
en_US: 'https://langbot.app/docs/en/usage/platforms/readme',
|
||||
zh_Hans: 'https://langbot.app/docs/zh/usage/platforms/readme',
|
||||
ja_JP: 'https://langbot.app/docs/ja/usage/platforms/readme',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
@@ -62,9 +62,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/agents',
|
||||
description: t('agents.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://link.langbot.app/en/docs/pipelines',
|
||||
zh_Hans: 'https://link.langbot.app/zh/docs/pipelines',
|
||||
ja_JP: 'https://link.langbot.app/ja/docs/pipelines',
|
||||
en_US: 'https://langbot.app/docs/en/usage/pipelines/readme',
|
||||
zh_Hans: 'https://langbot.app/docs/zh/usage/pipelines/readme',
|
||||
ja_JP: 'https://langbot.app/docs/ja/usage/pipelines/readme',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
@@ -75,9 +75,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/knowledge',
|
||||
description: t('knowledge.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://link.langbot.app/en/docs/knowledge',
|
||||
zh_Hans: 'https://link.langbot.app/zh/docs/knowledge',
|
||||
ja_JP: 'https://link.langbot.app/ja/docs/knowledge',
|
||||
en_US: 'https://langbot.app/docs/en/usage/knowledge/readme',
|
||||
zh_Hans: 'https://langbot.app/docs/zh/usage/knowledge/readme',
|
||||
ja_JP: 'https://langbot.app/docs/ja/usage/knowledge/readme',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
@@ -89,9 +89,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/extensions',
|
||||
description: t('plugins.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://docs.langbot.app/en/plugin/plugin-intro',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/plugin/plugin-intro',
|
||||
ja_JP: 'https://docs.langbot.app/ja/plugin/plugin-intro',
|
||||
en_US: 'https://langbot.app/docs/en/plugin/plugin-intro',
|
||||
zh_Hans: 'https://langbot.app/docs/zh/plugin/plugin-intro',
|
||||
ja_JP: 'https://langbot.app/docs/ja/plugin/plugin-intro',
|
||||
},
|
||||
section: 'extensions',
|
||||
}),
|
||||
@@ -102,9 +102,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/add-extension',
|
||||
description: t('plugins.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://docs.langbot.app/en/plugin/plugin-intro',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/plugin/plugin-intro',
|
||||
ja_JP: 'https://docs.langbot.app/ja/plugin/plugin-intro',
|
||||
en_US: 'https://langbot.app/docs/en/plugin/plugin-intro',
|
||||
zh_Hans: 'https://langbot.app/docs/zh/plugin/plugin-intro',
|
||||
ja_JP: 'https://langbot.app/docs/ja/plugin/plugin-intro',
|
||||
},
|
||||
section: 'extensions',
|
||||
}),
|
||||
|
||||
@@ -491,6 +491,7 @@ export default function ModelsPanel({
|
||||
// Get the provider info
|
||||
const provider = providers.find((p) => p.uuid === providerUuid);
|
||||
const providerData = {
|
||||
uuid: providerUuid,
|
||||
requester: provider?.requester || '',
|
||||
base_url: provider?.base_url || '',
|
||||
api_keys: provider?.api_keys || [],
|
||||
@@ -500,7 +501,7 @@ export default function ModelsPanel({
|
||||
await httpClient.testLLMModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
provider_uuid: '',
|
||||
provider_uuid: providerUuid,
|
||||
provider: providerData,
|
||||
abilities,
|
||||
reasoning_config: reasoningConfig,
|
||||
@@ -510,7 +511,7 @@ export default function ModelsPanel({
|
||||
await httpClient.testEmbeddingModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
provider_uuid: '',
|
||||
provider_uuid: providerUuid,
|
||||
provider: providerData,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
@@ -518,7 +519,7 @@ export default function ModelsPanel({
|
||||
await httpClient.testRerankModel('_', {
|
||||
uuid: '',
|
||||
name,
|
||||
provider_uuid: '',
|
||||
provider_uuid: providerUuid,
|
||||
provider: providerData,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
@@ -542,6 +543,29 @@ export default function ModelsPanel({
|
||||
expandedProviders.forEach((uuid) => loadProviderModels(uuid));
|
||||
}
|
||||
|
||||
async function handleProviderDeleted(providerUuid: string) {
|
||||
setProviders((prev) =>
|
||||
prev.filter((provider) => provider.uuid !== providerUuid),
|
||||
);
|
||||
setProviderModels((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[providerUuid];
|
||||
return next;
|
||||
});
|
||||
setExpandedProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(providerUuid);
|
||||
return next;
|
||||
});
|
||||
await Promise.all([
|
||||
loadProviders(),
|
||||
...Array.from(expandedProviders)
|
||||
.filter((uuid) => uuid !== providerUuid)
|
||||
.map((uuid) => loadProviderModels(uuid)),
|
||||
]);
|
||||
setProviderFormOpen(false);
|
||||
}
|
||||
|
||||
function renderProviderCard(
|
||||
provider: ModelProvider,
|
||||
isLangBotModels: boolean = false,
|
||||
@@ -674,8 +698,14 @@ export default function ModelsPanel({
|
||||
)}
|
||||
</PanelBody>
|
||||
|
||||
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
|
||||
<DialogContent className="w-full max-w-[calc(100%-2rem)] p-4 sm:max-w-[600px] sm:p-6">
|
||||
<Dialog
|
||||
open={providerFormOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleFormClose();
|
||||
else setProviderFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto p-4 sm:max-w-[600px] sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingProviderId
|
||||
@@ -683,11 +713,15 @@ export default function ModelsPanel({
|
||||
: t('models.addProvider')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ProviderForm
|
||||
providerId={editingProviderId || undefined}
|
||||
onFormSubmit={handleFormClose}
|
||||
onFormCancel={() => setProviderFormOpen(false)}
|
||||
/>
|
||||
{providerFormOpen && (
|
||||
<ProviderForm
|
||||
key={editingProviderId || 'new'}
|
||||
providerId={editingProviderId || undefined}
|
||||
onFormSubmit={handleFormClose}
|
||||
onFormCancel={handleFormClose}
|
||||
onProviderDeleted={canManage ? handleProviderDeleted : undefined}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { useCodexLogin } from './useCodexLogin';
|
||||
|
||||
export default function CodexAccountSection({
|
||||
login,
|
||||
providerId,
|
||||
}: {
|
||||
login: ReturnType<typeof useCodexLogin>;
|
||||
providerId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [confirmDisconnect, setConfirmDisconnect] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyFailed, setCopyFailed] = useState(false);
|
||||
const { phase, device } = login;
|
||||
const copyGeneration = useRef(0);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
const generation = copyGeneration;
|
||||
setCopied(false);
|
||||
setCopyFailed(false);
|
||||
return () => {
|
||||
generation.current++;
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
};
|
||||
}, [providerId, device?.authorization_id, device?.user_code, phase]);
|
||||
const handleCopy = async () => {
|
||||
if (!device) return;
|
||||
const generation = ++copyGeneration.current;
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await copyToClipboard(device.user_code);
|
||||
} catch {
|
||||
// Clipboard failures are recoverable; never log device codes.
|
||||
}
|
||||
if (generation !== copyGeneration.current) return;
|
||||
setCopied(ok);
|
||||
setCopyFailed(!ok);
|
||||
if (ok) {
|
||||
toast.success(t('common.copySuccess'));
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
toast.error(t('common.copyFailed'));
|
||||
}
|
||||
};
|
||||
const waiting = ['starting', 'loading', 'canceling'].includes(phase);
|
||||
return (
|
||||
<section
|
||||
data-testid="codex-account"
|
||||
aria-label={t('models.codex.account')}
|
||||
className="min-w-0 rounded-lg border p-3 space-y-3 text-sm"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium">{t('models.codex.account')}</h3>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{t('models.codex.description')}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
role={phase === 'error' ? 'alert' : 'status'}
|
||||
aria-live="polite"
|
||||
className={
|
||||
phase === 'error' ? 'text-destructive' : 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{t(`models.codex.${phase}`)}
|
||||
</p>
|
||||
{device && phase === 'pending' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground">
|
||||
{t('models.codex.instructions')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="select-all break-all rounded border bg-muted px-3 py-2 text-base font-semibold tracking-wider">
|
||||
{device.user_code}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{t(copied ? 'models.codex.copied' : 'models.codex.copyCode')}
|
||||
</Button>
|
||||
</div>
|
||||
{copyFailed && (
|
||||
<p role="status" className="text-muted-foreground">
|
||||
{t('models.codex.copyManually')}
|
||||
</p>
|
||||
)}
|
||||
<a
|
||||
href={device.verification_uri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t('models.codex.continueAtOpenAI')}
|
||||
</a>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('models.codex.expiresAt', {
|
||||
time: new Date(device.expires_at * 1000).toLocaleTimeString(),
|
||||
})}
|
||||
</p>
|
||||
{login.retrying && (
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
{t('models.codex.retrying')}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => providerId && void login.cancel(providerId)}
|
||||
>
|
||||
{t('models.codex.cancelSignIn')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{providerId && !waiting && phase !== 'pending' && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{phase !== 'connected' && (
|
||||
<Button type="submit" size="sm" variant="outline">
|
||||
{t(
|
||||
phase === 'error' || phase === 'expired'
|
||||
? 'models.codex.tryAgain'
|
||||
: 'models.codex.signIn',
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{phase === 'connected' && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setConfirmDisconnect(false);
|
||||
void login.start(providerId);
|
||||
}}
|
||||
>
|
||||
{t('models.codex.reconnect')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmDisconnect(true)}
|
||||
>
|
||||
{t('models.codex.disconnect')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{confirmDisconnect && phase === 'connected' && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<p>{t('models.codex.disconnectConfirm')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmDisconnect(false);
|
||||
if (providerId) void login.disconnect(providerId);
|
||||
}}
|
||||
>
|
||||
{t('models.codex.confirmDisconnect')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDisconnect(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+380
-145
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -16,12 +16,31 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { DialogFooter } from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '../../types';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { toast } from 'sonner';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check, ChevronDown, Search } from 'lucide-react';
|
||||
import { providerPayload } from './codexPolicy';
|
||||
import { useCodexLogin } from './useCodexLogin';
|
||||
import CodexAccountSection from './CodexAccountSection';
|
||||
|
||||
const getFormSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
@@ -35,12 +54,14 @@ interface ProviderFormProps {
|
||||
providerId?: string;
|
||||
onFormSubmit: (providerUuid: string) => void | Promise<void>;
|
||||
onFormCancel: () => void;
|
||||
onProviderDeleted?: (providerUuid: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function ProviderForm({
|
||||
providerId,
|
||||
onFormSubmit,
|
||||
onFormCancel,
|
||||
onProviderDeleted,
|
||||
}: ProviderFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const formSchema = getFormSchema(t);
|
||||
@@ -54,7 +75,31 @@ export default function ProviderForm({
|
||||
api_key: '',
|
||||
},
|
||||
});
|
||||
const { setValue } = form;
|
||||
const { reset } = form;
|
||||
const isCodex = form.watch('requester') === 'openai-codex';
|
||||
const [savedProviderId, setSavedProviderId] = useState(providerId);
|
||||
const savedId = useRef(providerId);
|
||||
const submitting = useRef(false);
|
||||
const deleting = useRef(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
const [mutableProviderLoaded, setMutableProviderLoaded] = useState(false);
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>(
|
||||
'loading',
|
||||
);
|
||||
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||
const mounted = useRef(true);
|
||||
const login = useCodexLogin(isCodex, providerId);
|
||||
const loginActive = ['starting', 'pending', 'canceling', 'loading'].includes(
|
||||
login.phase,
|
||||
);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [requesterList, setRequesterList] = useState<
|
||||
{
|
||||
@@ -68,72 +113,59 @@ export default function ProviderForm({
|
||||
>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadRequesters = useCallback(async () => {
|
||||
const resp = await httpClient.getProviderRequesters();
|
||||
setRequesterList(
|
||||
resp.requesters
|
||||
.filter((item) => item.name !== 'space-chat-completions')
|
||||
.map((item) => ({
|
||||
label: extractI18nObject(item.label),
|
||||
value: item.name,
|
||||
category: item.spec.provider_category || 'manufacturer',
|
||||
defaultUrl:
|
||||
item.spec.config
|
||||
.find((c) => c.name === 'base_url')
|
||||
?.default?.toString() || '',
|
||||
description: extractI18nObject(item.description),
|
||||
alias: item.spec.alias || '',
|
||||
})),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const loadProvider = useCallback(
|
||||
async (id: string) => {
|
||||
const resp = await httpClient.getModelProvider(id);
|
||||
const provider = resp.provider;
|
||||
|
||||
setValue('name', provider.name);
|
||||
setValue('requester', provider.requester);
|
||||
setValue('base_url', provider.base_url);
|
||||
setValue('api_key', provider.api_keys?.[0] || '');
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Ignore both success and failure from a closed form or superseded attempt.
|
||||
let canceled = false;
|
||||
setLoadState('loading');
|
||||
setMutableProviderLoaded(false);
|
||||
|
||||
async function init() {
|
||||
await loadRequesters();
|
||||
if (providerId) {
|
||||
await loadProvider(providerId);
|
||||
try {
|
||||
const [requesters, detail] = await Promise.all([
|
||||
httpClient.getProviderRequesters(),
|
||||
providerId ? httpClient.getModelProvider(providerId) : null,
|
||||
]);
|
||||
if (canceled) return;
|
||||
setRequesterList(
|
||||
requesters.requesters
|
||||
.filter((item) => item.name !== LANGBOT_MODELS_PROVIDER_REQUESTER)
|
||||
.map((item) => ({
|
||||
label: extractI18nObject(item.label),
|
||||
value: item.name,
|
||||
category: item.spec.provider_category || 'manufacturer',
|
||||
defaultUrl:
|
||||
item.spec.config
|
||||
.find((c) => c.name === 'base_url')
|
||||
?.default?.toString() || '',
|
||||
description: extractI18nObject(item.description),
|
||||
alias: item.spec.alias || '',
|
||||
})),
|
||||
);
|
||||
if (detail) {
|
||||
const provider = detail.provider;
|
||||
reset({
|
||||
name: provider.name,
|
||||
requester: provider.requester,
|
||||
base_url: provider.base_url,
|
||||
api_key: provider.api_keys?.[0] || '',
|
||||
});
|
||||
setMutableProviderLoaded(
|
||||
provider.uuid === providerId &&
|
||||
provider.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
);
|
||||
}
|
||||
setLoadState('ready');
|
||||
} catch {
|
||||
if (!canceled) setLoadState('error');
|
||||
}
|
||||
}
|
||||
init();
|
||||
}, [providerId, loadProvider, loadRequesters]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
setSearchQuery('');
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Focus search input when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
void init();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [providerId, reset, loadAttempt]);
|
||||
|
||||
// Filter requesters based on search query
|
||||
const filteredRequesters = requesterList.filter(
|
||||
@@ -163,29 +195,105 @@ export default function ProviderForm({
|
||||
};
|
||||
|
||||
async function handleFormSubmit(values: z.infer<typeof formSchema>) {
|
||||
const data = {
|
||||
name: values.name,
|
||||
requester: values.requester,
|
||||
base_url: values.base_url,
|
||||
api_keys: values.api_key ? [values.api_key] : [],
|
||||
};
|
||||
|
||||
if (
|
||||
loadState !== 'ready' ||
|
||||
submitting.current ||
|
||||
deleting.current ||
|
||||
(isCodex && loginActive)
|
||||
)
|
||||
return;
|
||||
submitting.current = true;
|
||||
const data = providerPayload(values);
|
||||
try {
|
||||
let savedProviderUuid = providerId;
|
||||
if (providerId) {
|
||||
await httpClient.updateModelProvider(providerId, data);
|
||||
toast.success(t('models.providerSaved'));
|
||||
if (savedId.current) {
|
||||
await httpClient.updateModelProvider(savedId.current, data);
|
||||
} else {
|
||||
const response = await httpClient.createModelProvider(data);
|
||||
savedProviderUuid = response.uuid;
|
||||
toast.success(t('models.providerCreated'));
|
||||
savedId.current = response.uuid;
|
||||
if (mounted.current) setSavedProviderId(response.uuid);
|
||||
}
|
||||
if (!mounted.current) return;
|
||||
if (isCodex && login.phase !== 'connected') {
|
||||
await login.start(savedId.current);
|
||||
} else {
|
||||
toast.success(t('models.providerSaved'));
|
||||
await onFormSubmit(savedId.current);
|
||||
}
|
||||
await onFormSubmit(savedProviderUuid as string);
|
||||
} catch (err) {
|
||||
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
|
||||
if (mounted.current)
|
||||
toast.error(
|
||||
t('models.providerSaveError') + (err as CustomApiError).msg,
|
||||
);
|
||||
} finally {
|
||||
submitting.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (
|
||||
loadState !== 'ready' ||
|
||||
!providerId ||
|
||||
!mutableProviderLoaded ||
|
||||
!onProviderDeleted ||
|
||||
deleting.current ||
|
||||
submitting.current ||
|
||||
(isCodex && loginActive)
|
||||
)
|
||||
return;
|
||||
deleting.current = true;
|
||||
setIsDeleting(true);
|
||||
setDeleteError('');
|
||||
try {
|
||||
await httpClient.deleteModelProvider(providerId, true);
|
||||
} catch (err) {
|
||||
const detail =
|
||||
(err as CustomApiError | null)?.msg ||
|
||||
(err instanceof Error ? err.message : '');
|
||||
setDeleteError(t('models.providerDeleteError') + detail);
|
||||
deleting.current = false;
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
toast.success(t('models.providerDeleted'));
|
||||
await onProviderDeleted(providerId);
|
||||
}
|
||||
|
||||
if (loadState !== 'ready') {
|
||||
return (
|
||||
<>
|
||||
{loadState === 'loading' ? (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t('common.loading')}
|
||||
className="flex justify-center py-8"
|
||||
>
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
) : (
|
||||
<p role="alert" className="py-8 text-sm text-destructive">
|
||||
{t('models.loadError')}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{loadState === 'error' && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoadState('loading');
|
||||
setLoadAttempt((attempt) => attempt + 1);
|
||||
}}
|
||||
>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onFormCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -202,7 +310,12 @@ export default function ProviderForm({
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input
|
||||
{...field}
|
||||
disabled={
|
||||
form.formState.isSubmitting || (isCodex && loginActive)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -222,45 +335,65 @@ export default function ProviderForm({
|
||||
{t('models.requester')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) setSearchQuery('');
|
||||
}}
|
||||
>
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'ring-2 ring-ring ring-offset-2',
|
||||
)}
|
||||
>
|
||||
{selectedRequester ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getProviderRequesterIconURL(
|
||||
selectedRequester.value,
|
||||
)}
|
||||
alt={selectedRequester.label}
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{selectedRequester.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t('models.selectRequester')}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
form.formState.isSubmitting ||
|
||||
(isCodex && (!!savedProviderId || loginActive))
|
||||
}
|
||||
aria-expanded={isOpen}
|
||||
className={cn(
|
||||
'h-4 w-4 opacity-50 transition-transform',
|
||||
isOpen && 'rotate-180',
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'ring-2 ring-ring ring-offset-2',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
>
|
||||
{selectedRequester ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getProviderRequesterIconURL(
|
||||
selectedRequester.value,
|
||||
)}
|
||||
alt={selectedRequester.label}
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{selectedRequester.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t('models.selectRequester')}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 opacity-50 transition-transform',
|
||||
isOpen && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
{/* Dropdown */}
|
||||
{/* Unmount on close so an exiting layer cannot eat Dialog Escape. */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95">
|
||||
<PopoverContent
|
||||
align="start"
|
||||
collisionPadding={8}
|
||||
className="flex max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-16px)] flex-col overflow-hidden p-0"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center border-b px-3">
|
||||
<div className="flex shrink-0 items-center border-b px-3">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
@@ -275,7 +408,13 @@ export default function ProviderForm({
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<div className="max-h-[300px] overflow-y-auto p-1">
|
||||
<div
|
||||
className="min-h-0 max-h-[300px] overflow-y-auto overscroll-contain p-1"
|
||||
// The dialog's document-level scroll lock treats this portal as outside.
|
||||
// Keep native list scrolling without forwarding gestures to that lock.
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
onTouchMove={(event) => event.stopPropagation()}
|
||||
>
|
||||
{Object.entries(groupedRequesters).map(
|
||||
([category, items]) => {
|
||||
if (items.length === 0) return null;
|
||||
@@ -288,6 +427,11 @@ export default function ProviderForm({
|
||||
<button
|
||||
key={r.value}
|
||||
type="button"
|
||||
disabled={
|
||||
!!providerId &&
|
||||
r.value === 'openai-codex' &&
|
||||
!isCodex
|
||||
}
|
||||
onClick={() => {
|
||||
field.onChange(r.value);
|
||||
const req = requesterList.find(
|
||||
@@ -337,9 +481,9 @@ export default function ProviderForm({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
{selectedRequester?.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -351,40 +495,131 @@ export default function ProviderForm({
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.requestURL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isCodex ? (
|
||||
<CodexAccountSection login={login} providerId={savedProviderId} />
|
||||
) : (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.requestURL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={
|
||||
form.formState.isSubmitting || (isCodex && loginActive)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.apiKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.apiKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">{t('common.save')}</Button>
|
||||
<Button type="button" variant="outline" onClick={onFormCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<DialogFooter className="flex-row flex-wrap items-start justify-between sm:justify-between">
|
||||
{providerId && mutableProviderLoaded && onProviderDeleted && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={
|
||||
isDeleting ||
|
||||
form.formState.isSubmitting ||
|
||||
(isCodex && loginActive)
|
||||
}
|
||||
onClick={() => {
|
||||
setDeleteError('');
|
||||
setDeleteConfirmOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="ml-auto flex flex-col gap-2 sm:flex-row">
|
||||
{(!isCodex || !savedProviderId || login.phase === 'connected') && (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isDeleting ||
|
||||
form.formState.isSubmitting ||
|
||||
(isCodex && loginActive)
|
||||
}
|
||||
>
|
||||
{isCodex
|
||||
? t(
|
||||
login.phase === 'connected'
|
||||
? 'models.codex.done'
|
||||
: 'models.codex.saveAndSignIn',
|
||||
)
|
||||
: t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isDeleting}
|
||||
onClick={onFormCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
<AlertDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!deleting.current) setDeleteConfirmOpen(open);
|
||||
}}
|
||||
>
|
||||
{deleteConfirmOpen && (
|
||||
<AlertDialogContent className="max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-lg">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('common.delete')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('models.deleteProviderCascadeConfirmation')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{deleteError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-sm text-destructive break-words"
|
||||
>
|
||||
{deleteError}
|
||||
</p>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>
|
||||
{t('common.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isDeleting}
|
||||
aria-busy={isDeleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
)}
|
||||
</AlertDialog>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Subscription credentials are server-owned, never API-key form values. */
|
||||
export function providerPayload(values: {
|
||||
name: string;
|
||||
requester: string;
|
||||
base_url: string;
|
||||
api_key?: string;
|
||||
}) {
|
||||
const subscription = values.requester === 'openai-codex';
|
||||
return {
|
||||
name: values.name,
|
||||
requester: values.requester,
|
||||
base_url: subscription
|
||||
? 'https://chatgpt.com/backend-api/codex'
|
||||
: values.base_url,
|
||||
api_keys: subscription ? [] : values.api_key ? [values.api_key] : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function pollDelay(interval: number, failures = 0): number {
|
||||
const seconds = Number.isFinite(interval) && interval > 0 ? interval : 5;
|
||||
return Math.max(seconds, Math.min(60, seconds * 2 ** failures)) * 1000;
|
||||
}
|
||||
|
||||
export function isCodexVerificationUri(uri: string): boolean {
|
||||
return uri === 'https://auth.openai.com/codex/device';
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import type { CodexDeviceAuthorization } from '@/app/infra/entities/codex';
|
||||
import { isCodexVerificationUri, pollDelay } from './codexPolicy';
|
||||
|
||||
type Phase =
|
||||
| 'disconnected'
|
||||
| 'loading'
|
||||
| 'starting'
|
||||
| 'pending'
|
||||
| 'connected'
|
||||
| 'expired'
|
||||
| 'error'
|
||||
| 'canceling';
|
||||
|
||||
/** One in-memory authorization, sequential polls, and stale-response fencing. */
|
||||
export function useCodexLogin(enabled: boolean, providerId?: string) {
|
||||
const [phase, setPhase] = useState<Phase>('disconnected');
|
||||
const [device, setDevice] = useState<CodexDeviceAuthorization | null>(null);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const generation = useRef(0);
|
||||
const busy = useRef(false);
|
||||
const attempt = useRef<{ uuid: string; authorizationId: string } | null>(
|
||||
null,
|
||||
);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const deadline = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const request = useRef<AbortController | null>(null);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
generation.current++;
|
||||
clearTimeout(timer.current);
|
||||
clearTimeout(deadline.current);
|
||||
request.current?.abort();
|
||||
busy.current = false;
|
||||
const pending = attempt.current;
|
||||
attempt.current = null;
|
||||
return pending;
|
||||
}, []);
|
||||
|
||||
const clearPending = useCallback(async () => {
|
||||
const pending = stop();
|
||||
if (pending)
|
||||
await httpClient.cancelCodexDeviceLogin(
|
||||
pending.uuid,
|
||||
pending.authorizationId,
|
||||
);
|
||||
}, [stop]);
|
||||
|
||||
const loadStatus = useCallback(async (uuid: string) => {
|
||||
const version = generation.current;
|
||||
request.current = new AbortController();
|
||||
setPhase('loading');
|
||||
try {
|
||||
const status = await httpClient.getCodexAuthStatus(
|
||||
uuid,
|
||||
request.current.signal,
|
||||
);
|
||||
if (version === generation.current) setPhase(status.status);
|
||||
} catch {
|
||||
if (version === generation.current) setPhase('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setDevice(null);
|
||||
setPhase('disconnected');
|
||||
if (enabled && providerId) void loadStatus(providerId);
|
||||
return () => {
|
||||
// Device creation is deliberately not aborted: its late response must be
|
||||
// canceled server-side even if this form has already unmounted.
|
||||
void clearPending().catch(() => {});
|
||||
};
|
||||
}, [enabled, providerId, loadStatus, clearPending]);
|
||||
|
||||
async function start(uuid: string) {
|
||||
if (busy.current) return;
|
||||
const old = stop();
|
||||
busy.current = true;
|
||||
const version = generation.current;
|
||||
setPhase('starting');
|
||||
setDevice(null);
|
||||
setRetrying(false);
|
||||
try {
|
||||
if (old)
|
||||
await httpClient.cancelCodexDeviceLogin(old.uuid, old.authorizationId);
|
||||
if (version !== generation.current) return;
|
||||
const authorization = await httpClient.startCodexDeviceLogin(uuid);
|
||||
if (version !== generation.current) {
|
||||
await httpClient.cancelCodexDeviceLogin(
|
||||
uuid,
|
||||
authorization.authorization_id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
attempt.current = {
|
||||
uuid,
|
||||
authorizationId: authorization.authorization_id,
|
||||
};
|
||||
if (
|
||||
!isCodexVerificationUri(authorization.verification_uri) ||
|
||||
!Number.isFinite(authorization.expires_at)
|
||||
) {
|
||||
await clearPending();
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
setDevice(authorization);
|
||||
setPhase('pending');
|
||||
let interval = authorization.interval;
|
||||
let failures = 0;
|
||||
request.current = new AbortController();
|
||||
const signal = request.current.signal;
|
||||
const expire = () => {
|
||||
if (version !== generation.current) return;
|
||||
void clearPending().catch(() => {});
|
||||
setDevice(null);
|
||||
setPhase('expired');
|
||||
};
|
||||
deadline.current = setTimeout(
|
||||
expire,
|
||||
Math.max(0, authorization.expires_at * 1000 - Date.now()),
|
||||
);
|
||||
const poll = async () => {
|
||||
if (version !== generation.current) return;
|
||||
if (Date.now() >= authorization.expires_at * 1000) {
|
||||
expire();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await httpClient.pollCodexDeviceLogin(
|
||||
uuid,
|
||||
authorization.authorization_id,
|
||||
signal,
|
||||
);
|
||||
if (version !== generation.current) return;
|
||||
if (result.status !== 'pending') {
|
||||
attempt.current = null;
|
||||
stop();
|
||||
setDevice(null);
|
||||
setPhase(result.status);
|
||||
return;
|
||||
}
|
||||
interval = result.interval ?? interval;
|
||||
failures = 0;
|
||||
setRetrying(false);
|
||||
} catch (error) {
|
||||
if (version !== generation.current) return;
|
||||
const code = (error as { code?: number }).code;
|
||||
if (
|
||||
(code === -1 || (code !== undefined && code >= 500)) &&
|
||||
failures < 3
|
||||
) {
|
||||
failures++;
|
||||
setRetrying(true);
|
||||
} else {
|
||||
void clearPending().catch(() => {});
|
||||
setDevice(null);
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
timer.current = setTimeout(poll, pollDelay(interval, failures));
|
||||
};
|
||||
timer.current = setTimeout(poll, pollDelay(interval));
|
||||
} catch {
|
||||
if (version === generation.current) {
|
||||
busy.current = false;
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(uuid: string) {
|
||||
setPhase('canceling');
|
||||
setDevice(null);
|
||||
const pending = clearPending();
|
||||
const version = generation.current;
|
||||
try {
|
||||
await pending;
|
||||
if (version === generation.current) await loadStatus(uuid);
|
||||
} catch {
|
||||
if (version === generation.current) setPhase('error');
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect(uuid: string) {
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
setPhase('loading');
|
||||
const version = generation.current;
|
||||
try {
|
||||
await httpClient.disconnectCodex(uuid);
|
||||
if (version === generation.current) await loadStatus(uuid);
|
||||
} catch {
|
||||
if (version === generation.current) setPhase('error');
|
||||
} finally {
|
||||
busy.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { phase, device, retrying, start, cancel, disconnect, loadStatus };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { hasModelReasoningAbility } from '../../reasoning/model-reasoning';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Trash2, Eye, Wrench, Check, BrainCircuit } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -175,8 +176,7 @@ export default function ModelItem({
|
||||
|
||||
const supportsReasoning =
|
||||
modelType === 'llm' &&
|
||||
((model as LLMModel).reasoning_capabilities?.supported === true ||
|
||||
(model as LLMModel).abilities?.includes('reasoning'));
|
||||
hasModelReasoningAbility(model as LLMModel, isLangBotModels);
|
||||
const canSaveModel = !isLangBotModels;
|
||||
|
||||
// Check if popover should be disabled (space models when not logged in)
|
||||
@@ -353,7 +353,11 @@ export default function ModelItem({
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`edit-reasoning-${model.uuid}`}
|
||||
checked={editAbilities.includes('reasoning')}
|
||||
checked={
|
||||
isLangBotModels
|
||||
? supportsReasoning
|
||||
: editAbilities.includes('reasoning')
|
||||
}
|
||||
disabled={isLangBotModels}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAbility('reasoning', checked as boolean)
|
||||
|
||||
@@ -34,11 +34,11 @@ export default function NewVersionDialog({
|
||||
const getUpdateDocsUrl = () => {
|
||||
const language = i18n.language;
|
||||
if (language === 'zh-Hans' || language === 'zh-Hant') {
|
||||
return 'https://link.langbot.app/zh/docs/update';
|
||||
return 'https://langbot.app/docs/zh/deploy/update';
|
||||
} else if (language === 'ja-JP') {
|
||||
return 'https://link.langbot.app/ja/docs/update';
|
||||
return 'https://langbot.app/docs/ja/deploy/update';
|
||||
} else {
|
||||
return 'https://link.langbot.app/en/docs/update';
|
||||
return 'https://langbot.app/docs/en/deploy/update';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function ReasoningLevelPicker({
|
||||
size="sm"
|
||||
disabled={disabled || safeLevels.length <= 1}
|
||||
aria-label={`${t('models.reasoningLevel')}: ${currentLabel}`}
|
||||
className="h-9 w-9 shrink-0 gap-1.5 px-2.5 text-xs font-normal sm:w-auto sm:max-w-36"
|
||||
className="h-9 w-9 shrink-0 gap-1.5 px-2.5 text-xs font-normal sm:w-auto sm:max-w-52"
|
||||
>
|
||||
<BrainCircuit
|
||||
className={`size-4 shrink-0 ${isExplicit ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
@@ -77,6 +77,18 @@ export default function ReasoningLevelPicker({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[272px] p-4">
|
||||
{safeLevels.includes('provider_default') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mb-3 w-full"
|
||||
aria-pressed={safeValue === 'provider_default'}
|
||||
onClick={() => onChange('provider_default')}
|
||||
>
|
||||
{t(REASONING_LEVEL_LABEL_KEYS.provider_default)}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex h-5 items-center gap-0.5 text-sm text-muted-foreground">
|
||||
<span>{currentLabel}</span>
|
||||
<ChevronRight className="size-3.5" />
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '../models-dialog/types';
|
||||
|
||||
interface ModelReasoningInfo {
|
||||
abilities?: string[];
|
||||
reasoning_capabilities?: { supported?: boolean };
|
||||
provider?: { requester?: string };
|
||||
}
|
||||
|
||||
export function hasModelReasoningAbility(
|
||||
model: ModelReasoningInfo | undefined,
|
||||
isLangBotModels = model?.provider?.requester ===
|
||||
LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
model?.abilities?.includes('reasoning') ||
|
||||
(isLangBotModels && model?.reasoning_capabilities?.supported === true),
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,14 @@ import React, {
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Braces, Loader2, Trash2, Wrench, XCircle } from 'lucide-react';
|
||||
import {
|
||||
Braces,
|
||||
Loader2,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
Wrench,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -102,7 +109,7 @@ function StatusDisplay({
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<XCircle className="size-5" />
|
||||
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
||||
<span className="font-medium">{t('mcp.connectionFailedStatus')}</span>
|
||||
</div>
|
||||
<div className="pl-7 text-sm text-red-500 space-y-0.5">
|
||||
<div>
|
||||
@@ -118,15 +125,41 @@ function StatusDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
if (runtimeInfo.error_phase === 'oauth_required') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-400">
|
||||
<ShieldAlert className="size-5" />
|
||||
<span className="font-medium">
|
||||
{t('mcp.oauthAuthorizationRequired')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pl-7 text-sm text-muted-foreground">
|
||||
{t('mcp.oauthAuthorizationRequiredSuggestion')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const httpStatus = runtimeInfo.error_code?.match(/^http_(\d{3})$/)?.[1];
|
||||
const errorDetail =
|
||||
runtimeInfo.error_code === 'connection_unreachable'
|
||||
? t('mcp.connectionUnreachable')
|
||||
: runtimeInfo.error_code === 'connection_timeout'
|
||||
? t('mcp.connectionTimeout')
|
||||
: httpStatus
|
||||
? t('mcp.connectionHttpError', { status: httpStatus })
|
||||
: runtimeInfo.error_message || t('mcp.unknownError');
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<XCircle className="size-5" />
|
||||
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
||||
<span className="font-medium">{t('mcp.connectionFailedStatus')}</span>
|
||||
</div>
|
||||
{runtimeInfo.error_message && (
|
||||
<div className="pl-7 text-sm text-red-500">
|
||||
{runtimeInfo.error_message}
|
||||
{errorDetail && (
|
||||
<div className="pl-7 whitespace-pre-wrap break-words text-sm text-muted-foreground">
|
||||
{errorDetail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -844,15 +877,31 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
async function testMcp() {
|
||||
setMcpTesting(true);
|
||||
|
||||
const showConnectionFailure = (
|
||||
message: string,
|
||||
info?: MCPServerRuntimeInfo,
|
||||
) => {
|
||||
toast.error(t('mcp.connectionFailedStatus'));
|
||||
setRuntimeInfo({
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
resource_count: 0,
|
||||
resources: [],
|
||||
...info,
|
||||
status: MCPSessionStatus.ERROR,
|
||||
error_message: info?.error_message || message,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const mode = form.getValues('mode');
|
||||
if (mode === 'stdio' && !mcpStdioEnabled) {
|
||||
toast.error(t('mcp.stdioDisabledByPolicy'));
|
||||
showConnectionFailure(t('mcp.stdioDisabledByPolicy'));
|
||||
setMcpTesting(false);
|
||||
return;
|
||||
}
|
||||
if (mode === 'stdio' && !boxAvailable) {
|
||||
toast.error(t('mcp.stdioBlockedByBoxToast'));
|
||||
showConnectionFailure(t('mcp.stdioBlockedByBoxToast'));
|
||||
setMcpTesting(false);
|
||||
return;
|
||||
}
|
||||
@@ -923,15 +972,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
if (taskResp.runtime.exception) {
|
||||
const errorMsg =
|
||||
taskResp.runtime.exception || t('mcp.unknownError');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
setRuntimeInfo({
|
||||
status: MCPSessionStatus.ERROR,
|
||||
error_message: errorMsg,
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
resource_count: 0,
|
||||
resources: [],
|
||||
});
|
||||
const runtimeInfoFromTest = taskResp.task_context?.metadata
|
||||
?.runtime_info as MCPServerRuntimeInfo | undefined;
|
||||
showConnectionFailure(errorMsg, runtimeInfoFromTest);
|
||||
if (shouldTestPersistedServer) {
|
||||
await onPersistedTestComplete?.(serverName);
|
||||
}
|
||||
@@ -958,14 +1001,19 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
clearInterval(interval);
|
||||
setMcpTesting(false);
|
||||
const errorMsg =
|
||||
(err as CustomApiError).msg || t('mcp.getTaskFailed');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
(err as CustomApiError).msg ||
|
||||
(err as Error).message ||
|
||||
t('mcp.getTaskFailed');
|
||||
showConnectionFailure(errorMsg);
|
||||
}
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setMcpTesting(false);
|
||||
const errorMsg = (err as Error).message || t('mcp.unknownError');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
const errorMsg =
|
||||
(err as CustomApiError).msg ||
|
||||
(err as Error).message ||
|
||||
t('mcp.unknownError');
|
||||
showConnectionFailure(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,24 +4,18 @@ import { MessageSquare, Sparkles, Check, Users } from 'lucide-react';
|
||||
import MetricCard from './MetricCard';
|
||||
import SystemStatusCard from './SystemStatusCards';
|
||||
import TrafficChart from './TrafficChart';
|
||||
import {
|
||||
OverviewMetrics,
|
||||
MonitoringMessage,
|
||||
LLMCall,
|
||||
} from '../../types/monitoring';
|
||||
import { OverviewMetrics, MonitoringData } from '../../types/monitoring';
|
||||
|
||||
interface OverviewCardsProps {
|
||||
metrics: OverviewMetrics | null;
|
||||
messages?: MonitoringMessage[];
|
||||
llmCalls?: LLMCall[];
|
||||
traffic?: MonitoringData['traffic'];
|
||||
loading?: boolean;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
export default function OverviewCards({
|
||||
metrics,
|
||||
messages = [],
|
||||
llmCalls = [],
|
||||
traffic,
|
||||
loading,
|
||||
refreshKey,
|
||||
}: OverviewCardsProps) {
|
||||
@@ -100,7 +94,7 @@ export default function OverviewCards({
|
||||
</div>
|
||||
|
||||
{/* Traffic Chart */}
|
||||
<TrafficChart messages={messages} llmCalls={llmCalls} loading={loading} />
|
||||
<TrafficChart traffic={traffic} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,119 +11,33 @@ import {
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { MonitoringMessage, LLMCall } from '../../types/monitoring';
|
||||
import { MonitoringData } from '../../types/monitoring';
|
||||
|
||||
interface TrafficChartProps {
|
||||
messages: MonitoringMessage[];
|
||||
llmCalls: LLMCall[];
|
||||
traffic?: MonitoringData['traffic'];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
time: string;
|
||||
timestamp: number;
|
||||
messages: number;
|
||||
llmCalls: number;
|
||||
}
|
||||
|
||||
export default function TrafficChart({
|
||||
messages,
|
||||
llmCalls,
|
||||
loading,
|
||||
}: TrafficChartProps) {
|
||||
export default function TrafficChart({ traffic, loading }: TrafficChartProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const safeMessages = Array.isArray(messages) ? messages : [];
|
||||
const safeLlmCalls = Array.isArray(llmCalls) ? llmCalls : [];
|
||||
if (!safeMessages.length && !safeLlmCalls.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Combine all timestamps and find the range
|
||||
const allTimestamps = [
|
||||
...safeMessages.map((m) => m.timestamp.getTime()),
|
||||
...safeLlmCalls.map((c) => c.timestamp.getTime()),
|
||||
];
|
||||
|
||||
if (allTimestamps.length === 0) return [];
|
||||
|
||||
const minTime = Math.min(...allTimestamps);
|
||||
const maxTime = Math.max(...allTimestamps);
|
||||
const timeRange = maxTime - minTime;
|
||||
|
||||
// Determine bucket size based on time range
|
||||
let bucketSize: number;
|
||||
let formatTime: (date: Date) => string;
|
||||
|
||||
if (timeRange <= 60 * 60 * 1000) {
|
||||
// <= 1 hour: 5-minute buckets
|
||||
bucketSize = 5 * 60 * 1000;
|
||||
formatTime = (date) =>
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (timeRange <= 6 * 60 * 60 * 1000) {
|
||||
// <= 6 hours: 15-minute buckets
|
||||
bucketSize = 15 * 60 * 1000;
|
||||
formatTime = (date) =>
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (timeRange <= 24 * 60 * 60 * 1000) {
|
||||
// <= 24 hours: 1-hour buckets
|
||||
bucketSize = 60 * 60 * 1000;
|
||||
formatTime = (date) =>
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (timeRange <= 7 * 24 * 60 * 60 * 1000) {
|
||||
// <= 7 days: 4-hour buckets
|
||||
bucketSize = 4 * 60 * 60 * 1000;
|
||||
formatTime = (date) =>
|
||||
`${date.toLocaleDateString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})} ${date.toLocaleTimeString([], { hour: '2-digit' })}`;
|
||||
} else {
|
||||
// > 7 days: 1-day buckets
|
||||
bucketSize = 24 * 60 * 60 * 1000;
|
||||
formatTime = (date) =>
|
||||
date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// Create buckets
|
||||
const buckets: Map<number, ChartDataPoint> = new Map();
|
||||
const startBucket = Math.floor(minTime / bucketSize) * bucketSize;
|
||||
const endBucket = Math.ceil(maxTime / bucketSize) * bucketSize;
|
||||
|
||||
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketSize) {
|
||||
buckets.set(bucket, {
|
||||
time: formatTime(new Date(bucket)),
|
||||
timestamp: bucket,
|
||||
messages: 0,
|
||||
llmCalls: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Count messages per bucket
|
||||
safeMessages.forEach((msg) => {
|
||||
const bucket =
|
||||
Math.floor(msg.timestamp.getTime() / bucketSize) * bucketSize;
|
||||
const point = buckets.get(bucket);
|
||||
if (point) {
|
||||
point.messages++;
|
||||
}
|
||||
});
|
||||
|
||||
// Count LLM calls per bucket
|
||||
safeLlmCalls.forEach((call) => {
|
||||
const bucket =
|
||||
Math.floor(call.timestamp.getTime() / bucketSize) * bucketSize;
|
||||
const point = buckets.get(bucket);
|
||||
if (point) {
|
||||
point.llmCalls++;
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(buckets.values()).sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
}, [messages, llmCalls]);
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
(traffic?.points ?? []).map((point) => ({
|
||||
...point,
|
||||
time: point.timestamp.toLocaleString(
|
||||
[],
|
||||
traffic?.bucket === 'day'
|
||||
? { month: 'short', day: 'numeric' }
|
||||
: {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
},
|
||||
),
|
||||
})),
|
||||
[traffic],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -150,7 +64,13 @@ export default function TrafficChart({
|
||||
</h3>
|
||||
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<BarChart3 className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">{t('monitoring.trafficChart.noData')}</div>
|
||||
<div className="text-sm">
|
||||
{t(
|
||||
traffic
|
||||
? 'monitoring.trafficChart.noData'
|
||||
: 'monitoring.trafficChart.unavailable',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -161,6 +81,11 @@ export default function TrafficChart({
|
||||
<h3 className="text-base font-semibold text-foreground mb-6">
|
||||
{t('monitoring.trafficChart.title')}
|
||||
</h3>
|
||||
{traffic?.truncated && (
|
||||
<p role="status" className="text-sm text-muted-foreground mb-3">
|
||||
{t('monitoring.trafficChart.truncated')}
|
||||
</p>
|
||||
)}
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
FilterState,
|
||||
MonitoringData,
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
LLMCall,
|
||||
EmbeddingCall,
|
||||
} from '../types/monitoring';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import { backendClient, useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
|
||||
import { parseUTCTimestamp } from '../utils/dateUtils';
|
||||
|
||||
/**
|
||||
@@ -16,6 +17,10 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
const [data, setData] = useState<MonitoringData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const workspaceUuid = useCurrentWorkspace()?.workspace.uuid;
|
||||
const requestIdRef = useRef(0);
|
||||
const scope = JSON.stringify([workspaceUuid, filterState]);
|
||||
const [requestScope, setRequestScope] = useState<string | null>(null);
|
||||
|
||||
// Memoize filter parameters to prevent unnecessary re-renders
|
||||
const selectedBotsStr = useMemo(
|
||||
@@ -72,6 +77,12 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
|
||||
// Fetch data based on filters
|
||||
const fetchData = useCallback(async () => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
const isCurrent = () =>
|
||||
requestId === requestIdRef.current &&
|
||||
getCurrentWorkspaceSnapshot()?.workspace.uuid === workspaceUuid;
|
||||
setRequestScope(scope);
|
||||
setData(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -91,6 +102,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
endTime,
|
||||
limit: 50,
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
|
||||
const overview = response?.overview ?? {
|
||||
total_messages: 0,
|
||||
@@ -127,6 +139,17 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
|
||||
// Transform the response to match MonitoringData interface
|
||||
const transformedData: MonitoringData = {
|
||||
traffic: response.traffic
|
||||
? {
|
||||
bucket: response.traffic.bucket,
|
||||
truncated: response.traffic.truncated,
|
||||
points: response.traffic.points.map((point) => ({
|
||||
timestamp: parseUTCTimestamp(point.timestamp),
|
||||
messages: point.messages,
|
||||
llmCalls: point.llm_calls,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
overview: {
|
||||
totalMessages: overview.total_messages,
|
||||
llmCalls: overview.llm_calls,
|
||||
@@ -396,22 +419,33 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
|
||||
setData(transformedData);
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch monitoring data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (isCurrent()) setLoading(false);
|
||||
}
|
||||
}, [getTimeRange, filterState.selectedBots, filterState.selectedPipelines]);
|
||||
}, [
|
||||
getTimeRange,
|
||||
filterState.selectedBots,
|
||||
filterState.selectedPipelines,
|
||||
scope,
|
||||
workspaceUuid,
|
||||
]);
|
||||
|
||||
// Fetch data when filter state changes
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return () => {
|
||||
requestIdRef.current += 1;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
selectedBotsStr,
|
||||
selectedPipelinesStr,
|
||||
filterState.timeRange,
|
||||
customDateRangeStr,
|
||||
workspaceUuid,
|
||||
]);
|
||||
|
||||
// Manual refetch function
|
||||
@@ -420,9 +454,9 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
data: requestScope === scope ? data : null,
|
||||
loading: requestScope !== scope || loading,
|
||||
error: requestScope === scope ? error : null,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ function MonitoringPageContent() {
|
||||
currentWorkspace?.permissions.includes('data.export') ?? false;
|
||||
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
|
||||
useMonitoringFilters();
|
||||
const { data, loading, refetch } = useMonitoringData(filterState);
|
||||
const { data, loading, error, refetch } = useMonitoringData(filterState);
|
||||
|
||||
// Counter to force feedbackTimeRange recomputation on manual refresh
|
||||
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
|
||||
@@ -174,492 +174,556 @@ function MonitoringPageContent() {
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="relative z-0 flex flex-col gap-6 pb-4 pt-3">
|
||||
{/* Overview Section */}
|
||||
<OverviewCards
|
||||
metrics={data?.overview || null}
|
||||
messages={data?.messages || []}
|
||||
llmCalls={data?.llmCalls || []}
|
||||
loading={loading}
|
||||
/>
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border border-destructive p-6 space-y-3"
|
||||
>
|
||||
<p>{t('monitoring.loadError')}</p>
|
||||
<Button variant="outline" onClick={handleRefresh}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative z-0 flex flex-col gap-6 pb-4 pt-3">
|
||||
{/* Overview Section */}
|
||||
<OverviewCards
|
||||
metrics={data?.overview || null}
|
||||
traffic={data?.traffic}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{/* Tabs Section */}
|
||||
<div className="bg-card rounded-xl border overflow-hidden">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="px-3 pt-4 sm:px-6">
|
||||
<TabsList className="h-12 w-full justify-start gap-1 overflow-x-auto p-1 sm:w-auto">
|
||||
<TabsTrigger value="messages" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.messages')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="modelCalls" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.modelCalls')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tokens" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.tokens')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="feedback" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.feedback')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="errors" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.errors')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Tabs Section */}
|
||||
{!loading && data && (
|
||||
<div
|
||||
className="text-sm text-muted-foreground space-y-1"
|
||||
role="status"
|
||||
>
|
||||
{data.totalCount.messages > data.messages.length && (
|
||||
<p>
|
||||
{t('monitoring.partialMessages', {
|
||||
shown: data.messages.length,
|
||||
total: data.totalCount.messages,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{data.totalCount.llmCalls + data.totalCount.embeddingCalls >
|
||||
data.modelCalls.length && (
|
||||
<p>
|
||||
{t('monitoring.partialModelCalls', {
|
||||
shown: data.modelCalls.length,
|
||||
total:
|
||||
data.totalCount.llmCalls + data.totalCount.embeddingCalls,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{(data.totalCount.toolCalls ?? 0) > data.toolCalls.length && (
|
||||
<p>
|
||||
{t('monitoring.partialToolCalls', {
|
||||
shown: data.toolCalls.length,
|
||||
total: data.totalCount.toolCalls,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{data.totalCount.errors > data.errors.length && (
|
||||
<p>
|
||||
{t('monitoring.partialErrors', {
|
||||
shown: data.errors.length,
|
||||
total: data.totalCount.errors,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="messages" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner
|
||||
text={t('monitoring.messageList.loading')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data && conversationTurns.length > 0 && (
|
||||
<ConversationTurnList
|
||||
turns={conversationTurns}
|
||||
expandedTurnId={expandedTurnId}
|
||||
onToggleTurn={toggleTurnExpand}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && (!data || conversationTurns.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<MessageSquare className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">
|
||||
{t('monitoring.messageList.noMessages')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
<div className="bg-card rounded-xl border overflow-hidden">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="px-3 pt-4 sm:px-6">
|
||||
<TabsList className="h-12 w-full justify-start gap-1 overflow-x-auto p-1 sm:w-auto">
|
||||
<TabsTrigger value="messages" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.messages')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="modelCalls" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.modelCalls')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tokens" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.tokens')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="feedback" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.feedback')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="errors" className="px-3 py-2 sm:px-6">
|
||||
{t('monitoring.tabs.errors')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="modelCalls" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data &&
|
||||
data.modelCalls &&
|
||||
data.modelCalls.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{data.modelCalls.map((call) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="border rounded-xl p-3 transition-all duration-200 sm:p-5"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
{/* Query ID - only show if messageId exists */}
|
||||
{call.messageId && (
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Query ID: {call.messageId}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs"
|
||||
onClick={() =>
|
||||
jumpToMessage(call.messageId!)
|
||||
}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
{t(
|
||||
'monitoring.messageList.viewConversation',
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{/* Model Type Badge */}
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.modelType === 'llm'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||
}`}
|
||||
>
|
||||
{call.modelType === 'llm'
|
||||
? t('monitoring.modelCalls.llmModel')
|
||||
: t('monitoring.modelCalls.embeddingModel')}
|
||||
</span>
|
||||
{/* Call Type Badge for Embedding */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.callType && (
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.callType === 'retrieve'
|
||||
? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
|
||||
}`}
|
||||
>
|
||||
{call.callType === 'retrieve'
|
||||
? t(
|
||||
'monitoring.modelCalls.retrieveCall',
|
||||
)
|
||||
: t(
|
||||
'monitoring.modelCalls.embeddingCall',
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* Status Badge */}
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.status === 'success'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}
|
||||
>
|
||||
{call.status}
|
||||
</span>
|
||||
</div>
|
||||
{/* Model Name */}
|
||||
<div className="font-medium text-sm text-foreground mb-2">
|
||||
{call.modelName}
|
||||
</div>
|
||||
{/* Context Info - only for LLM calls */}
|
||||
{call.modelType === 'llm' &&
|
||||
call.botName &&
|
||||
call.pipelineName && (
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{call.botName} → {call.pipelineName}
|
||||
</div>
|
||||
)}
|
||||
{/* Token Info */}
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{call.modelType === 'llm' && call.tokens && (
|
||||
<>
|
||||
<span>
|
||||
{t('monitoring.llmCalls.inputTokens')}:{' '}
|
||||
{call.tokens.input}
|
||||
</span>
|
||||
<span>
|
||||
{t('monitoring.llmCalls.outputTokens')}:{' '}
|
||||
{call.tokens.output}
|
||||
</span>
|
||||
<span>
|
||||
{t('monitoring.llmCalls.totalTokens')}:{' '}
|
||||
{call.tokens.total}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{call.modelType === 'embedding' && (
|
||||
<>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.promptTokens',
|
||||
)}
|
||||
: {call.promptTokens}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.totalTokens',
|
||||
)}
|
||||
: {call.totalTokens}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.inputCount',
|
||||
)}
|
||||
: {call.inputCount}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span>
|
||||
{t('monitoring.llmCalls.duration')}:{' '}
|
||||
{call.duration}ms
|
||||
</span>
|
||||
{call.cost && (
|
||||
<span>
|
||||
{t('monitoring.llmCalls.cost')}: $
|
||||
{call.cost.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Knowledge Base Info for Embedding */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.knowledgeBaseId && (
|
||||
<div>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.knowledgeBase',
|
||||
)}
|
||||
: {call.knowledgeBaseId}
|
||||
</div>
|
||||
)}
|
||||
{/* Query Text for Embedding Retrieve */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.queryText && (
|
||||
<div className="mt-2 p-2 bg-muted rounded text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
'monitoring.embeddingCalls.queryText',
|
||||
)}
|
||||
:{' '}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{call.queryText.length > 100
|
||||
? call.queryText.substring(0, 100) +
|
||||
'...'
|
||||
: call.queryText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{call.errorMessage && (
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
Error: {call.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap ml-4">
|
||||
{call.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<TabsContent value="messages" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner
|
||||
text={t('monitoring.messageList.loading')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
(!data ||
|
||||
!data.modelCalls ||
|
||||
data.modelCalls.length === 0) && (
|
||||
{!loading && data && conversationTurns.length > 0 && (
|
||||
<ConversationTurnList
|
||||
turns={conversationTurns}
|
||||
expandedTurnId={expandedTurnId}
|
||||
onToggleTurn={toggleTurnExpand}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && (!data || conversationTurns.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<Sparkles className="h-[3rem] w-[3rem]" />
|
||||
<MessageSquare className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">
|
||||
{t('monitoring.modelCalls.noData')}
|
||||
{t('monitoring.messageList.noMessages')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tokens" className="p-3 m-0 sm:p-6">
|
||||
<TokenMonitoring
|
||||
botIds={
|
||||
filterState.selectedBots.length > 0
|
||||
? filterState.selectedBots
|
||||
: undefined
|
||||
}
|
||||
pipelineIds={
|
||||
filterState.selectedPipelines.length > 0
|
||||
? filterState.selectedPipelines
|
||||
: undefined
|
||||
}
|
||||
startTime={feedbackTimeRange.startTime}
|
||||
endTime={feedbackTimeRange.endTime}
|
||||
refreshKey={feedbackRefreshKey}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feedback" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{/* Feedback Stats Cards */}
|
||||
<div className="mb-6">
|
||||
<FeedbackStatsCards
|
||||
stats={feedbackStats}
|
||||
loading={feedbackLoading}
|
||||
/>
|
||||
<TabsContent value="modelCalls" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback List */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('monitoring.feedback.feedbackList')}
|
||||
</h3>
|
||||
<FeedbackList
|
||||
feedback={feedbackList}
|
||||
loading={feedbackLoading}
|
||||
onViewMessage={jumpToMessage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="errors" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data && data.errors && data.errors.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{data.errors.map((error) => (
|
||||
<div
|
||||
key={error.id}
|
||||
className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden transition-all duration-200"
|
||||
>
|
||||
{/* Error Header - Always Visible */}
|
||||
<div
|
||||
className="p-3 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/50 transition-colors bg-red-50/50 dark:bg-red-950/30 sm:p-5"
|
||||
onClick={() => toggleErrorExpand(error.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start flex-1">
|
||||
{/* Expand Icon */}
|
||||
<div className="mr-3 mt-0.5">
|
||||
{expandedErrorId === error.id ? (
|
||||
<ChevronDown className="w-5 h-5 text-red-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Info */}
|
||||
{!loading &&
|
||||
data &&
|
||||
data.modelCalls &&
|
||||
data.modelCalls.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{data.modelCalls.map((call) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="border rounded-xl p-3 transition-all duration-200 sm:p-5"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
{/* Query ID */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Query ID: {error.messageId || '-'}
|
||||
</span>
|
||||
{error.messageId && (
|
||||
{/* Query ID - only show if messageId exists */}
|
||||
{call.messageId && (
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Query ID: {call.messageId}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
jumpToMessage(error.messageId!);
|
||||
}}
|
||||
onClick={() =>
|
||||
jumpToMessage(call.messageId!)
|
||||
}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
{t(
|
||||
'monitoring.messageList.viewConversation',
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium text-sm text-red-700 dark:text-red-300">
|
||||
{error.errorType}
|
||||
{/* Model Type Badge */}
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.modelType === 'llm'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||
}`}
|
||||
>
|
||||
{call.modelType === 'llm'
|
||||
? t('monitoring.modelCalls.llmModel')
|
||||
: t(
|
||||
'monitoring.modelCalls.embeddingModel',
|
||||
)}
|
||||
</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{error.botName}
|
||||
</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{error.pipelineName}
|
||||
{/* Call Type Badge for Embedding */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.callType && (
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.callType === 'retrieve'
|
||||
? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
|
||||
}`}
|
||||
>
|
||||
{call.callType === 'retrieve'
|
||||
? t(
|
||||
'monitoring.modelCalls.retrieveCall',
|
||||
)
|
||||
: t(
|
||||
'monitoring.modelCalls.embeddingCall',
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* Status Badge */}
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
call.status === 'success'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}
|
||||
>
|
||||
{call.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-red-600 dark:text-red-400 line-clamp-2">
|
||||
{error.errorMessage}
|
||||
</p>
|
||||
{/* Model Name */}
|
||||
<div className="font-medium text-sm text-foreground mb-2">
|
||||
{call.modelName}
|
||||
</div>
|
||||
{/* Context Info - only for LLM calls */}
|
||||
{call.modelType === 'llm' &&
|
||||
call.botName &&
|
||||
call.pipelineName && (
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{call.botName} → {call.pipelineName}
|
||||
</div>
|
||||
)}
|
||||
{/* Token Info */}
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{call.modelType === 'llm' &&
|
||||
call.tokens && (
|
||||
<>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.llmCalls.inputTokens',
|
||||
)}
|
||||
: {call.tokens.input}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.llmCalls.outputTokens',
|
||||
)}
|
||||
: {call.tokens.output}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.llmCalls.totalTokens',
|
||||
)}
|
||||
: {call.tokens.total}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{call.modelType === 'embedding' && (
|
||||
<>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.promptTokens',
|
||||
)}
|
||||
: {call.promptTokens}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.totalTokens',
|
||||
)}
|
||||
: {call.totalTokens}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.inputCount',
|
||||
)}
|
||||
: {call.inputCount}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span>
|
||||
{t('monitoring.llmCalls.duration')}:{' '}
|
||||
{call.duration}ms
|
||||
</span>
|
||||
{call.cost && (
|
||||
<span>
|
||||
{t('monitoring.llmCalls.cost')}: $
|
||||
{call.cost.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Knowledge Base Info for Embedding */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.knowledgeBaseId && (
|
||||
<div>
|
||||
{t(
|
||||
'monitoring.embeddingCalls.knowledgeBase',
|
||||
)}
|
||||
: {call.knowledgeBaseId}
|
||||
</div>
|
||||
)}
|
||||
{/* Query Text for Embedding Retrieve */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.queryText && (
|
||||
<div className="mt-2 p-2 bg-muted rounded text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
'monitoring.embeddingCalls.queryText',
|
||||
)}
|
||||
:{' '}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{call.queryText.length > 100
|
||||
? call.queryText.substring(0, 100) +
|
||||
'...'
|
||||
: call.queryText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{call.errorMessage && (
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
Error: {call.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="flex flex-col items-end gap-2 ml-4">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{error.timestamp.toLocaleString()}
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap ml-4">
|
||||
{call.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expandedErrorId === error.id && (
|
||||
<div className="border-t border-red-200 dark:border-red-900 p-5 bg-background">
|
||||
<div className="space-y-4 pl-8 border-l-2 border-red-300 dark:border-red-800 ml-4">
|
||||
{/* Error Details */}
|
||||
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3">
|
||||
{t('monitoring.errors.errorMessage')}
|
||||
</h4>
|
||||
<div className="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap break-words">
|
||||
{error.errorMessage}
|
||||
{!loading &&
|
||||
(!data ||
|
||||
!data.modelCalls ||
|
||||
data.modelCalls.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<Sparkles className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">
|
||||
{t('monitoring.modelCalls.noData')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tokens" className="p-3 m-0 sm:p-6">
|
||||
<TokenMonitoring
|
||||
botIds={
|
||||
filterState.selectedBots.length > 0
|
||||
? filterState.selectedBots
|
||||
: undefined
|
||||
}
|
||||
pipelineIds={
|
||||
filterState.selectedPipelines.length > 0
|
||||
? filterState.selectedPipelines
|
||||
: undefined
|
||||
}
|
||||
startTime={feedbackTimeRange.startTime}
|
||||
endTime={feedbackTimeRange.endTime}
|
||||
refreshKey={feedbackRefreshKey}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feedback" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{/* Feedback Stats Cards */}
|
||||
<div className="mb-6">
|
||||
<FeedbackStatsCards
|
||||
stats={feedbackStats}
|
||||
loading={feedbackLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Feedback List */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('monitoring.feedback.feedbackList')}
|
||||
</h3>
|
||||
<FeedbackList
|
||||
feedback={feedbackList}
|
||||
loading={feedbackLoading}
|
||||
onViewMessage={jumpToMessage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="errors" className="p-3 m-0 sm:p-6">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data &&
|
||||
data.errors &&
|
||||
data.errors.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{data.errors.map((error) => (
|
||||
<div
|
||||
key={error.id}
|
||||
className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden transition-all duration-200"
|
||||
>
|
||||
{/* Error Header - Always Visible */}
|
||||
<div
|
||||
className="p-3 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/50 transition-colors bg-red-50/50 dark:bg-red-950/30 sm:p-5"
|
||||
onClick={() => toggleErrorExpand(error.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start flex-1">
|
||||
{/* Expand Icon */}
|
||||
<div className="mr-3 mt-0.5">
|
||||
{expandedErrorId === error.id ? (
|
||||
<ChevronDown className="w-5 h-5 text-red-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Info */}
|
||||
<div className="flex-1">
|
||||
{/* Query ID */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Query ID: {error.messageId || '-'}
|
||||
</span>
|
||||
{error.messageId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
jumpToMessage(error.messageId!);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
{t(
|
||||
'monitoring.messageList.viewConversation',
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium text-sm text-red-700 dark:text-red-300">
|
||||
{error.errorType}
|
||||
</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{error.botName}
|
||||
</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{error.pipelineName}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-red-600 dark:text-red-400 line-clamp-2">
|
||||
{error.errorMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="flex flex-col items-end gap-2 ml-4">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{error.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context Info */}
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.messageList.viewDetails')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.bot')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{error.botName}
|
||||
{/* Expanded Details */}
|
||||
{expandedErrorId === error.id && (
|
||||
<div className="border-t border-red-200 dark:border-red-900 p-5 bg-background">
|
||||
<div className="space-y-4 pl-8 border-l-2 border-red-300 dark:border-red-800 ml-4">
|
||||
{/* Error Details */}
|
||||
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3">
|
||||
{t('monitoring.errors.errorMessage')}
|
||||
</h4>
|
||||
<div className="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap break-words">
|
||||
{error.errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.pipeline')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{error.pipelineName}
|
||||
|
||||
{/* Context Info */}
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.messageList.viewDetails')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.bot')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{error.botName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.pipeline')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{error.pipelineName}
|
||||
</div>
|
||||
</div>
|
||||
{error.sessionId && (
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.sessions.sessionId')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{error.sessionId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error.sessionId && (
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.sessions.sessionId')}
|
||||
</div>
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{error.sessionId}
|
||||
</div>
|
||||
|
||||
{/* Stack Trace */}
|
||||
{error.stackTrace && (
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.errors.stackTrace')}
|
||||
</h4>
|
||||
<pre className="text-xs text-muted-foreground overflow-auto max-h-60 bg-background p-3 rounded whitespace-pre-wrap break-words">
|
||||
{error.stackTrace}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stack Trace */}
|
||||
{error.stackTrace && (
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.errors.stackTrace')}
|
||||
</h4>
|
||||
<pre className="text-xs text-muted-foreground overflow-auto max-h-60 bg-background p-3 rounded whitespace-pre-wrap break-words">
|
||||
{error.stackTrace}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
(!data || !data.errors || data.errors.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<CheckCircle2 className="h-[3rem] w-[3rem] text-green-500 dark:text-green-600" />
|
||||
<div className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('monitoring.errors.noErrors')}
|
||||
{!loading &&
|
||||
(!data || !data.errors || data.errors.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<CheckCircle2 className="h-[3rem] w-[3rem] text-green-500 dark:text-green-600" />
|
||||
<div className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('monitoring.errors.noErrors')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,6 +217,11 @@ export interface FeedbackStats {
|
||||
}
|
||||
|
||||
export interface MonitoringData {
|
||||
traffic?: {
|
||||
bucket: 'hour' | 'day';
|
||||
points: Array<{ timestamp: Date; messages: number; llmCalls: number }>;
|
||||
truncated: boolean;
|
||||
};
|
||||
overview: OverviewMetrics;
|
||||
messages: MonitoringMessage[];
|
||||
llmCalls: LLMCall[];
|
||||
|
||||
@@ -155,17 +155,18 @@ function findTurnBySessionTime(
|
||||
sessionTurns: Map<string, ConversationTurn[]>,
|
||||
sessionId: string | undefined,
|
||||
timestamp: Date,
|
||||
botId: string,
|
||||
): ConversationTurn | undefined {
|
||||
if (!sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const turns = sessionTurns.get(sessionId);
|
||||
const turns = sessionTurns.get(JSON.stringify([botId, sessionId]));
|
||||
if (!turns?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let nearest = turns[0];
|
||||
let nearest: ConversationTurn | undefined;
|
||||
const targetTime = timestamp.getTime();
|
||||
|
||||
for (const turn of turns) {
|
||||
@@ -203,15 +204,16 @@ export function buildConversationTurns(
|
||||
|
||||
for (const message of visibleMessages) {
|
||||
const role = normalizeRole(message, activityMessageIds);
|
||||
const previousTurn = lastTurnBySession.get(message.sessionId);
|
||||
const sessionKey = JSON.stringify([message.botId, message.sessionId]);
|
||||
const previousTurn = lastTurnBySession.get(sessionKey);
|
||||
const shouldStartTurn = role === 'user' || !previousTurn;
|
||||
const turn = shouldStartTurn ? createTurn(message) : previousTurn;
|
||||
|
||||
if (shouldStartTurn) {
|
||||
const turns = sessionTurns.get(message.sessionId) ?? [];
|
||||
const turns = sessionTurns.get(sessionKey) ?? [];
|
||||
turns.push(turn);
|
||||
sessionTurns.set(message.sessionId, turns);
|
||||
lastTurnBySession.set(message.sessionId, turn);
|
||||
sessionTurns.set(sessionKey, turns);
|
||||
lastTurnBySession.set(sessionKey, turn);
|
||||
}
|
||||
|
||||
addMessageToTurn(turn, message, role);
|
||||
@@ -221,9 +223,14 @@ export function buildConversationTurns(
|
||||
const allTurns = Array.from(sessionTurns.values()).flat();
|
||||
|
||||
for (const call of llmCalls) {
|
||||
const turn =
|
||||
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
|
||||
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
|
||||
const turn = call.messageId
|
||||
? messageIdToTurn.get(call.messageId)
|
||||
: findTurnBySessionTime(
|
||||
sessionTurns,
|
||||
call.sessionId,
|
||||
call.timestamp,
|
||||
call.botId,
|
||||
);
|
||||
|
||||
if (!turn) {
|
||||
continue;
|
||||
@@ -243,9 +250,14 @@ export function buildConversationTurns(
|
||||
}
|
||||
|
||||
for (const call of toolCalls) {
|
||||
const turn =
|
||||
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
|
||||
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
|
||||
const turn = call.messageId
|
||||
? messageIdToTurn.get(call.messageId)
|
||||
: findTurnBySessionTime(
|
||||
sessionTurns,
|
||||
call.sessionId,
|
||||
call.timestamp,
|
||||
call.botId,
|
||||
);
|
||||
|
||||
if (!turn) {
|
||||
continue;
|
||||
@@ -262,9 +274,14 @@ export function buildConversationTurns(
|
||||
}
|
||||
|
||||
for (const error of errors) {
|
||||
const turn =
|
||||
(error.messageId ? messageIdToTurn.get(error.messageId) : undefined) ??
|
||||
findTurnBySessionTime(sessionTurns, error.sessionId, error.timestamp);
|
||||
const turn = error.messageId
|
||||
? messageIdToTurn.get(error.messageId)
|
||||
: findTurnBySessionTime(
|
||||
sessionTurns,
|
||||
error.sessionId,
|
||||
error.timestamp,
|
||||
error.botId,
|
||||
);
|
||||
|
||||
if (!turn) {
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
Puzzle,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import type { MigrationInstallation } from '@/app/infra/entities/api/pipeline-migration';
|
||||
import type { PluginV4 } from '@/app/infra/entities/plugin';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
|
||||
const stageKeys: Record<string, string> = {
|
||||
checking: 'pipelineMigration.checkingPlugin',
|
||||
downloading: 'plugins.installProgress.downloading',
|
||||
validating: 'plugins.installProgress.validating',
|
||||
preparing: 'pipelineMigration.preparingPlugin',
|
||||
installing_deps: 'plugins.installProgress.installingDeps',
|
||||
activating: 'plugins.installProgress.initializing',
|
||||
refreshing: 'plugins.installProgress.activating',
|
||||
done: 'plugins.installProgress.completed',
|
||||
};
|
||||
|
||||
function bytes(value: number) {
|
||||
return value < 1024 * 1024
|
||||
? `${(value / 1024).toFixed(1)} KB`
|
||||
: `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function InstallationPluginIdentity({ item }: { item: MigrationInstallation }) {
|
||||
const { t } = useTranslation();
|
||||
const [plugin, setPlugin] = useState<PluginV4 | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void getCloudServiceClientSync()
|
||||
.getPluginDetail(item.author, item.name)
|
||||
.then(({ plugin }) => {
|
||||
if (active) setPlugin(plugin);
|
||||
})
|
||||
.catch(() => {
|
||||
// Marketplace metadata is optional and must not interrupt installation.
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [item.author, item.name]);
|
||||
|
||||
const name = (plugin?.label && extractI18nObject(plugin.label)) || item.name;
|
||||
const description =
|
||||
(plugin?.description && extractI18nObject(plugin.description)) ||
|
||||
t('market.noDescription');
|
||||
const iconURL = getCloudServiceClientSync().resolveMarketplaceIconURL(
|
||||
'plugin',
|
||||
item.author,
|
||||
item.name,
|
||||
plugin?.icon,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="size-10 rounded-lg">
|
||||
<AvatarImage src={iconURL} alt={name} className="object-contain" />
|
||||
<AvatarFallback className="rounded-lg">
|
||||
<Puzzle className="size-5 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-sm font-medium"
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{item.version}
|
||||
</Badge>
|
||||
</div>
|
||||
<p
|
||||
className="line-clamp-2 break-words text-xs text-muted-foreground"
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MigrationInstallProgress({
|
||||
installations,
|
||||
}: {
|
||||
installations: MigrationInstallation[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!installations.length) return null;
|
||||
return (
|
||||
<div className="space-y-2" data-testid="migration-installations">
|
||||
{installations.map((item) => {
|
||||
const failed = item.status === 'failed';
|
||||
const done = item.status === 'completed';
|
||||
const Icon = failed ? XCircle : done ? CheckCircle2 : Loader2;
|
||||
const label = t(
|
||||
stageKeys[item.stage] ?? 'pipelineMigration.installing',
|
||||
);
|
||||
const end = item.finished_at ?? item.updated_at;
|
||||
const seconds = (start: number, finish: number) =>
|
||||
`${Math.max(0, finish - start).toFixed(1)} s`;
|
||||
return (
|
||||
<Collapsible
|
||||
key={`${item.author}/${item.name}/${item.version}`}
|
||||
className="rounded-md border p-3"
|
||||
>
|
||||
<InstallationPluginIdentity item={item} />
|
||||
<div className="mt-3 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon
|
||||
className={`size-3.5 shrink-0 ${failed ? 'text-destructive' : !done ? 'animate-spin' : 'text-green-600'}`}
|
||||
/>
|
||||
{failed ? t('plugins.installProgress.failed') : label}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{seconds(item.started_at, end)}
|
||||
</span>
|
||||
</div>
|
||||
{!failed && !done && (
|
||||
<Progress
|
||||
className="mt-2 h-1.5"
|
||||
value={item.progress_percent ?? 0}
|
||||
aria-label={t('pipelineMigration.stageProgress')}
|
||||
/>
|
||||
)}
|
||||
{failed && (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
{t(
|
||||
item.code === 'plugin_runtime_unavailable'
|
||||
? 'pipelineMigration.notices.runtimeUnavailable'
|
||||
: `pipelineMigration.installErrors.${item.code}`,
|
||||
{ defaultValue: t('pipelineMigration.installFailed') },
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="group mt-1 h-7 px-0 text-xs text-muted-foreground hover:bg-transparent"
|
||||
>
|
||||
<ChevronDown className="size-3 group-data-[state=open]:rotate-180" />
|
||||
{t('pipelineMigration.installDetails')}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 pt-1 text-xs">
|
||||
<p className="break-all text-muted-foreground">
|
||||
{item.author}/{item.name}
|
||||
</p>
|
||||
{item.steps.map((step, index) => (
|
||||
<div key={index} className="flex justify-between gap-2">
|
||||
<span
|
||||
className={
|
||||
failed && index === item.steps.length - 1
|
||||
? 'text-destructive'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{t(stageKeys[step.stage] ?? 'pipelineMigration.installing')}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{seconds(step.started_at, step.finished_at ?? end)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{item.download_current !== undefined && (
|
||||
<p className="text-muted-foreground">
|
||||
{t('pipelineMigration.downloaded', {
|
||||
size: bytes(item.download_current),
|
||||
})}
|
||||
{item.download_total
|
||||
? ` / ${bytes(item.download_total)}`
|
||||
: ''}
|
||||
{item.status === 'installing' &&
|
||||
item.stage === 'downloading' &&
|
||||
item.download_speed
|
||||
? ` · ${bytes(item.download_speed)}/s`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{item.deps_total !== undefined && (
|
||||
<p className="text-muted-foreground">
|
||||
{t('plugins.installProgress.depsInfo', {
|
||||
count: item.deps_total,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import EntityLoadState from '@/components/EntityLoadState';
|
||||
import { isCurrentPipelineConfig } from './pipeline-config-safety';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import PipelineFormComponent, {
|
||||
PipelineFormHandle,
|
||||
} from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
|
||||
@@ -150,6 +152,25 @@ export default function PipelineDetailContent({
|
||||
);
|
||||
if (!pipelineDetails) return <EntityLoadState />;
|
||||
|
||||
// Never hydrate plugin defaults or mount debug autosave for legacy bindings.
|
||||
if (!isCurrentPipelineConfig(pipelineDetails.config)) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{pipelineDetails.emoji || '⚙️'} {pipelineDetails.name}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pipelineDetails.description}
|
||||
</p>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t('pipelineMigration.legacyGate')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Edit Mode ====================
|
||||
const pipelineName =
|
||||
pipelineDetails?.name ||
|
||||
@@ -178,6 +199,7 @@ export default function PipelineDetailContent({
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<PipelineFormComponent
|
||||
ref={pipelineFormRef}
|
||||
onLegacyPipeline={setPipelineDetails}
|
||||
pipelineId={id}
|
||||
isEditMode={true}
|
||||
disableForm={!canManage}
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, ChevronDown, Loader2 } from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
|
||||
import MigrationInstallProgress from './MigrationInstallProgress';
|
||||
import { migrationIssueKey } from './pipeline-migration-issues';
|
||||
import type { CurrentWorkspace } from '@/app/infra/entities/workspace';
|
||||
import type {
|
||||
MigrationInstallation,
|
||||
PipelineMigrationIssue,
|
||||
PipelineMigrationPreview,
|
||||
PipelineMigrationResult,
|
||||
} from '@/app/infra/entities/api/pipeline-migration';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
export function migrationWorkspaceKey(workspace: CurrentWorkspace | null) {
|
||||
return workspace
|
||||
? `${workspace.workspace.instance_uuid}:${workspace.workspace.uuid}:${workspace.placement_generation}:${workspace.permissions.join(',')}`
|
||||
: '';
|
||||
}
|
||||
|
||||
const resultStates = new Set([
|
||||
'pending',
|
||||
'migrated',
|
||||
'already_current',
|
||||
'blocked',
|
||||
'stale',
|
||||
'failed',
|
||||
'activation_pending',
|
||||
]);
|
||||
type Status =
|
||||
| 'idle'
|
||||
| 'submitting'
|
||||
| 'running'
|
||||
| 'finished'
|
||||
| 'failed'
|
||||
| 'lost'
|
||||
| 'requestError';
|
||||
|
||||
export default function PipelineMigration({
|
||||
workspace,
|
||||
onComplete,
|
||||
}: {
|
||||
workspace: CurrentWorkspace;
|
||||
onComplete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scopeKey = migrationWorkspaceKey(workspace);
|
||||
const canManage = workspace.permissions.includes('resource.manage');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [preview, setPreview] = useState<PipelineMigrationPreview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = useState(false);
|
||||
const [valid, setValid] = useState(false);
|
||||
const [phase, setPhase] = useState('migrating');
|
||||
const [dataOnly, setDataOnly] = useState(false);
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [installations, setInstallations] = useState<MigrationInstallation[]>(
|
||||
[],
|
||||
);
|
||||
const [results, setResults] = useState<PipelineMigrationResult[]>([]);
|
||||
const active = useRef(false);
|
||||
const submitting = useRef(false);
|
||||
const previewGeneration = useRef(0);
|
||||
const controller = useRef<AbortController | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
onCompleteRef.current = onComplete;
|
||||
const isCurrent = useCallback(
|
||||
() =>
|
||||
active.current &&
|
||||
migrationWorkspaceKey(getCurrentWorkspaceSnapshot()) === scopeKey,
|
||||
[scopeKey],
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (allowSelection = true) => {
|
||||
if (!isCurrent()) return;
|
||||
const generation = ++previewGeneration.current;
|
||||
setValid(false);
|
||||
setLoading(true);
|
||||
setPreviewError(false);
|
||||
try {
|
||||
const response = await httpClient.getPipelineMigrationPreview({
|
||||
signal: controller.current?.signal,
|
||||
});
|
||||
if (!isCurrent() || generation !== previewGeneration.current) return;
|
||||
if (
|
||||
response.workspace_uuid !== workspace.workspace.uuid ||
|
||||
!Array.isArray(response.items) ||
|
||||
response.total !== response.items.length
|
||||
)
|
||||
throw new Error('Invalid preview');
|
||||
setPreview(response);
|
||||
setValid(allowSelection);
|
||||
} catch {
|
||||
if (isCurrent() && generation === previewGeneration.current) {
|
||||
setPreviewError(true);
|
||||
setPreview(null);
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent() && generation === previewGeneration.current)
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[isCurrent, workspace.workspace.uuid],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
active.current = true;
|
||||
controller.current = new AbortController();
|
||||
void refresh();
|
||||
const onFocus = () => {
|
||||
if (!submitting.current) void refresh();
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => {
|
||||
active.current = false;
|
||||
controller.current?.abort();
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const busy = status === 'submitting' || status === 'running';
|
||||
const completed =
|
||||
status === 'finished' &&
|
||||
results.length > 0 &&
|
||||
results.every((item) =>
|
||||
['migrated', 'already_current'].includes(item.state),
|
||||
);
|
||||
const rows = preview?.items ?? [];
|
||||
const count = rows.filter(
|
||||
(item) => !['already_current', 'not_legacy'].includes(item.state),
|
||||
).length;
|
||||
|
||||
function renderIssue(issue: PipelineMigrationIssue, warning = false) {
|
||||
// Unknown server codes use localized fallbacks, never raw upstream messages.
|
||||
if (
|
||||
[
|
||||
'plugin_version_unavailable',
|
||||
'plugin_download_timeout',
|
||||
'plugin_marketplace_unavailable',
|
||||
'plugin_download_failed',
|
||||
'dependency_prepare_failed',
|
||||
'plugin_launch_failed',
|
||||
].includes(issue.code)
|
||||
)
|
||||
return <span>{t(`pipelineMigration.installErrors.${issue.code}`)}</span>;
|
||||
if (issue.code === 'plugin_install_failed')
|
||||
return <span>{t('pipelineMigration.installFailed')}</span>;
|
||||
const key = migrationIssueKey(issue.code);
|
||||
const fallback = t(
|
||||
warning
|
||||
? 'pipelineMigration.warningFallback'
|
||||
: 'pipelineMigration.blockerFallback',
|
||||
);
|
||||
const message = key
|
||||
? t(`pipelineMigration.notices.${key}`, { defaultValue: fallback })
|
||||
: fallback;
|
||||
return <span>{message}</span>;
|
||||
}
|
||||
|
||||
async function execute(installPlugins: boolean) {
|
||||
if (
|
||||
submitting.current ||
|
||||
!isCurrent() ||
|
||||
!canManage ||
|
||||
!valid ||
|
||||
loading ||
|
||||
count === 0
|
||||
)
|
||||
return;
|
||||
let items = rows.filter(
|
||||
(item) => !['already_current', 'not_legacy'].includes(item.state),
|
||||
);
|
||||
setInstallations([]);
|
||||
setDataOnly(!installPlugins);
|
||||
setPhase(installPlugins ? 'installing' : 'migrating');
|
||||
submitting.current = true;
|
||||
++previewGeneration.current;
|
||||
setStatus('submitting');
|
||||
setValid(false);
|
||||
setResults(
|
||||
items.map((item) => ({
|
||||
pipeline_uuid: item.pipeline_uuid,
|
||||
state: 'pending',
|
||||
code: null,
|
||||
})),
|
||||
);
|
||||
|
||||
const loseObservation = () => {
|
||||
if (!isCurrent()) return;
|
||||
submitting.current = false;
|
||||
setStatus('lost');
|
||||
void refresh(false);
|
||||
onCompleteRef.current();
|
||||
};
|
||||
try {
|
||||
const { task_id, pipeline_uuids } =
|
||||
await httpClient.executePipelineMigration(
|
||||
{ confirmed: true, all: true, install_plugins: installPlugins },
|
||||
{ signal: controller.current?.signal },
|
||||
);
|
||||
if (!isCurrent()) return;
|
||||
if (
|
||||
!Array.isArray(pipeline_uuids) ||
|
||||
!pipeline_uuids.length ||
|
||||
pipeline_uuids.some((id) => typeof id !== 'string' || !id) ||
|
||||
new Set(pipeline_uuids).size !== pipeline_uuids.length
|
||||
) {
|
||||
loseObservation();
|
||||
return;
|
||||
}
|
||||
// The server captures the complete workspace set at admission.
|
||||
items = pipeline_uuids.map(
|
||||
(id) =>
|
||||
rows.find((row) => row.pipeline_uuid === id) ?? {
|
||||
pipeline_uuid: id,
|
||||
name: id,
|
||||
state: 'ready' as const,
|
||||
legacy_runner: null,
|
||||
target_runner_id: null,
|
||||
target_plugin: null,
|
||||
changed_paths: [],
|
||||
warnings: [],
|
||||
blockers: [],
|
||||
preview_token: null,
|
||||
},
|
||||
);
|
||||
setStatus('running');
|
||||
const poll = async () => {
|
||||
if (!isCurrent()) return;
|
||||
try {
|
||||
const task = await httpClient.getAsyncTask(task_id, {
|
||||
signal: controller.current?.signal,
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
const metadata = task.task_context?.metadata;
|
||||
if (
|
||||
task.id !== task_id ||
|
||||
metadata?.kind !== 'pipeline_migration' ||
|
||||
!Array.isArray(metadata.results)
|
||||
) {
|
||||
loseObservation();
|
||||
return;
|
||||
}
|
||||
const outcome = metadata.results as PipelineMigrationResult[];
|
||||
if (
|
||||
outcome.some(
|
||||
(item) =>
|
||||
!item ||
|
||||
!resultStates.has(item.state) ||
|
||||
!items.some(
|
||||
(submitted) => submitted.pipeline_uuid === item.pipeline_uuid,
|
||||
),
|
||||
) ||
|
||||
(task.runtime.done && outcome.length !== items.length) ||
|
||||
new Set(outcome.map((item) => item.pipeline_uuid)).size !==
|
||||
outcome.length
|
||||
) {
|
||||
loseObservation();
|
||||
return;
|
||||
}
|
||||
const scopedResults = items.map(
|
||||
(item) =>
|
||||
outcome.find(
|
||||
(result) => result.pipeline_uuid === item.pipeline_uuid,
|
||||
) ?? {
|
||||
pipeline_uuid: item.pipeline_uuid,
|
||||
state: 'pending' as const,
|
||||
code: null,
|
||||
},
|
||||
);
|
||||
setResults(scopedResults);
|
||||
if (Array.isArray(metadata.installations))
|
||||
setInstallations(metadata.installations as MigrationInstallation[]);
|
||||
if (metadata.phase === 'installing' || metadata.phase === 'migrating')
|
||||
setPhase(metadata.phase);
|
||||
if (task.runtime.done) {
|
||||
if (
|
||||
!task.runtime.exception &&
|
||||
scopedResults.some((item) => item.state === 'pending')
|
||||
) {
|
||||
loseObservation();
|
||||
return;
|
||||
}
|
||||
submitting.current = false;
|
||||
setStatus(task.runtime.exception ? 'failed' : 'finished');
|
||||
void refresh(!task.runtime.exception);
|
||||
onCompleteRef.current();
|
||||
} else {
|
||||
timer.current = setTimeout(() => {
|
||||
void poll();
|
||||
}, 1000);
|
||||
}
|
||||
} catch {
|
||||
loseObservation();
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return;
|
||||
const code =
|
||||
typeof error === 'object' && error && 'code' in error
|
||||
? error.code
|
||||
: undefined;
|
||||
if (code === -1 || code === -2 || code === undefined) {
|
||||
loseObservation();
|
||||
return;
|
||||
}
|
||||
submitting.current = false;
|
||||
setStatus('requestError');
|
||||
}
|
||||
}
|
||||
|
||||
function changeOpen(next: boolean) {
|
||||
setOpen(next);
|
||||
if (!submitting.current) {
|
||||
if (next) void refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{(count > 0 || previewError || results.length > 0) && (
|
||||
<Alert className="mb-4 shrink-0 border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<AlertTriangle aria-hidden="true" />
|
||||
<AlertTitle>{t('pipelineMigration.title')}</AlertTitle>
|
||||
<AlertDescription className="flex items-center justify-between gap-3 text-amber-800 dark:text-amber-200">
|
||||
<span>
|
||||
{previewError
|
||||
? t('pipelineMigration.previewError')
|
||||
: t('pipelineMigration.detected', { count })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => changeOpen(true)}
|
||||
>
|
||||
{t('pipelineMigration.review')}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Dialog open={open} onOpenChange={changeOpen}>
|
||||
<DialogContent className="flex max-h-[90dvh] flex-col overflow-hidden sm:max-w-md">
|
||||
<DialogHeader className="shrink-0 pr-5">
|
||||
<DialogTitle>{t('pipelineMigration.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('pipelineMigration.autoDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 space-y-3 overflow-y-auto py-2">
|
||||
{!canManage && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('pipelineMigration.readOnly')}
|
||||
</p>
|
||||
)}
|
||||
{previewError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{t('pipelineMigration.previewError')}
|
||||
</p>
|
||||
)}
|
||||
{busy ? (
|
||||
<div className="flex items-center gap-2 text-sm" role="status">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin" />
|
||||
{t(`pipelineMigration.${phase}`)}
|
||||
</div>
|
||||
) : status !== 'idle' ? (
|
||||
<div className="space-y-2" role="status">
|
||||
<p className="text-sm">{t(`pipelineMigration.${status}`)}</p>
|
||||
{results.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('pipelineMigration.summary', {
|
||||
migrated: results.filter((r) =>
|
||||
['migrated', 'already_current'].includes(r.state),
|
||||
).length,
|
||||
remaining: results.filter(
|
||||
(r) =>
|
||||
!['migrated', 'already_current'].includes(r.state),
|
||||
).length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{dataOnly && results.some((r) => r.state === 'migrated') && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('pipelineMigration.dataOnlyHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('pipelineMigration.detected', { count })}
|
||||
</p>
|
||||
)}
|
||||
<MigrationInstallProgress installations={installations} />
|
||||
{busy && results.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('pipelineMigration.processed', {
|
||||
completed: results.filter((r) => r.state !== 'pending')
|
||||
.length,
|
||||
total: results.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{(count > 0 || results.length > 0) && (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="group px-0 text-muted-foreground hover:bg-transparent"
|
||||
>
|
||||
<ChevronDown className="size-4 group-data-[state=open]:rotate-180" />
|
||||
{t('pipelineMigration.viewPipelines')}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ScrollArea
|
||||
className="h-[min(35dvh,16rem)]"
|
||||
data-testid="migration-scroll-area"
|
||||
>
|
||||
<div className="space-y-3 pr-3">
|
||||
{rows
|
||||
.filter(
|
||||
(item) =>
|
||||
!['already_current', 'not_legacy'].includes(
|
||||
item.state,
|
||||
) ||
|
||||
results.some(
|
||||
(r) => r.pipeline_uuid === item.pipeline_uuid,
|
||||
),
|
||||
)
|
||||
.map((item) => {
|
||||
const result = results.find(
|
||||
(r) => r.pipeline_uuid === item.pipeline_uuid,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={item.pipeline_uuid}
|
||||
className="space-y-1 border-b pb-3 text-sm"
|
||||
data-testid={`migration-result-${item.pipeline_uuid}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="min-w-0 break-words font-medium">
|
||||
{item.name}
|
||||
</span>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{t(
|
||||
`pipelineMigration.states.${result?.state ?? item.state}`,
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
{item.target_plugin && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.target_plugin.name}
|
||||
</p>
|
||||
)}
|
||||
{item.warnings.some(
|
||||
(issue) =>
|
||||
issue.code === 'local.box_state_reset',
|
||||
) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('pipelineMigration.notices.boxReset')}
|
||||
</p>
|
||||
)}
|
||||
{result?.code && result.code !== 'data_only' ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{renderIssue({ code: result.code })}
|
||||
</p>
|
||||
) : (
|
||||
!result &&
|
||||
item.blockers
|
||||
.filter(
|
||||
(b) =>
|
||||
![
|
||||
'plugin_missing',
|
||||
'plugin_disabled',
|
||||
].includes(b.code),
|
||||
)
|
||||
.map((issue, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className="text-xs text-destructive"
|
||||
>
|
||||
{renderIssue(issue)}
|
||||
</p>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="shrink-0 flex-col gap-2 sm:flex-col">
|
||||
{completed ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
{t('pipelineMigration.complete')}
|
||||
</Button>
|
||||
) : (
|
||||
!busy && (
|
||||
<>
|
||||
<Button
|
||||
disabled={!canManage || loading || !valid || !count}
|
||||
className="w-full"
|
||||
onClick={() => void execute(true)}
|
||||
>
|
||||
{t('pipelineMigration.autoInstall')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canManage || loading || !valid || !count}
|
||||
className="w-full"
|
||||
onClick={() => void execute(false)}
|
||||
>
|
||||
{t('pipelineMigration.dataOnly')}
|
||||
</Button>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t('pipelineMigration.dataOnlyHint')}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
<Button variant="ghost" onClick={() => changeOpen(false)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Unavailability takes priority over the deployment's scope restriction. */
|
||||
export function getBoxScopeContext(
|
||||
boxAvailable: boolean,
|
||||
forcedTemplate?: string,
|
||||
) {
|
||||
forcedTemplate = forcedTemplate?.trim();
|
||||
return {
|
||||
box_available: boxAvailable,
|
||||
box_scope_editable: boxAvailable && !forcedTemplate,
|
||||
// Only expose forced-scope reasons when the sandbox is available.
|
||||
box_scope_forced: boxAvailable && !!forcedTemplate,
|
||||
box_scope_forced_global: boxAvailable && forcedTemplate === '{global}',
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import EntityLoadState from '@/components/EntityLoadState';
|
||||
import { preserveRunnerConfig } from './RunnerConfigPreservation';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { isCurrentPipelineConfig } from '../../pipeline-config-safety';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
@@ -67,6 +71,35 @@ import {
|
||||
type InstalledRunner,
|
||||
} from '@/app/home/agents/runner-marketplace';
|
||||
|
||||
/** A mount-scoped editing session: normalized defaults are UI state, not edits. */
|
||||
function PersistedRunnerForm(
|
||||
props: ComponentProps<typeof DynamicFormComponent>,
|
||||
) {
|
||||
const initialValues = useRef(props.initialValues);
|
||||
const previous = useRef<Record<string, unknown> | undefined>(undefined);
|
||||
const current = useRef(props.initialValues || {});
|
||||
current.current = props.initialValues || {};
|
||||
return (
|
||||
<DynamicFormComponent
|
||||
{...props}
|
||||
initialValues={initialValues.current}
|
||||
onSubmit={(values) => {
|
||||
const emitted = values as Record<string, unknown>;
|
||||
const next = preserveRunnerConfig(
|
||||
current.current,
|
||||
previous.current,
|
||||
emitted,
|
||||
);
|
||||
previous.current = structuredClone(emitted);
|
||||
if (JSON.stringify(next) !== JSON.stringify(current.current)) {
|
||||
current.current = next;
|
||||
props.onSubmit?.(next);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface PipelineFormComponentProps {
|
||||
pipelineId?: string;
|
||||
isEditMode: boolean;
|
||||
@@ -78,6 +111,7 @@ interface PipelineFormComponentProps {
|
||||
onCancel?: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onSavingChange?: (saving: boolean) => void;
|
||||
onLegacyPipeline?: (pipeline: Pipeline) => void;
|
||||
}
|
||||
|
||||
export interface PipelineFormHandle {
|
||||
@@ -103,6 +137,7 @@ const PipelineFormComponent = forwardRef<
|
||||
onCancel,
|
||||
onDirtyChange,
|
||||
onSavingChange,
|
||||
onLegacyPipeline,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -111,7 +146,10 @@ const PipelineFormComponent = forwardRef<
|
||||
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
|
||||
const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const pipelineFormElement = useRef<HTMLFormElement>(null);
|
||||
const isSavingRef = useRef(false);
|
||||
const legacyConfigRef = useRef(false);
|
||||
const [legacyConfig, setLegacyConfig] = useState(false);
|
||||
|
||||
const formSchema = isEditMode
|
||||
? z.object({
|
||||
@@ -291,6 +329,12 @@ const PipelineFormComponent = forwardRef<
|
||||
.getPipeline(pipelineId || '')
|
||||
.then((resp: GetPipelineResponseData) => {
|
||||
if (cancelled) return;
|
||||
if (!isCurrentPipelineConfig(resp.pipeline.config)) {
|
||||
legacyConfigRef.current = true;
|
||||
setLegacyConfig(true);
|
||||
onLegacyPipeline?.(resp.pipeline);
|
||||
return;
|
||||
}
|
||||
setIsDefaultPipeline(resp.pipeline.is_default ?? false);
|
||||
|
||||
const loadedValues = {
|
||||
@@ -316,7 +360,7 @@ const PipelineFormComponent = forwardRef<
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form, isEditMode, pipelineId, loadAttempt]);
|
||||
}, [form, isEditMode, pipelineId, loadAttempt, onLegacyPipeline]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -404,7 +448,15 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
|
||||
async function handleModify(values: FormValues): Promise<boolean> {
|
||||
if (isSavingRef.current) return false;
|
||||
// Imperative saves bypass native form submission validation. In particular,
|
||||
// never save an old structured value while its visible JSON draft is invalid.
|
||||
if (
|
||||
pipelineFormElement.current &&
|
||||
!pipelineFormElement.current.checkValidity()
|
||||
)
|
||||
return false;
|
||||
if (isSavingRef.current || legacyConfigRef.current || !pipelineLoaded)
|
||||
return false;
|
||||
const submittedSnapshot = JSON.stringify(values);
|
||||
const realConfig = {
|
||||
ai: values.ai,
|
||||
@@ -468,6 +520,7 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
if (legacyConfigRef.current || !pipelineLoaded) return false;
|
||||
if (!hasUnsavedChangesRef.current) return true;
|
||||
if (isSavingRef.current || !isEditMode) return false;
|
||||
const valid = await form.trigger();
|
||||
@@ -488,7 +541,9 @@ const PipelineFormComponent = forwardRef<
|
||||
values: object,
|
||||
) {
|
||||
const stageKey = `${String(formName)}.${stageName}`;
|
||||
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
|
||||
const isFirstEmission =
|
||||
!initializedStagesRef.current.has(stageKey) &&
|
||||
!(formName === 'output' && stageName === 'misc');
|
||||
|
||||
const currentValues =
|
||||
(form.getValues(formName) as Record<string, unknown>) || {};
|
||||
@@ -524,6 +579,29 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
|
||||
form.setValue(formName, nextValues);
|
||||
if (formName === 'output' && stageName === 'misc') {
|
||||
const removeThink = (values as Record<string, unknown>)['remove-think'];
|
||||
const previousThink = (
|
||||
currentValues.misc as Record<string, unknown> | undefined
|
||||
)?.['remove-think'];
|
||||
const runnerId = form.getValues('ai.runner.id') as string;
|
||||
const supported = aiConfigTabSchema?.stages.some(
|
||||
(stage) =>
|
||||
stage.name === runnerId &&
|
||||
stage.config.some((item) => item.name === 'remove-think'),
|
||||
);
|
||||
if (
|
||||
supported &&
|
||||
typeof removeThink === 'boolean' &&
|
||||
removeThink !== previousThink
|
||||
) {
|
||||
const configs = form.getValues('ai.runner_config') || {};
|
||||
form.setValue('ai.runner_config', {
|
||||
...configs,
|
||||
[runnerId]: { ...configs[runnerId], 'remove-think': removeThink },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isFirstEmission) {
|
||||
initializedStagesRef.current.add(stageKey);
|
||||
@@ -539,22 +617,23 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
|
||||
function handleRunnerConfigEmit(stageName: string, values: object) {
|
||||
const stageKey = `ai.runner_config.${stageName}`;
|
||||
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
|
||||
|
||||
const currentRunnerConfigs =
|
||||
(form.getValues('ai.runner_config') as Record<string, unknown>) || {};
|
||||
(form.getValues('ai.runner_config') as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>) || {};
|
||||
const removeThink = (values as Record<string, unknown>)['remove-think'];
|
||||
const previousThink = currentRunnerConfigs[stageName]?.['remove-think'];
|
||||
form.setValue('ai.runner_config', {
|
||||
...currentRunnerConfigs,
|
||||
[stageName]: values,
|
||||
});
|
||||
|
||||
if (isFirstEmission) {
|
||||
initializedStagesRef.current.add(stageKey);
|
||||
const currentSnapshot = JSON.stringify(form.getValues());
|
||||
if (savedSnapshotRef.current === '' || !hasUnsavedChangesRef.current) {
|
||||
savedSnapshotRef.current = currentSnapshot;
|
||||
}
|
||||
if (typeof removeThink === 'boolean' && removeThink !== previousThink) {
|
||||
const output = form.getValues('output') || {};
|
||||
form.setValue('output', {
|
||||
...output,
|
||||
misc: { ...output.misc, 'remove-think': removeThink },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,7 +710,7 @@ const PipelineFormComponent = forwardRef<
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<DynamicFormComponent
|
||||
<PersistedRunnerForm
|
||||
itemConfigList={stage.config}
|
||||
initialValues={stageInitialValues}
|
||||
systemContext={dynamicFormSystemContext}
|
||||
@@ -645,6 +724,10 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
}
|
||||
|
||||
const StageForm =
|
||||
formName === 'output' && stage.name === 'misc'
|
||||
? PersistedRunnerForm
|
||||
: DynamicFormComponent;
|
||||
const stageInitialValues: Record<string, any> =
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] || {};
|
||||
|
||||
@@ -659,7 +742,7 @@ const PipelineFormComponent = forwardRef<
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<DynamicFormComponent
|
||||
<StageForm
|
||||
itemConfigList={stage.config}
|
||||
initialValues={stageInitialValues}
|
||||
systemContext={dynamicFormSystemContext}
|
||||
@@ -711,6 +794,12 @@ const PipelineFormComponent = forwardRef<
|
||||
}
|
||||
};
|
||||
|
||||
if (legacyConfig)
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>{t('pipelineMigration.legacyGate')}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
if (loadFailed)
|
||||
return (
|
||||
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
|
||||
@@ -723,6 +812,7 @@ const PipelineFormComponent = forwardRef<
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="pipeline-form"
|
||||
ref={pipelineFormElement}
|
||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||
className="h-full flex flex-col flex-1 min-h-0 mb-2"
|
||||
>
|
||||
@@ -920,8 +1010,11 @@ const PipelineFormComponent = forwardRef<
|
||||
{/* Dynamic config sections (edit mode only) */}
|
||||
{isEditMode && (
|
||||
<>
|
||||
{activeSection === 'ai' && aiConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{aiConfigTabSchema && (
|
||||
<div
|
||||
className="space-y-6"
|
||||
hidden={activeSection !== 'ai'}
|
||||
>
|
||||
{aiConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'ai'),
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
type Values = Record<string, unknown>;
|
||||
const isRecord = (value: unknown): value is Values =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
/** Apply only edits relative to the mounted form's normalized emission.
|
||||
* The raw persisted object remains authoritative for unedited values, including
|
||||
* fields not represented by the schema, nulls, omitted defaults and whitespace.
|
||||
*/
|
||||
export function preserveRunnerConfig(
|
||||
current: Values,
|
||||
previous: Values | undefined,
|
||||
emitted: Values,
|
||||
): Values {
|
||||
if (!previous) return current;
|
||||
const result = { ...current };
|
||||
for (const key of new Set([
|
||||
...Object.keys(previous),
|
||||
...Object.keys(emitted),
|
||||
])) {
|
||||
if (JSON.stringify(previous[key]) === JSON.stringify(emitted[key]))
|
||||
continue;
|
||||
if (!Object.prototype.hasOwnProperty.call(emitted, key)) {
|
||||
delete result[key];
|
||||
} else if (isRecord(previous[key]) && isRecord(emitted[key])) {
|
||||
result[key] = preserveRunnerConfig(
|
||||
isRecord(current[key]) ? current[key] : {},
|
||||
previous[key],
|
||||
emitted[key],
|
||||
);
|
||||
} else {
|
||||
result[key] = emitted[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,19 +1,44 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import PipelineDetailContent from './PipelineDetailContent';
|
||||
import PipelineMigration, { migrationWorkspaceKey } from './PipelineMigration';
|
||||
|
||||
export default function PipelineConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
|
||||
if (detailId) {
|
||||
return <PipelineDetailContent key={detailId} id={detailId} />;
|
||||
}
|
||||
const workspace = useCurrentWorkspace();
|
||||
const { refreshPipelines } = useSidebarData();
|
||||
const [revision, setRevision] = useState(0);
|
||||
const scopeKey = migrationWorkspaceKey(workspace);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('pipelines.selectFromSidebar')}</p>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{workspace && workspace.permissions.includes('resource.view') && (
|
||||
<PipelineMigration
|
||||
key={scopeKey}
|
||||
workspace={workspace}
|
||||
onComplete={() => {
|
||||
void refreshPipelines();
|
||||
setRevision((current) => current + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{detailId ? (
|
||||
<PipelineDetailContent
|
||||
key={`${scopeKey}:${detailId}:${revision}`}
|
||||
id={detailId}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('pipelines.selectFromSidebar')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Only unambiguous current containers may hydrate editor defaults. */
|
||||
export function isCurrentPipelineConfig(config: unknown): boolean {
|
||||
if (!record(config) || !record(config.ai) || !record(config.ai.runner))
|
||||
return false;
|
||||
const ai = config.ai;
|
||||
const runner = ai.runner as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(ai).some((key) => !['runner', 'runner_config'].includes(key)) ||
|
||||
Object.keys(runner).some((key) => !['id', 'expire-time'].includes(key)) ||
|
||||
typeof runner.id !== 'string'
|
||||
)
|
||||
return false;
|
||||
|
||||
// The canonical current default has no selected runner yet. Do not confuse
|
||||
// it with a legacy selector or an orphaned configuration needing review.
|
||||
if (runner.id === '')
|
||||
return (
|
||||
record(ai.runner_config) && Object.keys(ai.runner_config).length === 0
|
||||
);
|
||||
|
||||
return runner.id.startsWith('plugin:');
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
const issueKeys: Record<string, string> = {
|
||||
plugin_runtime_unavailable: 'runtimeUnavailable',
|
||||
migration_failed: 'executionFailed',
|
||||
plugin_missing: 'pluginRequired',
|
||||
plugin_disabled: 'pluginRequired',
|
||||
'local.context_defaults': 'contextDefaults',
|
||||
'local.model_reasoning': 'modelReasoning',
|
||||
'local.serial_tools_preserved': 'serialTools',
|
||||
'local.retrieval_defaults': 'retrievalDefaults',
|
||||
'local.box_state_reset': 'boxReset',
|
||||
'coze.persistent_history': 'persistentHistory',
|
||||
'langflow.persistent_history': 'persistentHistory',
|
||||
'langflow.tweaks_default': 'tweaksDefault',
|
||||
'dify.timeout_default': 'timeoutDefault',
|
||||
'n8n.session_ids_reset': 'historyReset',
|
||||
'weknora.session_title_changed': 'sessionTitle',
|
||||
'migration.alias_repaired': 'aliasRepaired',
|
||||
'migration.null_default': 'nullDefault',
|
||||
'migration.output_policy_copied': 'outputPolicy',
|
||||
'migration.filtered_variables': 'filteredVariables',
|
||||
'migration.history_reset': 'historyReset',
|
||||
'migration.identity_preserved': 'identityPreserved',
|
||||
'migration.new_defaults': 'newDefaults',
|
||||
'migration.legacy_sections_archived': 'legacyArchive',
|
||||
'external.state_validation_required': 'externalState',
|
||||
'runtime.plugin_version_mismatch': 'pluginVersion',
|
||||
'runtime.schema_invalid': 'schemaChanged',
|
||||
'runtime.schema_missing': 'schemaChanged',
|
||||
'extensions.runner_excluded': 'runnerExcluded',
|
||||
'local.box_template_invalid': 'boxTemplateInvalid',
|
||||
runner_schema_incompatible: 'schemaChanged',
|
||||
plugin_version_incompatible: 'pluginVersion',
|
||||
'runtime.pending_interaction': 'pendingInteraction',
|
||||
};
|
||||
|
||||
/** Only locally reviewed codes may select localized copy. */
|
||||
export function migrationIssueKey(code: string): string | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(issueKeys, code)
|
||||
? issueKeys[code]
|
||||
: undefined;
|
||||
}
|
||||
+3
-1
@@ -185,7 +185,9 @@ function isPluginInstallTask(name: string): boolean {
|
||||
/**
|
||||
* Convert a backend AsyncTask to our PluginInstallTask.
|
||||
*/
|
||||
function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
||||
export function asyncTaskToPluginInstallTask(
|
||||
task: AsyncTask,
|
||||
): PluginInstallTask {
|
||||
const source = extractSourceFromName(task.name);
|
||||
const md = (task.task_context?.metadata ?? {}) as Record<string, unknown>;
|
||||
const action = task.task_context?.current_action || '';
|
||||
|
||||
@@ -196,6 +196,13 @@ export interface RunnerDescriptor {
|
||||
|
||||
export interface ProcessorRun {
|
||||
run_id: string;
|
||||
binding_id?: string;
|
||||
runner_id?: string;
|
||||
usage?: {
|
||||
prompt_tokens?: number | null;
|
||||
completion_tokens?: number | null;
|
||||
total_tokens?: number | null;
|
||||
} | null;
|
||||
status: string;
|
||||
status_reason?: string;
|
||||
created_at: number;
|
||||
@@ -204,7 +211,13 @@ export interface ProcessorRun {
|
||||
created_at_ms?: number | null;
|
||||
started_at_ms?: number | null;
|
||||
finished_at_ms?: number | null;
|
||||
metadata: { event_type?: string; input_event?: unknown; delivery?: unknown };
|
||||
metadata: {
|
||||
event_type?: string;
|
||||
source?: string;
|
||||
input?: { text?: string; contents?: unknown[]; attachments?: unknown[] };
|
||||
input_event?: unknown;
|
||||
delivery?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProcessorRunEvent {
|
||||
@@ -215,6 +228,7 @@ export interface ProcessorRunEvent {
|
||||
|
||||
export interface ProcessorRunPage {
|
||||
items: ProcessorRun[];
|
||||
total?: number;
|
||||
next_cursor: number | null;
|
||||
has_more: boolean;
|
||||
}
|
||||
@@ -766,6 +780,7 @@ export enum MCPSessionStatus {
|
||||
}
|
||||
|
||||
export interface MCPServerRuntimeInfo {
|
||||
error_code?: string;
|
||||
status: MCPSessionStatus;
|
||||
error_message?: string;
|
||||
/** Stage at which the session failed. Frontends key off this to render
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
export type PipelineMigrationState =
|
||||
| 'ready'
|
||||
| 'needs_plugin'
|
||||
| 'blocked'
|
||||
| 'already_current'
|
||||
| 'not_legacy'
|
||||
| 'activation_pending';
|
||||
|
||||
export interface PipelineMigrationIssue {
|
||||
code: string;
|
||||
field?: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineMigrationItem {
|
||||
pipeline_uuid: string;
|
||||
name: string;
|
||||
state: PipelineMigrationState;
|
||||
legacy_runner: string | null;
|
||||
target_runner_id: string | null;
|
||||
target_plugin: { author: string; name: string; version: string } | null;
|
||||
changed_paths: string[];
|
||||
blockers: PipelineMigrationIssue[];
|
||||
warnings: PipelineMigrationIssue[];
|
||||
preview_token: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineMigrationPreview {
|
||||
planner_version: string;
|
||||
workspace_uuid: string;
|
||||
items: PipelineMigrationItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type PipelineMigrationRequest =
|
||||
| { confirmed: true; all: true; install_plugins: boolean }
|
||||
| {
|
||||
confirmed: true;
|
||||
items: { pipeline_uuid: string; preview_token: string }[];
|
||||
};
|
||||
|
||||
export interface PipelineMigrationResult {
|
||||
pipeline_uuid: string;
|
||||
state:
|
||||
| 'pending'
|
||||
| 'migrated'
|
||||
| 'already_current'
|
||||
| 'blocked'
|
||||
| 'stale'
|
||||
| 'failed'
|
||||
| 'activation_pending';
|
||||
code: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineMigrationTaskMetadata {
|
||||
kind: 'pipeline_migration';
|
||||
phase?: 'installing' | 'migrating' | 'finished';
|
||||
results: PipelineMigrationResult[];
|
||||
installations?: MigrationInstallation[];
|
||||
}
|
||||
|
||||
export interface MigrationInstallation {
|
||||
author: string;
|
||||
name: string;
|
||||
version: string;
|
||||
status: 'installing' | 'completed' | 'failed';
|
||||
stage: string;
|
||||
code: string | null;
|
||||
progress_percent?: number;
|
||||
download_current?: number;
|
||||
download_total?: number;
|
||||
download_speed?: number;
|
||||
deps_total?: number;
|
||||
started_at: number;
|
||||
updated_at: number;
|
||||
finished_at: number | null;
|
||||
steps: { stage: string; started_at: number; finished_at: number | null }[];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Public device-login responses only. OAuth credentials stay on the server. */
|
||||
export interface CodexAuthStatus {
|
||||
status: 'connected' | 'disconnected' | 'expired';
|
||||
connected: boolean;
|
||||
expires_at: number | null;
|
||||
}
|
||||
|
||||
export interface CodexDeviceAuthorization {
|
||||
authorization_id: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
interval: number;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export interface CodexDevicePoll {
|
||||
status: 'pending' | 'connected' | 'expired';
|
||||
interval?: number;
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export interface IDynamicFormItemSchema {
|
||||
type: DynamicFormItemType;
|
||||
description?: I18nObject;
|
||||
options?: IDynamicFormItemOption[];
|
||||
/** Allow an editable value in addition to the declared select options. */
|
||||
allow_custom?: boolean;
|
||||
/** When the condition matches, the field is rendered. Same evaluator as
|
||||
* ``disable_if`` — supports the ``__system.*`` namespace via
|
||||
* ``DynamicFormComponent.systemContext``. */
|
||||
@@ -39,6 +41,13 @@ export interface IDynamicFormItemSchema {
|
||||
disable_if?: IShowIfCondition;
|
||||
/** Tooltip shown next to the field label when ``disable_if`` is active. */
|
||||
disabled_tooltip?: I18nObject;
|
||||
/** Optional overrides evaluated in order when ``disable_if`` matches.
|
||||
* The first matching ``when`` wins; otherwise use ``disabled_tooltip``.
|
||||
* Conditions use the same operators and value lookup as ``disable_if``. */
|
||||
disabled_tooltip_overrides?: {
|
||||
when: IShowIfCondition;
|
||||
tooltip: I18nObject;
|
||||
}[];
|
||||
|
||||
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
|
||||
scopes?: string[];
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
|
||||
import type {
|
||||
PipelineMigrationPreview,
|
||||
PipelineMigrationRequest,
|
||||
} from '@/app/infra/entities/api/pipeline-migration';
|
||||
import type { DebugExecutionEvent } from '@/app/infra/entities/api/agent-debug';
|
||||
import type {
|
||||
CodexAuthStatus,
|
||||
CodexDeviceAuthorization,
|
||||
CodexDevicePoll,
|
||||
} from '@/app/infra/entities/codex';
|
||||
import {
|
||||
ApiRespProviderRequesters,
|
||||
ApiRespProviderRequester,
|
||||
@@ -135,8 +144,52 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.put(`/api/v1/provider/providers/${uuid}`, provider);
|
||||
}
|
||||
|
||||
public deleteModelProvider(uuid: string): Promise<object> {
|
||||
return this.delete(`/api/v1/provider/providers/${uuid}`);
|
||||
public deleteModelProvider(uuid: string, cascade = false): Promise<object> {
|
||||
return this.delete(
|
||||
`/api/v1/provider/providers/${uuid}${cascade ? '?cascade=true' : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
public getCodexAuthStatus(
|
||||
uuid: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CodexAuthStatus> {
|
||||
return this.get(
|
||||
`/api/v1/provider/providers/${uuid}/codex/status`,
|
||||
undefined,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
public startCodexDeviceLogin(
|
||||
uuid: string,
|
||||
): Promise<CodexDeviceAuthorization> {
|
||||
return this.post(`/api/v1/provider/providers/${uuid}/codex/device`, {});
|
||||
}
|
||||
|
||||
public pollCodexDeviceLogin(
|
||||
uuid: string,
|
||||
authorizationId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CodexDevicePoll> {
|
||||
return this.post(
|
||||
`/api/v1/provider/providers/${uuid}/codex/device/poll`,
|
||||
{ authorization_id: authorizationId },
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
public cancelCodexDeviceLogin(
|
||||
uuid: string,
|
||||
authorizationId: string,
|
||||
): Promise<object> {
|
||||
return this.delete(
|
||||
`/api/v1/provider/providers/${uuid}/codex/device/${encodeURIComponent(authorizationId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
public disconnectCodex(uuid: string): Promise<object> {
|
||||
return this.delete(`/api/v1/provider/providers/${uuid}/codex/auth`);
|
||||
}
|
||||
|
||||
public scanProviderModels(
|
||||
@@ -392,6 +445,19 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get(`/api/v1/pipelines/${uuid}`);
|
||||
}
|
||||
|
||||
public getPipelineMigrationPreview(
|
||||
config?: RequestConfig,
|
||||
): Promise<PipelineMigrationPreview> {
|
||||
return this.get('/api/v1/pipelines/_/migration/preview', undefined, config);
|
||||
}
|
||||
|
||||
public executePipelineMigration(
|
||||
body: PipelineMigrationRequest,
|
||||
config?: RequestConfig,
|
||||
): Promise<AsyncTaskCreatedResp & { pipeline_uuids?: string[] }> {
|
||||
return this.post('/api/v1/pipelines/_/migration/execute', body, config);
|
||||
}
|
||||
|
||||
public createPipeline(pipeline: Pipeline): Promise<{
|
||||
uuid: string;
|
||||
}> {
|
||||
@@ -624,8 +690,13 @@ export class BackendClient extends BaseHttpClient {
|
||||
|
||||
public getBotSessions(
|
||||
botId: string,
|
||||
limit: number = 100,
|
||||
offset: number = 0,
|
||||
options: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
userQuery?: string;
|
||||
},
|
||||
): Promise<{
|
||||
sessions: Array<{
|
||||
session_id: string;
|
||||
@@ -645,15 +716,38 @@ export class BackendClient extends BaseHttpClient {
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('botId', botId);
|
||||
queryParams.append('limit', limit.toString());
|
||||
queryParams.append('offset', offset.toString());
|
||||
queryParams.append('limit', options.limit.toString());
|
||||
queryParams.append('offset', options.offset.toString());
|
||||
if (options.startTime) {
|
||||
queryParams.append('startTime', options.startTime);
|
||||
}
|
||||
if (options.endTime) {
|
||||
queryParams.append('endTime', options.endTime);
|
||||
}
|
||||
if (options.userQuery) {
|
||||
queryParams.append('userQuery', options.userQuery);
|
||||
}
|
||||
return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
public getSessionAnalysis<T>(
|
||||
sessionId: string,
|
||||
botId: string,
|
||||
options: { startTime?: string; endTime?: string } = {},
|
||||
): Promise<T> {
|
||||
const queryParams = new URLSearchParams({ botId });
|
||||
if (options.startTime) queryParams.set('startTime', options.startTime);
|
||||
if (options.endTime) queryParams.set('endTime', options.endTime);
|
||||
return this.get(
|
||||
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${queryParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
public getSessionMessages(
|
||||
sessionId: string,
|
||||
limit: number = 200,
|
||||
offset: number = 0,
|
||||
botId?: string,
|
||||
): Promise<{
|
||||
messages: Array<{
|
||||
id: string;
|
||||
@@ -677,6 +771,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('sessionId', sessionId);
|
||||
if (botId) queryParams.append('botId', botId);
|
||||
queryParams.append('limit', limit.toString());
|
||||
queryParams.append('offset', offset.toString());
|
||||
return this.get(`/api/v1/monitoring/messages?${queryParams.toString()}`);
|
||||
@@ -1266,8 +1361,8 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get(`/api/v1/system/tasks${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
public getAsyncTask(id: number): Promise<AsyncTask> {
|
||||
return this.get(`/api/v1/system/tasks/${id}`);
|
||||
public getAsyncTask(id: number, config?: RequestConfig): Promise<AsyncTask> {
|
||||
return this.get(`/api/v1/system/tasks/${id}`, undefined, config);
|
||||
}
|
||||
|
||||
public getPluginSystemStatus(): Promise<ApiRespPluginSystemStatus> {
|
||||
@@ -1387,12 +1482,92 @@ export class BackendClient extends BaseHttpClient {
|
||||
invitation_registration_enabled?: boolean;
|
||||
password_login_enabled?: boolean;
|
||||
space_login_enabled?: boolean;
|
||||
passkey_login_enabled?: boolean;
|
||||
passkey_supported?: boolean;
|
||||
}> {
|
||||
return this.get('/api/v1/user/account-info', undefined, {
|
||||
skipWorkspace: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Passkey (WebAuthn) API ============
|
||||
public getPasskeyAuthOptions(
|
||||
email?: string,
|
||||
origin?: string,
|
||||
): Promise<{ options: any; challenge_token: string }> {
|
||||
return this.post(
|
||||
'/api/v1/user/passkey/auth/options',
|
||||
{ email, origin },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public verifyPasskeyAuth(
|
||||
challenge_token: string,
|
||||
credential: any,
|
||||
): Promise<{ token: string; user: string }> {
|
||||
return this.post(
|
||||
'/api/v1/user/passkey/auth/verify',
|
||||
{ challenge_token, credential },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public getPasskeyRegisterOptions(
|
||||
origin?: string,
|
||||
): Promise<{ options: any; challenge_token: string }> {
|
||||
return this.post(
|
||||
'/api/v1/user/passkey/register/options',
|
||||
{ origin },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public verifyPasskeyRegister(
|
||||
challenge_token: string,
|
||||
credential: any,
|
||||
name?: string,
|
||||
): Promise<{ uuid: string; name: string; created_at?: string }> {
|
||||
return this.post(
|
||||
'/api/v1/user/passkey/register/verify',
|
||||
{ challenge_token, credential, name },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public getPasskeys(): Promise<
|
||||
Array<{
|
||||
uuid: string;
|
||||
name: string;
|
||||
aaguid?: string;
|
||||
transports?: string;
|
||||
backed_up?: boolean;
|
||||
created_at?: string;
|
||||
last_used_at?: string;
|
||||
}>
|
||||
> {
|
||||
return this.get('/api/v1/user/passkeys', undefined, {
|
||||
skipWorkspace: true,
|
||||
});
|
||||
}
|
||||
|
||||
public renamePasskey(
|
||||
uuid: string,
|
||||
name: string,
|
||||
): Promise<{ uuid: string; name: string }> {
|
||||
return this.patch(
|
||||
`/api/v1/user/passkey/${encodeURIComponent(uuid)}`,
|
||||
{ name },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public deletePasskey(uuid: string): Promise<void> {
|
||||
return this.delete(`/api/v1/user/passkey/${encodeURIComponent(uuid)}`, {
|
||||
skipWorkspace: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Workspace API ============
|
||||
public getWorkspaceBootstrap(): Promise<WorkspaceBootstrapResponse> {
|
||||
return this.get('/api/v1/workspaces/bootstrap', undefined, {
|
||||
@@ -1512,6 +1687,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
public async bindSpaceAccount(
|
||||
code: string,
|
||||
state: string,
|
||||
redirectUri: string,
|
||||
): Promise<{
|
||||
token: string;
|
||||
user: string;
|
||||
@@ -1519,7 +1695,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
}> {
|
||||
const response = await this.instance.post(
|
||||
'/api/v1/user/bind-space',
|
||||
{ code, state },
|
||||
{ code, state, redirect_uri: redirectUri },
|
||||
{ skipWorkspace: true } as RequestConfig,
|
||||
);
|
||||
if (response.data.code !== 0) {
|
||||
@@ -1555,6 +1731,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
public async exchangeSpaceOAuthCode(
|
||||
code: string,
|
||||
state: string,
|
||||
redirectUri: string,
|
||||
workspaceUuid?: string,
|
||||
launchAssertion?: string,
|
||||
): Promise<{
|
||||
@@ -1569,6 +1746,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
{
|
||||
code,
|
||||
state,
|
||||
redirect_uri: redirectUri,
|
||||
workspace_uuid: workspaceUuid,
|
||||
launch_assertion: launchAssertion,
|
||||
},
|
||||
@@ -1591,6 +1769,11 @@ export class BackendClient extends BaseHttpClient {
|
||||
endTime?: string;
|
||||
limit?: number;
|
||||
}): Promise<{
|
||||
traffic?: {
|
||||
bucket: 'hour' | 'day';
|
||||
points: Array<{ timestamp: string; messages: number; llm_calls: number }>;
|
||||
truncated: boolean;
|
||||
};
|
||||
overview: {
|
||||
total_messages: number;
|
||||
llm_calls: number;
|
||||
|
||||
@@ -35,7 +35,9 @@ import {
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Layers,
|
||||
Fingerprint,
|
||||
} from 'lucide-react';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -63,6 +65,8 @@ export default function Login() {
|
||||
const [spaceLoading, setSpaceLoading] = useState(false);
|
||||
const [showLocalLogin, setShowLocalLogin] = useState(false);
|
||||
const [showSpaceLogin, setShowSpaceLogin] = useState(false);
|
||||
const [showPasskeyLogin, setShowPasskeyLogin] = useState(false);
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
@@ -90,6 +94,9 @@ export default function Login() {
|
||||
}
|
||||
setShowLocalLogin(res.password_login_enabled !== false);
|
||||
setShowSpaceLogin(res.space_login_enabled !== false);
|
||||
setShowPasskeyLogin(
|
||||
res.passkey_login_enabled !== false || Boolean(res.passkey_supported),
|
||||
);
|
||||
setLoading(false);
|
||||
|
||||
// Also check if already logged in
|
||||
@@ -184,6 +191,30 @@ export default function Login() {
|
||||
handleLogin(values.email, values.password);
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
setPasskeyLoading(true);
|
||||
try {
|
||||
const { options, challenge_token } =
|
||||
await httpClient.getPasskeyAuthOptions(
|
||||
undefined,
|
||||
window.location.origin,
|
||||
);
|
||||
const authResp = await startAuthentication({ optionsJSON: options });
|
||||
const res = await httpClient.verifyPasskeyAuth(challenge_token, authResp);
|
||||
if (await finishLogin(res.token, res.user)) {
|
||||
toast.success(t('common.passkeyLoginSuccess'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'NotAllowedError') {
|
||||
// User cancelled the biometric prompt
|
||||
} else {
|
||||
toast.error(error?.message || t('common.passkeyLoginFailed'));
|
||||
}
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogin(username: string, password: string) {
|
||||
httpClient
|
||||
.authUser(username, password)
|
||||
@@ -324,8 +355,27 @@ export default function Login() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPasskeyLogin && (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full cursor-pointer"
|
||||
onClick={handlePasskeyLogin}
|
||||
disabled={passkeyLoading}
|
||||
>
|
||||
{passkeyLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Fingerprint className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('common.loginWithPasskey')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider - only show if both login methods are available */}
|
||||
{showSpaceLogin && showLocalLogin && (
|
||||
{(showSpaceLogin || showPasskeyLogin) && showLocalLogin && (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
|
||||
@@ -7,12 +7,6 @@ import {
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
} from '@/components/ui/input-otp';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
@@ -28,14 +22,12 @@ import {
|
||||
import { useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Mail, Lock, ArrowLeft } from 'lucide-react';
|
||||
import { Mail, Lock, ArrowLeft, KeyRound } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
|
||||
const REGEXP_ONLY_DIGITS_AND_CHARS = /^[0-9a-zA-Z]+$/;
|
||||
|
||||
const formSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
email: z.string().email(t('common.invalidEmail')),
|
||||
@@ -136,28 +128,17 @@ export default function ResetPassword() {
|
||||
{t('resetPassword.recoveryKeyDescription')}
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={field.value}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS.source}
|
||||
onChange={(value) => {
|
||||
// 将输入的值转换为大写
|
||||
const upperValue = value.toUpperCase();
|
||||
field.onChange(upperValue);
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
{/* Recovery keys are case-sensitive base64url strings; send them verbatim */}
|
||||
<div className="relative">
|
||||
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={t('resetPassword.enterRecoveryKey')}
|
||||
className="pl-10 font-mono"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
/**
|
||||
* Copy text to clipboard with fallback support
|
||||
* Tries to use modern Clipboard API first, falls back to execCommand if not available
|
||||
*
|
||||
* @param text - The text to copy to clipboard
|
||||
* @returns Promise<boolean> - true if successful, false otherwise
|
||||
*/
|
||||
/** Copy text using the Clipboard API, with a focus-trap-safe legacy fallback. */
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// Try modern Clipboard API first
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
try {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Clipboard] Modern API failed, trying fallback:', err);
|
||||
// Fall through to legacy method
|
||||
}
|
||||
} catch {
|
||||
// Permission/security errors can include sensitive text; do not log them.
|
||||
}
|
||||
|
||||
// Fallback to legacy execCommand method
|
||||
const previousFocus = document.activeElement as HTMLElement | null;
|
||||
const textArea = document.createElement('textarea');
|
||||
try {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
// Radix modal focus scopes reject focus on elements appended to body.
|
||||
const container =
|
||||
previousFocus?.closest('[role="dialog"], [role="alertdialog"]') ??
|
||||
document.body;
|
||||
container.appendChild(textArea);
|
||||
textArea.focus({ preventScroll: true });
|
||||
textArea.select();
|
||||
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
return successful;
|
||||
} catch (err) {
|
||||
console.error('[Clipboard] Fallback method failed:', err);
|
||||
if (
|
||||
document.activeElement !== textArea ||
|
||||
textArea.selectionEnd !== text.length
|
||||
)
|
||||
return false;
|
||||
return document.execCommand('copy');
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
textArea.remove();
|
||||
if (previousFocus?.isConnected)
|
||||
previousFocus.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
+804
-551
File diff suppressed because it is too large
Load Diff
+16
-10
@@ -105,13 +105,16 @@ export function isRequiredRunnerConfigComplete(
|
||||
});
|
||||
}
|
||||
|
||||
export const LOCAL_AGENT_RUNNER_ID = 'plugin:langbot-team/LocalAgent/default';
|
||||
|
||||
export function configureLocalAgentPrimaryModel(
|
||||
config: Record<string, unknown>,
|
||||
modelUuid: string,
|
||||
): Record<string, unknown> {
|
||||
const aiConfig = (config.ai ?? {}) as Record<string, unknown>;
|
||||
const runnerConfig = (aiConfig.runner ?? {}) as Record<string, unknown>;
|
||||
const localAgentConfig = (aiConfig['local-agent'] ?? {}) as Record<
|
||||
const configs = (aiConfig.runner_config ?? {}) as Record<string, unknown>;
|
||||
const localAgentConfig = (configs[LOCAL_AGENT_RUNNER_ID] ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
@@ -121,15 +124,18 @@ export function configureLocalAgentPrimaryModel(
|
||||
...config,
|
||||
ai: {
|
||||
...aiConfig,
|
||||
runner: { ...runnerConfig, runner: 'local-agent' },
|
||||
'local-agent': {
|
||||
...localAgentConfig,
|
||||
model: {
|
||||
...modelConfig,
|
||||
primary: modelUuid,
|
||||
fallbacks: Array.isArray(modelConfig.fallbacks)
|
||||
? modelConfig.fallbacks
|
||||
: [],
|
||||
runner: { ...runnerConfig, id: LOCAL_AGENT_RUNNER_ID },
|
||||
runner_config: {
|
||||
...configs,
|
||||
[LOCAL_AGENT_RUNNER_ID]: {
|
||||
...localAgentConfig,
|
||||
model: {
|
||||
...modelConfig,
|
||||
primary: modelUuid,
|
||||
fallbacks: Array.isArray(modelConfig.fallbacks)
|
||||
? modelConfig.fallbacks
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user