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:
fdc310
2026-09-19 11:00:45 +08:00
357 changed files with 43727 additions and 6682 deletions
+15 -3
View File
@@ -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;
}
+30 -2
View File
@@ -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>
);
}
+34 -6
View File
@@ -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>
</>
@@ -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>
);
}
@@ -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,
};
}
+500 -436
View File
@@ -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;
}
+31 -6
View File
@@ -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;
}
@@ -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 || '';
+16 -1
View File
@@ -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 }[];
}
+19
View File
@@ -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[];
+192 -9
View File
@@ -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;
+51 -1
View File
@@ -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" />
+12 -31
View File
@@ -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>
+24 -24
View File
@@ -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 });
}
}
File diff suppressed because it is too large Load Diff
+16 -10
View File
@@ -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
: [],
},
},
},
},