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
: [],
},
},
},
},
+1 -1
View File
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
className,
)}
{...props}
+1
View File
@@ -9,6 +9,7 @@ const Progress = React.forwardRef<
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
value={value}
className={cn(
'relative h-2 w-full overflow-hidden rounded-full bg-primary/20',
className,
+104 -40
View File
@@ -1,3 +1,5 @@
import pipelineMigration from './pipeline-migration/en-US';
const enUS = {
sidebarGuide: {
label: 'Workspace tour',
@@ -172,6 +174,7 @@ const enUS = {
},
},
},
pipelineMigration,
sidebar: {
home: 'Home',
extensions: 'Extensions',
@@ -187,6 +190,7 @@ const enUS = {
editionCloud: 'Cloud',
},
common: {
customValue: 'Custom',
loadFailed: 'Failed to load. Please try again.',
login: 'Login',
logout: 'Logout',
@@ -256,7 +260,8 @@ const enUS = {
privacyPolicy: 'Privacy Policy',
and: 'and',
dataCollectionPolicy: 'Data Collection Policy',
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
dataCollectionPolicyUrl:
'https://langbot.app/docs/en/insight/data-collection-policy',
loading: 'Loading...',
fieldRequired: 'This field is required',
or: 'or',
@@ -265,6 +270,10 @@ const enUS = {
'Recommended: Use official stable model APIs and cloud services',
loginLocal: 'Login with local account',
loginWithPassword: 'Login with password',
loginWithPasskey: 'Sign in with Passkey',
passkeyLoginSuccess: 'Passkey verified successfully, signing in...',
passkeyLoginFailed: 'Failed to sign in with Passkey',
passkeyNotSupported: 'Passkey is not supported on this browser or device',
spaceLoginTitle: 'Login with LangBot Account',
spaceLoginDescription:
'Scan the QR code or visit the link below to authorize',
@@ -361,6 +370,37 @@ const enUS = {
help: 'Get Help',
},
models: {
codex: {
account: 'ChatGPT subscription',
description:
'Sign in with your ChatGPT account. Subscription access is separate from OpenAI API billing; model availability and usage limits depend on your plan.',
disconnected: 'Not connected',
loading: 'Checking connection…',
starting: 'Starting sign-in…',
pending: 'Waiting for authorization',
connected: 'Connected',
expired: 'Sign-in expired. Start again to get a new code.',
error: 'Unable to sign in. Check your connection and try again.',
canceling: 'Canceling sign-in…',
saveAndSignIn: 'Save and sign in',
done: 'Done',
instructions:
'Enter this code on the OpenAI page. Keep this dialog open until sign-in completes.',
copyCode: 'Copy code',
copied: 'Copied',
copyManually: 'Select and copy the code manually.',
continueAtOpenAI: 'Continue at OpenAI',
expiresAt: 'Code expires at {{time}}.',
retrying: 'Connection interrupted. Retrying automatically…',
cancelSignIn: 'Cancel sign-in',
tryAgain: 'Try again',
signIn: 'Sign in',
reconnect: 'Reconnect',
disconnect: 'Disconnect',
disconnectConfirm:
'Disconnect this provider? Its models will stop working until you sign in again. This does not cancel your ChatGPT subscription.',
confirmDisconnect: 'Confirm disconnect',
},
title: 'Models',
description: 'Configure and manage models that can be used in pipelines',
createModel: 'Create Model',
@@ -397,7 +437,7 @@ const enUS = {
reasoningAbility: 'Reasoning',
reasoningLevel: 'Reasoning level',
reasoningLevels: {
providerDefault: 'Provider default',
providerDefault: 'Use provider default',
disabled: 'Off',
enabled: 'On',
minimal: 'Minimal',
@@ -509,6 +549,8 @@ const enUS = {
providerSaveError: 'Failed to save provider: ',
providerDeleted: 'Provider deleted',
providerDeleteError: 'Failed to delete provider: ',
deleteProviderCascadeConfirmation:
'Delete this provider and ALL models it contains? This action is irreversible and cannot be undone.',
deleteProviderConfirmation:
'Are you sure you want to delete this provider?',
loadError: 'Failed to load data',
@@ -928,6 +970,17 @@ const enUS = {
},
},
agents: {
monitoring: {
description:
'Follow each task from its triggering event through model output and tool execution.',
empty:
'No runs yet. Trigger a platform event or run a debug test to see it here.',
input: 'Triggering input',
eventData: 'Event data',
execution: 'Execution steps',
rawEvents: 'Raw run events',
inputUnavailable: 'Input was not recorded for this run.',
},
eventProcessor: {
configurations: 'Plugin processor configurations',
configTab: 'Configuration',
@@ -966,6 +1019,7 @@ const enUS = {
loadMore: 'Load more',
activation:
'Install a plugin, create a processor configuration, then bind a bot.',
status_timeout: 'Timed out',
status_pending: 'Pending',
status_running: 'Running',
status_completed: 'Completed',
@@ -1588,6 +1642,15 @@ const enUS = {
connectionSuccess: 'Connection successful',
connectionFailed: 'Connection failed, please check URL',
connectionFailedStatus: 'Connection Failed',
connectionUnreachable:
'Cannot reach the MCP server. Check that it is running and accessible.',
connectionTimeout:
'The MCP server did not respond in time. Check the service or increase the timeout.',
connectionHttpError:
'The MCP server returned HTTP {{status}}. Check its access requirements and server logs.',
oauthAuthorizationRequired: 'OAuth authorization required',
oauthAuthorizationRequiredSuggestion:
'This MCP server requires OAuth sign-in. OAuth sign-in is not available yet; add an Authorization header manually if the server supports it.',
boxDisabledStdioRefused:
'Stdio MCP servers require the Box sandbox, which is disabled in config (box.enabled = false).',
boxUnavailableStdioRefused:
@@ -2066,6 +2129,20 @@ const enUS = {
bindSpaceWarning:
'After binding, your login email will be changed from {{localEmail}} to the LangBot Account email.',
bindSpaceSuccess: 'LangBot Account bound successfully',
passkeySectionTitle: 'Passkeys',
passkeySectionDesc:
'Sign in securely without passwords using biometrics or security keys',
addPasskey: 'Add Passkey',
passkeyName: 'Key Name',
passkeyNamePlaceholder: 'e.g., MacBook Touch ID, YubiKey',
passkeyCreated: 'Created on {{date}}',
passkeyLastUsed: 'Last used: {{date}}',
noPasskeys: 'No passkeys registered yet',
deletePasskeyConfirm:
'Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.',
passkeyAddedSuccess: 'Passkey added successfully',
passkeyDeleteSuccess: 'Passkey deleted',
passkeyRenameSuccess: 'Passkey renamed successfully',
bindSpaceFailed: 'Failed to bind LangBot Account',
bindSpaceInvalidState:
'Invalid bind request. Please try again from account settings.',
@@ -2371,7 +2448,16 @@ const enUS = {
queryVariables: {
title: 'Query Variables',
},
loadError: 'Failed to load monitoring data',
partialMessages:
'Showing {{shown}} of {{total}} messages. Conversation traces may be incomplete.',
partialModelCalls: 'Showing {{shown}} of {{total}} model calls.',
partialToolCalls:
'Showing {{shown}} of {{total}} tool calls. Conversation traces may be incomplete.',
partialErrors: 'Showing {{shown}} of {{total}} errors.',
trafficChart: {
unavailable: 'Traffic aggregation unavailable',
truncated: 'Traffic range truncated. Choose a shorter time range.',
title: 'Traffic Overview',
messages: 'Messages',
llmCalls: 'LLM Calls',
@@ -2626,7 +2712,7 @@ const enUS = {
next: 'Next',
finish: 'Create & Deploy',
confirmCreateBot: 'Confirm, Create Bot',
createSuccess: 'Processor created and linked to the bot successfully!',
createSuccess: 'Pipeline created and linked to bot successfully!',
botCreateSuccess: 'Bot created successfully!',
botSaveSuccess: 'Bot configuration saved and enabled!',
createError: 'Failed to create resources',
@@ -2634,42 +2720,13 @@ const enUS = {
completeSaveError: 'Failed to save completion status. Please try again.',
step: {
platform: 'Platform',
scenarioChannel: 'Scenario & Channel',
botConfig: 'Bot Setup',
aiEngine: 'AI Engine',
done: 'Done',
},
scenario: {
title: 'What should this bot do?',
description:
'Start with one outcome. You can add more behaviors after the bot is created.',
messageReply: 'Reply to messages',
messageReplyDescription:
'Answer incoming private or group messages with an AI Pipeline.',
welcomeMembers: 'Welcome new members',
welcomeMembersDescription: 'Run an Agent when someone joins a group.',
welcomeMembersPrompt:
'Welcome new group members with a short, friendly message. Use the available member and group context when present. Do not mention internal event names or system details.',
handleDepartures: 'Handle member departures',
handleDeparturesDescription:
'Run an Agent when someone leaves or is removed.',
handleDeparturesPrompt:
'Respond to group member departures with a brief, respectful message when a public response is appropriate. Do not speculate about why the member left or mention internal event names.',
handleModeration: 'Handle moderation events',
handleModerationDescription:
'Run an Agent when a group member is restricted.',
handleModerationPrompt:
'Write a concise, neutral group notice about the member restriction using only the available context. Do not invent details or mention internal event names.',
pipelineBadge: 'Pipeline',
agentBadge: 'Agent',
},
platform: {
title: 'Select a Channel',
description:
'Only channels that support the selected scenario are shown.',
chooseScenarioFirst: 'Choose a scenario to see compatible channels.',
noCompatiblePlatforms:
'No installed channel currently supports this scenario.',
title: 'Select a Platform',
description: 'Choose the messaging platform your bot will connect to.',
},
botConfig: {
title: 'Configure Your Bot',
@@ -2684,6 +2741,8 @@ const enUS = {
'The bot received an IM message. You can continue to the next step.',
messageReceivedLocalAccountWarning:
'The bot-side connection is configured correctly and received an IM message. Because you are not signed in with a LangBot Account, model calls may fail; continue to the next step to add your own model.',
pageBotPreviewFailed:
'Failed to load the test chat. Please save the configuration again to retry.',
pageBotTestPrompt:
'Page Bot is enabled. Click the chat bubble in the lower-right corner and send a message to verify the full conversation flow.',
pageBotTestNotice:
@@ -2704,14 +2763,18 @@ const enUS = {
'Monitor bot activity to verify the platform connection is working.',
},
aiEngine: {
defaultModelUnavailable:
'No default chat model is available. Please retry.',
defaultRunnerUnavailable: 'Local Agent is not ready. Please retry.',
preparingDefault: 'Preparing default AI…',
title: 'Select an AI Engine',
description:
"Choose the AI engine that will power your bot's intelligence.",
optionalDescription:
'This step is optional. Choose how you want to continue with the current agent.',
'The default AI is ready. Keep it or choose another way to connect.',
externalTitle: 'Connect an External Agent',
externalDescription:
'Connect Dify, n8n, Coze, or another platform and replace the bot pipeline.',
'Connect Dify, n8n, Coze, or another external Agent through a Runner plugin.',
ownModelTitle: 'Use My Own Model',
ownModelDescription:
'Add a provider, then scan or manually enter a model to finish setup.',
@@ -2724,7 +2787,7 @@ const enUS = {
selectModelTitle: 'Choose a Model',
selectScannedModelTitle: 'Choose a Model',
selectScannedModelDescription:
'The selected model will be the primary model of a new pipeline, and the bot will switch to it.',
'The selected model will replace the primary model in this bot’s pipeline.',
scanModelMode: 'Scan Models',
manualModelMode: 'Add Manually',
scanningModels: 'Scanning available models…',
@@ -2743,12 +2806,13 @@ const enUS = {
manualModelOptions: 'Optional Model Capabilities',
editProvider: 'Edit provider',
rescanModels: 'Scan models again',
moreFeaturesTitle: 'Add More Agent Features',
moreFeaturesTitle: 'Use the default setup',
moreFeaturesDescription:
'Open the workbench to add tools, knowledge bases, and other capabilities to the Agent that was just generated automatically.',
'Keep Local Agent and the recommended model. You can add tools and knowledge bases later.',
runnerDescription:
'Select a runner for the external agent and configure its connection.',
backToChoices: 'Back to options',
backToList: 'Back to list',
createExternal: 'Create and Bind',
finishWithModel: 'Use Selected Model & Finish',
openWorkbench: 'Open Workbench',
@@ -2783,7 +2847,7 @@ const enUS = {
done: {
title: 'All Set!',
description:
'Your bot has been created and connected to its processor. You can now manage it from the workbench.',
'Your bot has been created and connected to its pipeline. You can now manage it from the workbench.',
backToWorkbench: 'Back to Workbench',
},
},
+104 -7
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/es-ES';
const esES = {
pipelineMigration,
sidebar: {
home: 'Inicio',
extensions: 'Extensiones',
@@ -15,6 +18,7 @@ const esES = {
editionCloud: 'Cloud',
},
common: {
customValue: 'Personalizado',
login: 'Iniciar sesión',
logout: 'Cerrar sesión',
accountOptions: 'Configuración',
@@ -79,7 +83,8 @@ const esES = {
privacyPolicy: 'Política de privacidad',
and: 'y',
dataCollectionPolicy: 'Política de recopilación de datos',
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
dataCollectionPolicyUrl:
'https://langbot.app/docs/en/insight/data-collection-policy',
loading: 'Cargando...',
fieldRequired: 'Este campo es obligatorio',
or: 'o',
@@ -88,6 +93,11 @@ const esES = {
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
loginLocal: 'Iniciar sesión con cuenta local',
loginWithPassword: 'Iniciar sesión con contraseña',
loginWithPasskey: 'Iniciar sesión con Passkey',
passkeyLoginSuccess: 'Passkey verificada con éxito, iniciando sesión...',
passkeyLoginFailed: 'Error al iniciar sesión con Passkey',
passkeyNotSupported:
'Passkey no es compatible en este navegador o dispositivo',
spaceLoginTitle: 'Iniciar sesión con una cuenta de LangBot',
spaceLoginDescription:
'Escanea el código QR o visita el enlace para autorizar',
@@ -186,6 +196,38 @@ const esES = {
help: 'Obtener ayuda',
},
models: {
codex: {
account: 'Suscripción de ChatGPT',
description:
'Inicia sesión con tu cuenta de ChatGPT. La suscripción es independiente de la facturación de la API de OpenAI; los modelos y límites dependen de tu plan.',
disconnected: 'Sin conexión',
loading: 'Comprobando conexión…',
starting: 'Iniciando sesión…',
pending: 'Esperando autorización',
connected: 'Conectado',
expired: 'El inicio de sesión ha caducado. Solicita un nuevo código.',
error:
'No se pudo iniciar sesión. Comprueba la conexión e inténtalo de nuevo.',
canceling: 'Cancelando inicio de sesión…',
saveAndSignIn: 'Guardar e iniciar sesión',
done: 'Listo',
instructions:
'Introduce este código en la página de OpenAI. Mantén este diálogo abierto hasta completar el inicio de sesión.',
copyCode: 'Copiar código',
copied: 'Copiado',
copyManually: 'Selecciona y copia el código manualmente.',
continueAtOpenAI: 'Continuar en OpenAI',
expiresAt: 'El código caduca a las {{time}}.',
retrying: 'Conexión interrumpida. Reintentando automáticamente…',
cancelSignIn: 'Cancelar inicio de sesión',
tryAgain: 'Reintentar',
signIn: 'Iniciar sesión',
reconnect: 'Reconectar',
disconnect: 'Desconectar',
disconnectConfirm:
'¿Desconectar este proveedor? Sus modelos dejarán de funcionar hasta que vuelvas a iniciar sesión. Esto no cancela tu suscripción de ChatGPT.',
confirmDisconnect: 'Confirmar desconexión',
},
title: 'Modelos',
description:
'Configura y gestiona los modelos que se pueden usar en los Pipelines',
@@ -223,7 +265,7 @@ const esES = {
reasoningAbility: 'Razonamiento',
reasoningLevel: 'Nivel de razonamiento',
reasoningLevels: {
providerDefault: 'Predeterminado del proveedor',
providerDefault: 'Usar valor predeterminado del proveedor',
disabled: 'Desactivado',
enabled: 'Activado',
minimal: 'Mínimo',
@@ -338,6 +380,8 @@ const esES = {
providerSaveError: 'Error al guardar el proveedor: ',
providerDeleted: 'Proveedor eliminado',
providerDeleteError: 'Error al eliminar el proveedor: ',
deleteProviderCascadeConfirmation:
'¿Eliminar este proveedor y TODOS los modelos que contiene? Esta acción es irreversible y no se puede deshacer.',
deleteProviderConfirmation:
'¿Estás seguro de que deseas eliminar este proveedor?',
loadError: 'Error al cargar datos',
@@ -566,6 +610,17 @@ const esES = {
},
},
agents: {
monitoring: {
description:
'Consulta el evento, la salida del modelo y las herramientas de cada tarea.',
empty:
'Sin ejecuciones. Activa un evento o ejecuta una prueba de depuración.',
input: 'Entrada inicial',
eventData: 'Datos del evento',
execution: 'Pasos de ejecución',
rawEvents: 'Eventos sin procesar',
inputUnavailable: 'No se registró la entrada de esta ejecución.',
},
eventProcessor: {
configurations: 'Configuraciones de procesadores de plugins',
configTab: 'Configuración',
@@ -604,6 +659,7 @@ const esES = {
loadMore: 'Cargar más',
activation:
'Instala un plugin, crea una configuración de procesador y vincula un bot.',
status_timeout: 'Tiempo agotado',
status_pending: 'Pendiente',
status_running: 'En ejecución',
status_completed: 'Completado',
@@ -1053,6 +1109,15 @@ const esES = {
connectionSuccess: 'Conexión exitosa',
connectionFailed: 'Error de conexión, por favor verifica la URL',
connectionFailedStatus: 'Conexión fallida',
connectionUnreachable:
'No se puede acceder al servidor MCP. Compruebe que esté iniciado y accesible.',
connectionTimeout:
'El servidor MCP no respondió a tiempo. Compruebe el servicio o aumente el tiempo de espera.',
connectionHttpError:
'El servidor MCP devolvió HTTP {{status}}. Compruebe los requisitos de acceso y los registros del servidor.',
oauthAuthorizationRequired: 'Se requiere autorización OAuth',
oauthAuthorizationRequiredSuggestion:
'Este servidor MCP requiere inicio de sesión con OAuth. Aún no está disponible; agregue manualmente un encabezado Authorization si el servidor lo permite.',
boxDisabledStdioRefused:
'Los servidores MCP en modo stdio requieren el sandbox de Box, desactivado en la configuración (box.enabled = false).',
boxUnavailableStdioRefused:
@@ -1529,6 +1594,20 @@ const esES = {
bindSpaceWarning:
'Después de vincular, tu correo de inicio de sesión se cambiará de {{localEmail}} al correo de la cuenta de LangBot.',
bindSpaceSuccess: 'Cuenta de LangBot vinculada correctamente',
passkeySectionTitle: 'Llaves de acceso (Passkeys)',
passkeySectionDesc:
'Inicia sesión de forma segura sin contraseñas usando biometría o llaves de seguridad',
addPasskey: 'Añadir llave de acceso',
passkeyName: 'Nombre de la llave',
passkeyNamePlaceholder: 'p. ej., MacBook Touch ID, YubiKey',
passkeyCreated: 'Creada el {{date}}',
passkeyLastUsed: 'Último uso: {{date}}',
noPasskeys: 'No hay llaves de acceso registradas',
deletePasskeyConfirm:
'¿Seguro que deseas eliminar esta llave de acceso? Ya no podrás usarla para iniciar sesión.',
passkeyAddedSuccess: 'Llave de acceso añadida con éxito',
passkeyDeleteSuccess: 'Llave de acceso eliminada',
passkeyRenameSuccess: 'Nombre de llave de acceso modificado con éxito',
bindSpaceFailed: 'Error al vincular la cuenta de LangBot',
bindSpaceInvalidState:
'Solicitud de vinculación no válida. Por favor, inténtalo de nuevo desde la configuración de la cuenta.',
@@ -1755,7 +1834,17 @@ const esES = {
queryVariables: {
title: 'Variables de consulta',
},
loadError: 'No se pudieron cargar los datos de monitoreo',
partialMessages:
'Se muestran {{shown}} de {{total}} mensajes. Las trazas de conversación pueden estar incompletas.',
partialModelCalls: 'Se muestran {{shown}} de {{total}} llamadas al modelo.',
partialToolCalls:
'Se muestran {{shown}} de {{total}} llamadas a herramientas. Las trazas de conversación pueden estar incompletas.',
partialErrors: 'Se muestran {{shown}} de {{total}} errores.',
trafficChart: {
unavailable: 'Agregación de tráfico no disponible',
truncated:
'Rango de tráfico truncado. Selecciona un intervalo más corto.',
title: 'Resumen de tráfico',
messages: 'Mensajes',
llmCalls: 'Llamadas LLM',
@@ -1900,6 +1989,8 @@ const esES = {
'El Bot recibió un mensaje. Puedes continuar al siguiente paso.',
messageReceivedLocalAccountWarning:
'La conexión del Bot está configurada correctamente y recibió un mensaje. Como no has iniciado sesión con una cuenta de LangBot, las llamadas al modelo pueden fallar; continúa al siguiente paso para añadir tu propio modelo.',
pageBotPreviewFailed:
'No se pudo cargar el chat de prueba. Guarda la configuración de nuevo para reintentar.',
pageBotTestPrompt:
'El Bot de página está activado. Haz clic en la burbuja de chat de la esquina inferior derecha y envía un mensaje para verificar el flujo completo de la conversación.',
pageBotTestNotice:
@@ -1920,14 +2011,19 @@ const esES = {
'Monitorea la actividad del Bot para verificar que la conexión con la plataforma funcione.',
},
aiEngine: {
defaultModelUnavailable:
'No hay un modelo predeterminado disponible. Inténtalo de nuevo.',
defaultRunnerUnavailable:
'Local Agent aún no está listo. Inténtalo de nuevo.',
preparingDefault: 'Preparando la IA predeterminada…',
title: 'Configura el motor de IA',
description:
'Elige el motor de IA que impulsará la inteligencia de tu Bot.',
optionalDescription:
'Este paso es opcional. Elige cómo quieres continuar con el Agent actual.',
'La IA ya está configurada. Puedes usarla o cambiar la conexión.',
externalTitle: 'Conectar un Agent de una plataforma externa',
externalDescription:
'Conecta Dify, n8n, Coze u otra plataforma y sustituye el Pipeline del Bot.',
'Conecta Dify, n8n, Coze u otro Agent externo mediante un plugin Runner.',
ownModelTitle: 'Usar mi propio modelo',
ownModelDescription:
'Añade un proveedor y luego escanea o introduce manualmente un modelo para completar la configuración.',
@@ -1940,7 +2036,7 @@ const esES = {
selectModelTitle: 'Elige un modelo',
selectScannedModelTitle: 'Elige un modelo',
selectScannedModelDescription:
'El modelo seleccionado será el modelo principal de un nuevo Pipeline y el Bot cambiará a él.',
'El modelo seleccionado sustituirá al modelo principal del pipeline de este bot.',
scanModelMode: 'Detectar modelos',
manualModelMode: 'Añadir manualmente',
scanningModels: 'Detectando modelos disponibles…',
@@ -1959,12 +2055,13 @@ const esES = {
manualModelOptions: 'Capacidades opcionales del modelo',
editProvider: 'Editar proveedor',
rescanModels: 'Volver a detectar modelos',
moreFeaturesTitle: 'Añadir más funciones al Agent',
moreFeaturesTitle: 'Usar la configuración predeterminada',
moreFeaturesDescription:
'Abre el área de trabajo para añadir herramientas, bases de conocimiento y otras capacidades al Agent que se acaba de generar automáticamente.',
'Usa Local Agent y el modelo recomendado. Podrás añadir herramientas y bases de conocimiento después.',
runnerDescription:
'Selecciona un Runner para el Agent externo y configura su conexión.',
backToChoices: 'Volver a las opciones',
backToList: 'Volver a la lista',
createExternal: 'Crear y vincular',
finishWithModel: 'Usar el modelo seleccionado y finalizar',
openWorkbench: 'Abrir área de trabajo',
+109 -41
View File
@@ -1,3 +1,5 @@
import pipelineMigration from './pipeline-migration/ja-JP';
const jaJP = {
sidebarGuide: {
label: 'ワークスペースツアー',
@@ -160,6 +162,7 @@ const jaJP = {
},
},
},
pipelineMigration,
sidebar: {
home: 'ホーム',
extensions: '拡張機能',
@@ -175,6 +178,7 @@ const jaJP = {
editionCloud: 'Cloud',
},
common: {
customValue: 'カスタム',
loadFailed: '読み込みに失敗しました。再試行してください。',
login: 'ログイン',
logout: 'ログアウト',
@@ -245,7 +249,8 @@ const jaJP = {
privacyPolicy: 'プライバシーポリシー',
and: 'および',
dataCollectionPolicy: 'データ収集ポリシー',
dataCollectionPolicyUrl: 'https://link.langbot.app/ja/docs/data-policy',
dataCollectionPolicyUrl:
'https://langbot.app/docs/ja/insight/data-collection-policy',
loading: '読み込み中...',
fieldRequired: 'この項目は必須です',
or: 'または',
@@ -254,6 +259,11 @@ const jaJP = {
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
loginLocal: 'ローカルアカウントでログイン',
loginWithPassword: 'パスワードでログイン',
loginWithPasskey: 'パスキーでログイン',
passkeyLoginSuccess: 'パスキーの認証に成功しました。ログイン中...',
passkeyLoginFailed: 'パスキーでのログインに失敗しました',
passkeyNotSupported:
'お使いのブラウザまたはデバイスはパスキーをサポートしていません',
spaceLoginTitle: 'LangBot アカウントでログイン',
spaceLoginDescription:
'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
@@ -352,6 +362,38 @@ const jaJP = {
help: 'ヘルプドキュメントを見る',
},
models: {
codex: {
account: 'ChatGPT サブスクリプション',
description:
'ChatGPT アカウントでログインします。サブスクリプションと OpenAI API の課金は別です。利用可能なモデルと使用制限はプランによって異なります。',
disconnected: '未接続',
loading: '接続を確認中…',
starting: 'ログインを開始中…',
pending: '認証を待機中',
connected: '接続済み',
expired:
'ログインの有効期限が切れました。新しいコードを取得してください。',
error: 'ログインできません。接続を確認して再試行してください。',
canceling: 'ログインをキャンセル中…',
saveAndSignIn: '保存してログイン',
done: '完了',
instructions:
'OpenAI のページでこのコードを入力してください。ログインが完了するまでこの画面を開いたままにしてください。',
copyCode: 'コードをコピー',
copied: 'コピー済み',
copyManually: 'コードを選択して手動でコピーしてください。',
continueAtOpenAI: 'OpenAI で続行',
expiresAt: 'コードの有効期限: {{time}}',
retrying: '接続が切れました。自動的に再試行しています…',
cancelSignIn: 'ログインをキャンセル',
tryAgain: '再試行',
signIn: 'ログイン',
reconnect: '再接続',
disconnect: '切断',
disconnectConfirm:
'このプロバイダーを切断しますか?再ログインするまでモデルは使用できません。ChatGPT のサブスクリプションは解約されません。',
confirmDisconnect: '切断を確認',
},
title: 'モデル設定',
description: 'パイプラインで使用できるモデルを設定・管理',
createModel: 'モデルを作成',
@@ -388,7 +430,7 @@ const jaJP = {
reasoningAbility: '推論',
reasoningLevel: '推論レベル',
reasoningLevels: {
providerDefault: 'Provider デフォルト',
providerDefault: 'プロバイダーの既定値を使用',
disabled: 'オフ',
enabled: 'オン',
minimal: '最小',
@@ -503,6 +545,8 @@ const jaJP = {
providerSaveError: 'プロバイダーの保存に失敗しました:',
providerDeleted: 'プロバイダーを削除しました',
providerDeleteError: 'プロバイダーの削除に失敗しました:',
deleteProviderCascadeConfirmation:
'このプロバイダーと、その中のすべてのモデルを削除しますか?この操作は取り消せず、元に戻せません。',
deleteProviderConfirmation: 'このプロバイダーを削除してもよろしいですか?',
loadError: 'データの読み込みに失敗しました',
chat: 'チャット',
@@ -927,6 +971,17 @@ const jaJP = {
},
},
agents: {
monitoring: {
description:
'各タスクのトリガーイベント、モデル出力、ツール実行を確認します。',
empty:
'実行記録はありません。プラットフォームイベントまたはデバッグテストを実行してください。',
input: 'トリガー入力',
eventData: 'イベントデータ',
execution: '実行過程',
rawEvents: '生の実行イベント',
inputUnavailable: 'この実行の入力は記録されていません。',
},
eventProcessor: {
configurations: 'プラグインプロセッサー設定',
configTab: '設定',
@@ -966,6 +1021,7 @@ const jaJP = {
loadMore: 'さらに読み込む',
activation:
'プラグインをインストールし、プロセッサー設定を作成してボットに紐付けます。',
status_timeout: 'タイムアウト',
status_pending: '待機中',
status_running: '実行中',
status_completed: '完了',
@@ -1493,6 +1549,15 @@ const jaJP = {
connectionSuccess: '接続に成功しました',
connectionFailed: '接続に失敗しました,URLを確認してください',
connectionFailedStatus: '接続失敗',
connectionUnreachable:
'MCP サーバーに接続できません。起動状態とネットワークを確認してください。',
connectionTimeout:
'MCP サーバーの応答がタイムアウトしました。サービスを確認するか、待機時間を延長してください。',
connectionHttpError:
'MCP サーバーが HTTP {{status}} を返しました。アクセス要件とサーバーログを確認してください。',
oauthAuthorizationRequired: 'OAuth 認可が必要です',
oauthAuthorizationRequiredSuggestion:
'この MCP サーバーには OAuth ログインが必要です。現在は OAuth ログインに対応していません。サーバーが許可している場合は、Authorization ヘッダーを手動で追加してください。',
boxDisabledStdioRefused:
'Stdio モードの MCP サーバーは Box サンドボックスを必要としますが、設定で無効化されています(box.enabled = false)。',
boxUnavailableStdioRefused:
@@ -1972,6 +2037,20 @@ const jaJP = {
bindSpaceWarning:
'連携後、ログインメールアドレスは {{localEmail}} から LangBot アカウントのメールアドレスに変更されます。',
bindSpaceSuccess: 'LangBot アカウントの連携に成功しました',
passkeySectionTitle: 'パスキー (Passkey)',
passkeySectionDesc:
'生体認証やセキュリティキーを使って、パスワード不要で安全にログインします',
addPasskey: 'パスキーを追加',
passkeyName: 'キー名',
passkeyNamePlaceholder: '例: MacBook Touch ID、YubiKey',
passkeyCreated: '作成日: {{date}}',
passkeyLastUsed: '最終使用: {{date}}',
noPasskeys: '登録されているパスキーはありません',
deletePasskeyConfirm:
'このパスキーを削除してもよろしいですか?削除後はこのキーでのログインができなくなります。',
passkeyAddedSuccess: 'パスキーが正常に追加されました',
passkeyDeleteSuccess: 'パスキーを削除しました',
passkeyRenameSuccess: 'パスキー名を変更しました',
bindSpaceFailed: 'LangBot アカウントの連携に失敗しました',
bindSpaceInvalidState:
'無効な連携リクエストです。アカウント設定から再度お試しください。',
@@ -2280,7 +2359,17 @@ const jaJP = {
queryVariables: {
title: 'クエリ変数',
},
loadError: 'モニタリングデータを読み込めませんでした',
partialMessages:
'全 {{total}} 件中 {{shown}} 件のメッセージを表示。会話トレースは不完全な場合があります。',
partialModelCalls: '全 {{total}} 件中 {{shown}} 件のモデル呼び出しを表示。',
partialToolCalls:
'全 {{total}} 件中 {{shown}} 件のツール呼び出しを表示。会話トレースは不完全な場合があります。',
partialErrors: '全 {{total}} 件中 {{shown}} 件のエラーを表示。',
trafficChart: {
unavailable: 'トラフィック集計を利用できません',
truncated:
'トラフィック範囲が切り詰められています。短い期間を選択してください。',
title: 'トラフィック概要',
messages: 'メッセージ',
llmCalls: 'LLM呼び出し',
@@ -2390,7 +2479,7 @@ const jaJP = {
next: '次へ',
finish: '作成&デプロイ',
confirmCreateBot: '確定、ボットを作成',
createSuccess: 'プロセッサーが作成され、ボットにリンクされました!',
createSuccess: 'パイプラインが作成され、ボットにリンクされました!',
botCreateSuccess: 'ボットが正常に作成されました!',
botSaveSuccess: 'ボット設定が保存され、有効になりました!',
createError: 'リソースの作成に失敗しました',
@@ -2398,43 +2487,14 @@ const jaJP = {
completeSaveError: '完了状態の保存に失敗しました。もう一度お試しください。',
step: {
platform: 'プラットフォーム',
scenarioChannel: 'シナリオとチャンネル',
botConfig: 'ボット設定',
aiEngine: 'AIエンジン',
done: '完了',
},
scenario: {
title: 'このボットで何を実現しますか?',
description:
'まず主要な動作を1つ選びます。作成後に他の動作も追加できます。',
messageReply: '受信メッセージに返信',
messageReplyDescription:
'AI Pipeline でプライベートまたはグループメッセージに返信します。',
welcomeMembers: '新しいメンバーを歓迎',
welcomeMembersDescription:
'メンバーがグループに参加したときに Agent を実行します。',
welcomeMembersPrompt:
'新しいグループメンバーを短く親しみやすいメッセージで歓迎してください。利用可能なメンバーとグループの情報を活用し、内部イベント名やシステムの詳細には言及しないでください。',
handleDepartures: 'メンバーの退出を処理',
handleDeparturesDescription:
'メンバーが退出または削除されたときに Agent を実行します。',
handleDeparturesPrompt:
'公開の応答が適切な場合に、メンバーの退出へ短く敬意のあるメッセージで対応してください。退出理由を推測したり、内部イベント名に言及したりしないでください。',
handleModeration: 'モデレーションイベントを処理',
handleModerationDescription:
'グループメンバーが制限されたときに Agent を実行します。',
handleModerationPrompt:
'利用可能な情報だけを使い、メンバーへの制限について簡潔で中立的なグループ通知を書いてください。詳細を作り上げたり、内部イベント名に言及したりしないでください。',
pipelineBadge: 'Pipeline',
agentBadge: 'Agent',
},
platform: {
title: 'チャンネルを選択',
description: '選択したシナリオに対応するチャンネルのみ表示されます。',
chooseScenarioFirst:
'シナリオを選択すると、対応するチャンネルが表示されます。',
noCompatiblePlatforms:
'現在インストールされているチャンネルはこのシナリオに対応していません。',
title: 'プラットフォームを選択',
description:
'ボットが接続するメッセージングプラットフォームを選択してください。',
},
botConfig: {
title: 'ボットを設定',
@@ -2450,6 +2510,8 @@ const jaJP = {
'ボットが IM メッセージを受信しました。次のステップに進めます。',
messageReceivedLocalAccountWarning:
'ボット側の接続設定は正常で、IM メッセージを受信できています。LangBot Account でログインしていないためモデル呼び出しが失敗する場合がありますが、次のステップで独自のモデルを追加できます。',
pageBotPreviewFailed:
'テストチャットを読み込めませんでした。設定を再保存してお試しください。',
pageBotTestPrompt:
'ページボットが有効になりました。右下のチャットバブルをクリックしてメッセージを送信し、会話フロー全体を確認してください。',
pageBotTestNotice:
@@ -2470,14 +2532,19 @@ const jaJP = {
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
},
aiEngine: {
defaultModelUnavailable:
'既定の会話モデルがありません。再試行してください。',
defaultRunnerUnavailable:
'ローカル Agent が準備できていません。再試行してください。',
preparingDefault: '既定の AI を準備中…',
title: 'AIエンジンを選択',
description:
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
optionalDescription:
'このステップは任意です。現在の Agent をどのように設定するか選択してください。',
'既定の AI は設定済みです。そのまま使うか、接続方法を変更できます。',
externalTitle: '外部プラットフォームの Agent を接続',
externalDescription:
'Dify、n8n、Coze などを接続し、ボットのパイプラインを置き換えます。',
'ランナープラグインで Dify、n8n、Coze などの外部 Agent に接続します。',
ownModelTitle: '自分のモデルを使用',
ownModelDescription:
'プロバイダーを追加し、モデルをスキャンまたは手動入力して設定を完了します。',
@@ -2490,7 +2557,7 @@ const jaJP = {
selectModelTitle: 'モデルを選択',
selectScannedModelTitle: 'モデルを選択',
selectScannedModelDescription:
'選択したモデルを新しいパイプラインのメインモデルに設定し、ボットをそのパイプラインへ切り替えます。',
'選択したモデルを現在のパイプラインのメインモデルに設定します。',
scanModelMode: 'モデルをスキャン',
manualModelMode: '手動で追加',
scanningModels: '利用可能なモデルをスキャン中…',
@@ -2509,11 +2576,12 @@ const jaJP = {
manualModelOptions: '任意のモデル機能',
editProvider: 'プロバイダーを編集',
rescanModels: 'モデルを再スキャン',
moreFeaturesTitle: 'Agent に機能を追加',
moreFeaturesTitle: '既定の設定を使う',
moreFeaturesDescription:
'ワークベンチを開き、自動生成されたばかりの Agent にツール、ナレッジベースなどの機能を追加します。',
'ローカル Agent と推奨モデルを使います。ツールやナレッジベースは後から追加できます。',
runnerDescription: '外部 Agent の Runner を選択し、接続を設定します。',
backToChoices: '選択肢に戻る',
backToList: '一覧に戻る',
createExternal: '作成して関連付ける',
finishWithModel: '選択したモデルを使用して完了',
openWorkbench: 'ワークベンチを開く',
@@ -2548,7 +2616,7 @@ const jaJP = {
done: {
title: '完了しました!',
description:
'ボットが作成され、プロセッサーに接続されました。ワークベンチから管理できます。',
'ボットが作成され、パイプラインに接続されました。ワークベンチから管理できます。',
backToWorkbench: 'ワークベンチに戻る',
},
},
@@ -0,0 +1,128 @@
export default {
complete: 'Done',
checkingPlugin: 'Check plugin',
preparingPlugin: 'Prepare installation',
installDetails: 'Installation details',
stageProgress: 'Stage progress',
downloaded: 'Downloaded {{size}}',
processed: 'Processed {{completed}} / {{total}} pipelines',
installErrors: {
plugin_version_unavailable:
'The required plugin version is not available in the marketplace. Retry after it is published, or migrate data only.',
plugin_download_timeout:
'Plugin download timed out. Check the network and retry.',
plugin_marketplace_unavailable:
'The marketplace could not serve the plugin package. Retry later.',
plugin_download_failed:
'Could not download the plugin. Check the network and retry.',
dependency_prepare_failed:
'Plugin dependencies could not be installed. Check the runtime environment and retry.',
plugin_launch_failed:
'The plugin could not start. Check the runtime environment and retry.',
},
autoDescription:
'Legacy runners are now plugins. Migrate all pipelines while keeping your settings. Original configurations are backed up; conversations start fresh.',
viewPipelines: 'View pipelines',
autoInstall: 'Install plugins and migrate',
dataOnly: 'Migrate data only',
dataOnlyHint:
'For offline or private networks. Install the corresponding runner plugins yourself after migration.',
installing: 'Installing required plugins…',
migrating: 'Migrating pipelines…',
summary: '{{migrated}} migrated; {{remaining}} need attention.',
installFailed:
'Plugin installation failed. Expand the details to see the failed stage, then retry or migrate data only.',
activationRetryHint:
'After checking the runtime, refresh, select this pipeline and confirm to retry activation only. Its saved configuration will not be migrated again.',
details: 'Migration details',
notices: {
runtimeUnavailable:
'The plugin runtime is disconnected. Restore the connection and retry, or migrate data only.',
executionFailed: 'Migration failed. Check the server logs and retry.',
pluginRequired:
'Install or enable the runner plugin shown above, then refresh the preview.',
legacyArchive:
'The active configuration will contain only the selected runner. All original runner settings, including inactive ones, are kept in the migration backup.',
contextDefaults:
'History will use the new context budget and summarization defaults instead of a fixed round limit.',
modelReasoning:
'Per-model reasoning settings are preserved and applied by the host.',
serialTools: 'Tool calls remain sequential after migration.',
retrievalDefaults:
'Retrieval uses the new top-k and result limits. Review them after migration.',
boxReset:
'Sandbox reuse settings are preserved. Existing container state is not migrated; new sandboxes follow the same reuse rules.',
persistentHistory:
'New conversations use persistent, isolated history. Existing remote history is not imported.',
tweaksDefault: 'Langflow tweaks default to an empty object.',
timeoutDefault: 'Dify uses a 30-second request timeout by default.',
historyReset:
'Existing conversation history and session identifiers are not transferred. New conversations start after migration.',
sessionTitle: 'New WeKnora sessions use titles generated by the plugin.',
aliasRepaired: 'The old field name is mapped to its supported replacement.',
nullDefault: 'This empty value uses the new plugin default.',
outputPolicy:
'The existing reasoning-output visibility setting is preserved.',
filteredVariables:
'Only supported variables are transferred; reserved variables are supplied by the conversation context.',
identityPreserved: 'The provider user identity keeps its legacy source.',
newDefaults:
'New plugin options use their documented defaults; existing supported values are retained.',
externalState:
'Complete or cancel pending remote interactions before migration; their live state is not transferred.',
pluginVersion:
'Install the required plugin version, then refresh. Older versions are not compatible with this migration.',
schemaChanged:
'The installed runner configuration does not match the migration target. Check the plugin version and refresh.',
runnerExcluded:
'This pipeline excludes the required runner plugin. Update its extension settings first.',
boxTemplateInvalid:
'Invalid sandbox reuse template. Use {variable_name}; positional fields, format conversions and attribute access are not supported.',
pendingInteraction:
'A conversation is waiting for input. Complete or cancel it before migration.',
},
title: 'Pipeline migration',
description:
'Select pipelines to migrate. Original settings are backed up; conversations start fresh after migration.',
detected: '{{count}} pipelines need migration review.',
review: 'Review migration',
readOnly: 'Only workspace managers can migrate pipelines.',
previewError: 'Preview unavailable. Refresh to check again.',
submitting: 'Submitting selected pipelines…',
running:
'Migration is running. Closing this dialog does not cancel the task.',
finished: 'Task finished. Check each pipeline result below.',
failed: 'Task failed. Check each pipeline result below.',
lost: 'Task observation lost. Its outcome is unknown; refresh the preview before any further action.',
requestError:
'Request not completed. Refresh the preview before selecting again.',
warningFallback: 'Review this setting before migration.',
blockerFallback:
'This setting or runtime condition cannot be migrated safely. Resolve it and refresh the preview.',
changedFields: 'Changed fields',
activationHint:
'Configuration was saved, but activation is pending. Ask a workspace administrator to check runtime availability, then refresh. Do not rerun migration blindly.',
pluginHint:
'Missing a plugin? Install or enable its runner in Extensions, then refresh the preview.',
extensions: 'Open Extensions',
results: 'Per-pipeline results',
selection: '{{count}} selected (maximum 50)',
confirm: 'I confirm migration of the selected pipelines.',
refresh: 'Refresh preview',
execute: 'Migrate selected',
legacyGate:
'Legacy configuration is read-only until migration. Save and debug are unavailable to prevent implicit conversion.',
states: {
ready: 'Ready',
needs_plugin: 'Plugin required',
blocked: 'Blocked',
already_current: 'Already current',
not_legacy: 'Not legacy',
activation_pending: 'Activation pending',
pending: 'Pending',
migrated: 'Migrated',
stale: 'Stale preview',
failed: 'Failed',
},
};
@@ -0,0 +1,133 @@
export default {
complete: 'Finalizar',
checkingPlugin: 'Comprobar plugin',
preparingPlugin: 'Preparar instalación',
installDetails: 'Detalles de instalación',
stageProgress: 'Progreso por etapas',
downloaded: 'Descargado {{size}}',
processed: 'Procesados {{completed}} / {{total}} pipelines',
installErrors: {
plugin_version_unavailable:
'La versión requerida aún no está disponible en el mercado. Reintenta tras su publicación o migra solo los datos.',
plugin_download_timeout:
'La descarga agotó el tiempo de espera. Revisa la red y reintenta.',
plugin_marketplace_unavailable:
'El mercado no puede servir el paquete. Reintenta más tarde.',
plugin_download_failed: 'No se pudo descargar el plugin. Revisa la red.',
dependency_prepare_failed:
'No se pudieron instalar las dependencias. Revisa el entorno de ejecución.',
plugin_launch_failed:
'El plugin no pudo iniciarse. Revisa el entorno de ejecución.',
},
autoDescription:
'Los ejecutores antiguos ahora son plugins. Migra todos los pipelines conservando sus ajustes. Se guardará una copia de la configuración y las conversaciones empezarán de nuevo.',
viewPipelines: 'Ver pipelines',
autoInstall: 'Instalar plugins y migrar',
dataOnly: 'Migrar solo los datos',
dataOnlyHint:
'Para redes privadas o sin conexión. Instala los plugins de ejecución correspondientes después de la migración.',
installing: 'Instalando los plugins necesarios…',
migrating: 'Migrando pipelines…',
summary: '{{migrated}} migrados; {{remaining}} requieren atención.',
installFailed:
'La instalación falló. Consulta la etapa fallida en los detalles y reintenta, o migra solo los datos.',
activationRetryHint:
'Tras comprobar el entorno, actualiza, selecciona esta canalización y confirma para reintentar solo la activación. No se volverá a migrar la configuración guardada.',
details: 'Detalles de migración',
notices: {
runtimeUnavailable:
'El entorno de plugins está desconectado. Restablece la conexión y reintenta, o migra solo los datos.',
executionFailed:
'La migración falló. Revisa los registros del servidor y reintenta.',
pluginRequired:
'Instala o activa el plugin de ejecución indicado arriba y actualiza la vista previa.',
legacyArchive:
'La configuración activa contendrá solo el Runner seleccionado. Todos los ajustes anteriores, incluidos los inactivos, se conservan en la copia de seguridad de migración.',
contextDefaults:
'El historial usará el nuevo presupuesto de contexto y los valores de resumen, en lugar de un límite fijo de turnos.',
modelReasoning:
'Se conservan los ajustes de razonamiento por modelo y el host los aplica.',
serialTools: 'Las herramientas seguirán ejecutándose de forma secuencial.',
retrievalDefaults:
'La recuperación usará los nuevos límites de top-k y longitud. Revísalos después de migrar.',
boxReset:
'Se conserva la configuración de reutilización del sandbox. El estado del contenedor no se migra; los nuevos sandboxes siguen las mismas reglas.',
persistentHistory:
'Las nuevas conversaciones tendrán un historial persistente y aislado. No se importa el historial remoto anterior.',
tweaksDefault:
'Langflow tweaks usa un objeto vacío de forma predeterminada.',
timeoutDefault:
'Dify usa un tiempo de espera de 30 segundos de forma predeterminada.',
historyReset:
'No se transfieren el historial ni los identificadores de sesión. Después de migrar se iniciarán conversaciones nuevas.',
sessionTitle:
'Las nuevas sesiones de WeKnora usan títulos generados por el plugin.',
aliasRepaired:
'El nombre antiguo del campo se sustituye por el nombre admitido.',
nullDefault:
'Este valor vacío usará el valor predeterminado del nuevo plugin.',
outputPolicy:
'Se conserva la visibilidad actual del contenido de razonamiento.',
filteredVariables:
'Solo se transfieren variables admitidas; el contexto de la conversación proporciona las variables reservadas.',
identityPreserved:
'La identidad de usuario del proveedor conserva su origen anterior.',
newDefaults:
'Las opciones nuevas usan los valores predeterminados documentados; se conservan los valores anteriores admitidos.',
externalState:
'Completa o cancela las interacciones remotas pendientes antes de migrar; su estado activo no se transfiere.',
pluginVersion:
'Instala la versión requerida del plugin y actualiza. Las versiones anteriores no admiten esta migración.',
schemaChanged:
'La configuración del Runner instalado no coincide con el destino. Revisa la versión del plugin y actualiza.',
runnerExcluded:
'Esta canalización excluye el plugin Runner necesario. Ajusta primero la configuración de extensiones.',
boxTemplateInvalid:
'Plantilla de reutilización inválida. Use {nombre_variable}; no se admiten campos posicionales, conversiones de formato ni acceso a atributos.',
pendingInteraction:
'Hay una conversación esperando una respuesta. Complétala o cancélala antes de migrar.',
},
title: 'Migración de pipelines',
description:
'Selecciona los pipelines que deseas migrar. Se guardará una copia de la configuración y las conversaciones empezarán de nuevo.',
detected: '{{count}} pipelines requieren revisión.',
review: 'Revisar migración',
readOnly: 'Solo los gestores del espacio pueden migrar pipelines.',
previewError: 'Vista previa no disponible. Actualícela.',
submitting: 'Enviando pipelines seleccionados…',
running: 'Migración en curso. Cerrar el diálogo no cancela la tarea.',
finished: 'Tarea finalizada. Revise el resultado de cada pipeline.',
failed: 'La tarea falló. Revise el resultado de cada pipeline.',
lost: 'Se perdió el seguimiento de la tarea; se desconoce el resultado. Actualice la vista previa antes de actuar.',
requestError:
'Solicitud no completada. Actualice la vista previa y vuelva a seleccionar.',
warningFallback: 'Revise este ajuste antes de migrar.',
blockerFallback:
'Este ajuste o estado de ejecución no se puede migrar de forma segura. Resuélvalo y actualice la vista previa.',
changedFields: 'Campos modificados',
activationHint:
'Configuración guardada, pero activación pendiente. Pida al administrador que compruebe el entorno y actualice. No repita la migración a ciegas.',
pluginHint:
'¿Falta un plugin? Instala o activa su ejecutor en Extensiones y actualiza la vista previa.',
extensions: 'Abrir Extensiones',
results: 'Resultados por pipeline',
selection: '{{count}} seleccionados (máximo 50)',
confirm: 'Confirmo la migración de los pipelines seleccionados.',
refresh: 'Actualizar vista previa',
execute: 'Migrar seleccionados',
legacyGate:
'La configuración antigua es de solo lectura hasta la migración. Guardar y depurar no están disponibles para evitar una conversión implícita.',
states: {
ready: 'Listo',
needs_plugin: 'Plugin requerido',
blocked: 'Bloqueado',
already_current: 'Ya actualizado',
not_legacy: 'No es antiguo',
activation_pending: 'Activación pendiente',
pending: 'Pendiente',
migrated: 'Migrado',
stale: 'Vista previa caducada',
failed: 'Fallido',
},
};
@@ -0,0 +1,128 @@
export default {
complete: '完了',
checkingPlugin: 'プラグインを確認',
preparingPlugin: 'インストールを準備',
installDetails: 'インストール詳細',
stageProgress: '段階の進捗',
downloaded: 'ダウンロード済み {{size}}',
processed: '{{completed}} / {{total}} 件のパイプラインを処理済み',
installErrors: {
plugin_version_unavailable:
'必要なバージョンはまだマーケットにありません。公開後に再試行するかデータのみ移行してください。',
plugin_download_timeout:
'ダウンロードがタイムアウトしました。ネットワークを確認して再試行してください。',
plugin_marketplace_unavailable:
'マーケットからプラグインを取得できません。後で再試行してください。',
plugin_download_failed:
'ダウンロードに失敗しました。ネットワークを確認してください。',
dependency_prepare_failed:
'依存関係のインストールに失敗しました。実行環境を確認してください。',
plugin_launch_failed:
'プラグインを起動できません。実行環境を確認してください。',
},
autoDescription:
'従来の実行方式はプラグインになりました。設定を保持して全パイプラインを移行します。元の設定はバックアップされ、会話は新しく始まります。',
viewPipelines: 'パイプラインを表示',
autoInstall: 'プラグインを自動インストールして移行',
dataOnly: 'データのみ移行',
dataOnlyHint:
'オフラインやイントラネット向けです。移行後、対応するランナープラグインを手動でインストールしてください。',
installing: '必要なプラグインをインストール中…',
migrating: 'パイプラインを移行中…',
summary: '{{migrated}} 件移行済み、{{remaining}} 件の確認が必要です。',
installFailed:
'インストールに失敗しました。詳細で失敗した段階を確認し、再試行するかデータのみ移行してください。',
activationRetryHint:
'実行環境を確認して再読み込みし、このパイプラインを選択して確定すると、有効化のみを再試行します。保存済み設定は再移行しません。',
details: '移行の詳細',
notices: {
runtimeUnavailable:
'プラグインランタイムが未接続です。接続を復旧して再試行するか、データのみ移行してください。',
executionFailed:
'移行に失敗しました。サーバーログを確認して再試行してください。',
pluginRequired:
'上記のランナープラグインをインストールまたは有効化し、プレビューを更新してください。',
legacyArchive:
'有効な設定には選択した Runner のみを残します。未使用のものを含むすべての旧 Runner 設定は移行バックアップに保存します。',
contextDefaults:
'履歴には固定の往復数制限ではなく、新しいコンテキスト予算と要約の既定値を適用します。',
modelReasoning: 'モデル別の推論設定を保持し、ホスト側で適用します。',
serialTools: '移行後もツール呼び出しは順番に実行します。',
retrievalDefaults:
'検索には新しい top-k と結果長の既定値を適用します。移行後に確認してください。',
boxReset:
'サンドボックスの再利用設定は保持されます。既存コンテナの状態は移行せず、同じ再利用ルールで新しく作成されます。',
persistentHistory:
'新しい会話の履歴は分離して永続化します。既存のリモート履歴は取り込みません。',
tweaksDefault: 'Langflow tweaks の既定値は空のオブジェクトです。',
timeoutDefault: 'Dify のリクエストタイムアウトは既定で 30 秒です。',
historyReset:
'既存の会話履歴とセッション ID は引き継ぎません。移行後は新しい会話を開始します。',
sessionTitle:
'新しい WeKnora セッションにはプラグインが生成したタイトルを使用します。',
aliasRepaired: '旧フィールド名を現在サポートされている名前に置き換えます。',
nullDefault: 'この空の値には新しいプラグインの既定値を使用します。',
outputPolicy: '推論内容の表示設定は保持します。',
filteredVariables:
'対応する変数のみ移行します。予約変数は会話コンテキストから提供します。',
identityPreserved:
'サービス側のユーザー識別子には従来と同じ情報源を使用します。',
newDefaults:
'追加された設定には文書化された既定値を使い、対応する既存の値は保持します。',
externalState:
'移行前に待機中のリモート操作を完了またはキャンセルしてください。実行状態は引き継ぎません。',
pluginVersion:
'必要なバージョンのプラグインをインストールして再読み込みしてください。旧バージョンは移行に対応していません。',
schemaChanged:
'インストール済み Runner の設定形式が移行先と一致しません。プラグインのバージョンを確認してください。',
runnerExcluded:
'このパイプラインでは必要な Runner プラグインが除外されています。拡張機能の設定を変更してください。',
boxTemplateInvalid:
'再利用テンプレートが無効です。{変数名} を使用してください。位置引数、書式変換、属性アクセスは使用できません。',
pendingInteraction:
'入力待ちの会話があります。完了またはキャンセルしてから移行してください。',
},
title: 'パイプライン移行',
description:
'移行するパイプラインを選択してください。元の設定はバックアップされ、移行後は新しい会話を開始します。',
detected: '{{count}} 件のパイプラインに移行確認が必要です。',
review: '移行を確認',
readOnly: '移行できるのはワークスペースの管理権限を持つユーザーのみです。',
previewError: 'プレビューを取得できません。更新してください。',
submitting: '選択したパイプラインを送信中…',
running: '移行中です。ダイアログを閉じてもタスクは停止しません。',
finished: 'タスクが終了しました。各パイプラインの結果を確認してください。',
failed: 'タスクが失敗しました。各パイプラインの結果を確認してください。',
lost: 'タスクの追跡が失われ、結果は不明です。次の操作前にプレビューを更新してください。',
requestError:
'リクエストを完了できませんでした。プレビューを更新して再選択してください。',
warningFallback: '移行前にこの設定を確認してください。',
blockerFallback:
'この設定または実行状態は安全に移行できません。解決してプレビューを更新してください。',
changedFields: '変更フィールド',
activationHint:
'設定は保存されましたが、有効化待ちです。管理者に実行環境の確認を依頼してから更新してください。移行を無条件に再実行しないでください。',
pluginHint:
'プラグインが不足している場合は、拡張機能でランナーをインストールまたは有効化し、プレビューを更新してください。',
extensions: '拡張機能を開く',
results: 'パイプラインごとの結果',
selection: '{{count}} 件選択(最大 50 件)',
confirm: '選択したパイプラインの移行を確認します。',
refresh: 'プレビューを更新',
execute: '選択項目を移行',
legacyGate:
'旧設定は移行まで読み取り専用です。暗黙の変換を防ぐため、保存とデバッグは使用できません。',
states: {
ready: '移行可能',
needs_plugin: 'プラグインが必要',
blocked: '移行不可',
already_current: '移行済みの形式',
not_legacy: '旧形式ではない',
activation_pending: '有効化待ち',
pending: '待機中',
migrated: '移行済み',
stale: 'プレビューが古い',
failed: '失敗',
},
};
@@ -0,0 +1,130 @@
export default {
complete: 'Готово',
checkingPlugin: 'Проверка плагина',
preparingPlugin: 'Подготовка установки',
installDetails: 'Подробности установки',
stageProgress: 'Прогресс этапов',
downloaded: 'Загружено {{size}}',
processed: 'Обработано {{completed}} / {{total}} конвейеров',
installErrors: {
plugin_version_unavailable:
'Нужная версия ещё не опубликована в магазине. Повторите после публикации либо перенесите только данные.',
plugin_download_timeout:
'Время загрузки истекло. Проверьте сеть и повторите попытку.',
plugin_marketplace_unavailable:
'Магазин не может предоставить пакет. Повторите позже.',
plugin_download_failed: 'Не удалось загрузить плагин. Проверьте сеть.',
dependency_prepare_failed:
'Не удалось установить зависимости. Проверьте среду выполнения.',
plugin_launch_failed:
'Не удалось запустить плагин. Проверьте среду выполнения.',
},
autoDescription:
'Прежние исполнители стали плагинами. Все конвейеры будут перенесены с сохранением настроек. Исходные настройки будут скопированы, а разговоры начнутся заново.',
viewPipelines: 'Посмотреть конвейеры',
autoInstall: 'Установить плагины и перенести',
dataOnly: 'Перенести только данные',
dataOnlyHint:
'Для закрытых сетей и работы без интернета. После переноса установите соответствующие плагины исполнителей вручную.',
installing: 'Установка необходимых плагинов…',
migrating: 'Перенос конвейеров…',
summary: 'Перенесено: {{migrated}}; требуют внимания: {{remaining}}.',
installFailed:
'Установка не удалась. Откройте подробности, проверьте этап сбоя и повторите попытку либо перенесите только данные.',
activationRetryHint:
'Проверьте среду выполнения, обновите список, выберите конвейер и подтвердите повторную активацию. Сохранённая конфигурация не будет мигрировать повторно.',
details: 'Подробности переноса',
notices: {
runtimeUnavailable:
'Среда плагинов отключена. Восстановите подключение и повторите попытку или перенесите только данные.',
executionFailed:
'Миграция не удалась. Проверьте журнал сервера и повторите попытку.',
pluginRequired:
'Установите или включите указанный выше плагин исполнителя, затем обновите предпросмотр.',
legacyArchive:
'В активной конфигурации останется только выбранный Runner. Все прежние настройки, включая неиспользуемые, сохраняются в резервной копии миграции.',
contextDefaults:
'Вместо фиксированного числа раундов история будет использовать новый бюджет контекста и настройки суммаризации.',
modelReasoning:
'Настройки рассуждений для каждой модели сохраняются и применяются хостом.',
serialTools: 'Вызовы инструментов останутся последовательными.',
retrievalDefaults:
'Для поиска применяются новые значения top-k и ограничения длины результатов. Проверьте их после миграции.',
boxReset:
'Настройки повторного использования песочницы сохраняются. Состояние контейнера не переносится; новые песочницы используют те же правила.',
persistentHistory:
'Новые беседы используют постоянную изолированную историю. Прежняя удалённая история не импортируется.',
tweaksDefault:
'Для Langflow tweaks по умолчанию используется пустой объект.',
timeoutDefault: 'Тайм-аут запроса Dify по умолчанию составляет 30 секунд.',
historyReset:
'История и идентификаторы сеансов не переносятся. После миграции начнутся новые беседы.',
sessionTitle:
'Новые сеансы WeKnora используют заголовки, созданные плагином.',
aliasRepaired: 'Старое имя поля заменяется поддерживаемым именем.',
nullDefault:
'Для этого пустого значения применяется значение нового плагина по умолчанию.',
outputPolicy: 'Текущая настройка видимости рассуждений сохраняется.',
filteredVariables:
'Переносятся только поддерживаемые переменные; зарезервированные переменные задаются контекстом беседы.',
identityPreserved:
'Источник пользовательского идентификатора у провайдера остаётся прежним.',
newDefaults:
'Новые параметры используют документированные значения по умолчанию; поддерживаемые прежние значения сохраняются.',
externalState:
'До миграции завершите или отмените ожидающие удалённые взаимодействия: их текущее состояние не переносится.',
pluginVersion:
'Установите требуемую версию плагина и обновите список. Старые версии не поддерживают эту миграцию.',
schemaChanged:
'Схема установленного Runner не соответствует целевой. Проверьте версию плагина и обновите список.',
runnerExcluded:
'В этом конвейере исключён нужный плагин Runner. Сначала измените настройки расширений.',
boxTemplateInvalid:
'Неверный шаблон песочницы. Используйте {имя_переменной}; позиционные поля, преобразования формата и доступ к атрибутам не поддерживаются.',
pendingInteraction:
'Беседа ожидает ввода. Завершите или отмените её перед миграцией.',
},
title: 'Миграция конвейеров',
description:
'Выберите конвейеры для переноса. Исходные настройки сохранятся в резервной копии, а разговоры начнутся заново.',
detected: '{{count}} конвейеров требуют проверки миграции.',
review: 'Проверить миграцию',
readOnly:
'Миграция доступна только пользователям с правами управления пространством.',
previewError: 'Предпросмотр недоступен. Обновите его.',
submitting: 'Отправка выбранных конвейеров…',
running: 'Выполняется миграция. Закрытие окна не отменяет задачу.',
finished: 'Задача завершена. Проверьте результат каждого конвейера.',
failed: 'Задача завершилась ошибкой. Проверьте результат каждого конвейера.',
lost: 'Наблюдение за задачей потеряно; результат неизвестен. Обновите предпросмотр перед дальнейшими действиями.',
requestError: 'Запрос не завершён. Обновите предпросмотр и выберите заново.',
warningFallback: 'Проверьте эту настройку перед миграцией.',
blockerFallback:
'Эту настройку или состояние среды нельзя безопасно перенести. Устраните проблему и обновите предпросмотр.',
changedFields: 'Изменяемые поля',
activationHint:
'Настройки сохранены, но активация ожидается. Попросите администратора проверить среду и обновите предпросмотр. Не повторяйте миграцию вслепую.',
pluginHint:
'Не хватает плагина? Установите или включите его в расширениях, затем обновите предпросмотр.',
extensions: 'Открыть расширения',
results: 'Результаты по конвейерам',
selection: 'Выбрано {{count}} (максимум 50)',
confirm: 'Подтверждаю миграцию выбранных конвейеров.',
refresh: 'Обновить предпросмотр',
execute: 'Перенести выбранные',
legacyGate:
'Старая конфигурация доступна только для чтения до миграции. Сохранение и отладка отключены во избежание неявного преобразования.',
states: {
ready: 'Готов',
needs_plugin: 'Нужен плагин',
blocked: 'Заблокирован',
already_current: 'Уже актуален',
not_legacy: 'Не устаревший',
activation_pending: 'Ожидает активации',
pending: 'Ожидает',
migrated: 'Перенесён',
stale: 'Предпросмотр устарел',
failed: 'Ошибка',
},
};
@@ -0,0 +1,124 @@
export default {
complete: 'เสร็จสิ้น',
checkingPlugin: 'ตรวจสอบปลั๊กอิน',
preparingPlugin: 'เตรียมติดตั้ง',
installDetails: 'รายละเอียดการติดตั้ง',
stageProgress: 'ความคืบหน้าแต่ละขั้นตอน',
downloaded: 'ดาวน์โหลดแล้ว {{size}}',
processed: 'ดำเนินการแล้ว {{completed}} / {{total}} ไปป์ไลน์',
installErrors: {
plugin_version_unavailable:
'เวอร์ชันที่ต้องการยังไม่มีในตลาด กรุณาลองใหม่หลังเผยแพร่หรือย้ายเฉพาะข้อมูล',
plugin_download_timeout:
'การดาวน์โหลดหมดเวลา กรุณาตรวจสอบเครือข่ายแล้วลองใหม่',
plugin_marketplace_unavailable:
'ตลาดไม่สามารถให้แพ็กเกจปลั๊กอินได้ กรุณาลองใหม่ภายหลัง',
plugin_download_failed: 'ดาวน์โหลดปลั๊กอินไม่สำเร็จ กรุณาตรวจสอบเครือข่าย',
dependency_prepare_failed:
'ติดตั้งส่วนพึ่งพาไม่สำเร็จ กรุณาตรวจสอบสภาพแวดล้อมรันไทม์',
plugin_launch_failed:
'เริ่มปลั๊กอินไม่สำเร็จ กรุณาตรวจสอบสภาพแวดล้อมรันไทม์',
},
autoDescription:
'รันเนอร์เดิมเปลี่ยนเป็นปลั๊กอินแล้ว ย้ายไปป์ไลน์ทั้งหมดโดยคงการตั้งค่าเดิม ระบบจะสำรองการตั้งค่าและเริ่มการสนทนาใหม่',
viewPipelines: 'ดูไปป์ไลน์',
autoInstall: 'ติดตั้งปลั๊กอินและย้ายข้อมูล',
dataOnly: 'ย้ายเฉพาะข้อมูล',
dataOnlyHint:
'สำหรับเครือข่ายภายในหรือออฟไลน์ โปรดติดตั้งปลั๊กอินรันเนอร์ที่เกี่ยวข้องด้วยตนเองหลังย้ายข้อมูล',
installing: 'กำลังติดตั้งปลั๊กอินที่จำเป็น…',
migrating: 'กำลังย้ายไปป์ไลน์…',
summary: 'ย้ายแล้ว {{migrated}} รายการ ต้องตรวจสอบ {{remaining}} รายการ',
installFailed:
'ติดตั้งไม่สำเร็จ เปิดรายละเอียดเพื่อดูขั้นตอนที่ล้มเหลว แล้วลองใหม่หรือย้ายเฉพาะข้อมูล',
activationRetryHint:
'หลังตรวจสอบสภาพแวดล้อมการทำงาน ให้รีเฟรช เลือกไปป์ไลน์นี้และยืนยันเพื่อลองเปิดใช้งานอีกครั้งเท่านั้น การตั้งค่าที่บันทึกไว้จะไม่ถูกย้ายซ้ำ',
details: 'รายละเอียดการย้าย',
notices: {
runtimeUnavailable:
'ไม่ได้เชื่อมต่อรันไทม์ปลั๊กอิน โปรดเชื่อมต่อใหม่แล้วลองอีกครั้ง หรือย้ายเฉพาะข้อมูล',
executionFailed:
'การย้ายข้อมูลล้มเหลว โปรดตรวจสอบบันทึกเซิร์ฟเวอร์แล้วลองอีกครั้ง',
pluginRequired:
'ติดตั้งหรือเปิดใช้งานปลั๊กอินรันเนอร์ที่แสดงด้านบน แล้วรีเฟรชตัวอย่าง',
legacyArchive:
'การตั้งค่าที่ใช้งานจะเหลือเฉพาะ Runner ที่เลือก การตั้งค่า Runner เดิมทั้งหมด รวมถึงส่วนที่ไม่ได้ใช้งาน จะถูกเก็บไว้ในข้อมูลสำรองการย้าย',
contextDefaults:
'ประวัติจะใช้งบประมาณบริบทและค่าการสรุปใหม่ แทนการจำกัดจำนวนรอบแบบตายตัว',
modelReasoning:
'การตั้งค่าการให้เหตุผลของแต่ละโมเดลจะถูกเก็บไว้และใช้โดยโฮสต์',
serialTools: 'หลังย้ายข้อมูล เครื่องมือยังคงทำงานตามลำดับ',
retrievalDefaults:
'การค้นคืนจะใช้ค่า top-k และขีดจำกัดความยาวผลลัพธ์ใหม่ โปรดตรวจสอบหลังย้ายข้อมูล',
boxReset:
'คงการตั้งค่าการใช้แซนด์บ็อกซ์ซ้ำไว้ ไม่ย้ายสถานะคอนเทนเนอร์เดิม แซนด์บ็อกซ์ใหม่จะใช้กฎการใช้ซ้ำเดิม',
persistentHistory:
'บทสนทนาใหม่จะมีประวัติถาวรที่แยกจากกัน โดยไม่นำเข้าประวัติระยะไกลเดิม',
tweaksDefault: 'Langflow tweaks จะใช้วัตถุว่างเป็นค่าเริ่มต้น',
timeoutDefault: 'Dify ใช้เวลารอคำขอเริ่มต้น 30 วินาที',
historyReset:
'ประวัติและรหัสเซสชันเดิมจะไม่ถูกย้าย ระบบจะเริ่มบทสนทนาใหม่หลังย้ายข้อมูล',
sessionTitle: 'เซสชัน WeKnora ใหม่จะใช้ชื่อที่ปลั๊กอินสร้าง',
aliasRepaired: 'ชื่อฟิลด์เดิมจะเปลี่ยนเป็นชื่อที่รองรับในปัจจุบัน',
nullDefault: 'ค่าว่างนี้จะใช้ค่าเริ่มต้นของปลั๊กอินใหม่',
outputPolicy: 'การตั้งค่าการแสดงเนื้อหาการให้เหตุผลเดิมจะถูกเก็บไว้',
filteredVariables:
'ย้ายเฉพาะตัวแปรที่รองรับ ส่วนตัวแปรสงวนจะมาจากบริบทการสนทนา',
identityPreserved: 'ข้อมูลระบุตัวผู้ใช้ของผู้ให้บริการยังใช้แหล่งเดิม',
newDefaults: 'ตัวเลือกใหม่ใช้ค่าเริ่มต้นตามเอกสาร และคงค่าเดิมที่รองรับไว้',
externalState:
'โปรดดำเนินการหรือยกเลิกการโต้ตอบระยะไกลที่ค้างอยู่ก่อนย้ายข้อมูล สถานะที่กำลังทำงานจะไม่ถูกย้าย',
pluginVersion:
'ติดตั้งปลั๊กอินเวอร์ชันที่กำหนดแล้วรีเฟรช เวอร์ชันเก่าไม่รองรับการย้ายข้อมูลนี้',
schemaChanged:
'รูปแบบการตั้งค่า Runner ที่ติดตั้งไม่ตรงกับเป้าหมาย โปรดตรวจสอบเวอร์ชันปลั๊กอินแล้วรีเฟรช',
runnerExcluded:
'ไปป์ไลน์นี้ยกเว้นปลั๊กอิน Runner ที่จำเป็น โปรดแก้ไขการตั้งค่าส่วนขยายก่อน',
boxTemplateInvalid:
'รูปแบบเทมเพลตไม่ถูกต้อง ใช้ {ชื่อตัวแปร} โดยไม่ใช้ฟิลด์ตามตำแหน่ง การแปลงรูปแบบ หรือการเข้าถึงแอตทริบิวต์',
pendingInteraction:
'มีบทสนทนาที่รอข้อมูลอยู่ โปรดดำเนินการให้เสร็จหรือยกเลิกก่อนย้ายข้อมูล',
},
title: 'ย้ายไปป์ไลน์',
description:
'เลือกไปป์ไลน์ที่จะย้าย ระบบจะสำรองการตั้งค่าเดิมและเริ่มการสนทนาใหม่หลังย้าย',
detected: 'มี {{count}} ไปป์ไลน์ที่ต้องตรวจสอบการย้าย',
review: 'ตรวจสอบการย้าย',
readOnly: 'เฉพาะผู้มีสิทธิ์จัดการพื้นที่ทำงานเท่านั้นที่ย้ายไปป์ไลน์ได้',
previewError: 'ไม่สามารถดูตัวอย่างได้ โปรดรีเฟรช',
submitting: 'กำลังส่งไปป์ไลน์ที่เลือก…',
running: 'กำลังย้าย การปิดหน้าต่างไม่ยกเลิกงาน',
finished: 'งานสิ้นสุดแล้ว โปรดตรวจสอบผลลัพธ์ของแต่ละไปป์ไลน์',
failed: 'งานล้มเหลว โปรดตรวจสอบผลลัพธ์ของแต่ละไปป์ไลน์',
lost: 'ไม่สามารถติดตามงานได้ ไม่ทราบผลลัพธ์ โปรดรีเฟรชตัวอย่างก่อนดำเนินการต่อ',
requestError: 'คำขอไม่เสร็จสมบูรณ์ โปรดรีเฟรชตัวอย่างและเลือกใหม่',
warningFallback: 'ตรวจสอบการตั้งค่านี้ก่อนย้าย',
blockerFallback:
'ไม่สามารถย้ายการตั้งค่าหรือสถานะรันนี้ได้อย่างปลอดภัย โปรดแก้ไขแล้วรีเฟรชตัวอย่าง',
changedFields: 'ฟิลด์ที่เปลี่ยน',
activationHint:
'บันทึกการตั้งค่าแล้วแต่รอเปิดใช้งาน โปรดให้ผู้ดูแลตรวจสอบสภาพแวดล้อมแล้วรีเฟรช อย่าย้ายซ้ำโดยไม่ตรวจสอบ',
pluginHint:
'ขาดปลั๊กอิน? ติดตั้งหรือเปิดใช้งานรันเนอร์ในส่วนขยาย แล้วรีเฟรชตัวอย่าง',
extensions: 'เปิดส่วนขยาย',
results: 'ผลลัพธ์แต่ละไปป์ไลน์',
selection: 'เลือก {{count}} รายการ (สูงสุด 50)',
confirm: 'ฉันยืนยันการย้ายไปป์ไลน์ที่เลือก',
refresh: 'รีเฟรชตัวอย่าง',
execute: 'ย้ายรายการที่เลือก',
legacyGate:
'การตั้งค่าเดิมอ่านได้อย่างเดียวจนกว่าจะย้าย ปิดการบันทึกและดีบักเพื่อป้องกันการแปลงโดยไม่ตั้งใจ',
states: {
ready: 'พร้อม',
needs_plugin: 'ต้องการปลั๊กอิน',
blocked: 'ถูกบล็อก',
already_current: 'เป็นรุ่นใหม่แล้ว',
not_legacy: 'ไม่ใช่รุ่นเก่า',
activation_pending: 'รอเปิดใช้งาน',
pending: 'รอดำเนินการ',
migrated: 'ย้ายแล้ว',
stale: 'ตัวอย่างล้าสมัย',
failed: 'ล้มเหลว',
},
};
@@ -0,0 +1,127 @@
export default {
complete: 'Hoàn tất',
checkingPlugin: 'Kiểm tra plugin',
preparingPlugin: 'Chuẩn bị cài đặt',
installDetails: 'Chi tiết cài đặt',
stageProgress: 'Tiến độ từng giai đoạn',
downloaded: 'Đã tải {{size}}',
processed: 'Đã xử lý {{completed}} / {{total}} pipeline',
installErrors: {
plugin_version_unavailable:
'Phiên bản cần thiết chưa có trên chợ. Thử lại sau khi phát hành hoặc chỉ chuyển dữ liệu.',
plugin_download_timeout:
'Tải plugin quá thời gian chờ. Kiểm tra mạng rồi thử lại.',
plugin_marketplace_unavailable:
'Chợ chưa thể cung cấp gói plugin. Vui lòng thử lại sau.',
plugin_download_failed: 'Không thể tải plugin. Vui lòng kiểm tra mạng.',
dependency_prepare_failed:
'Không thể cài các thư viện phụ thuộc. Kiểm tra môi trường chạy.',
plugin_launch_failed:
'Không thể khởi động plugin. Kiểm tra môi trường chạy.',
},
autoDescription:
'Các runner cũ đã chuyển thành plugin. Chuyển đổi tất cả pipeline và giữ lại thiết lập. Cấu hình cũ sẽ được sao lưu; hội thoại sẽ bắt đầu lại.',
viewPipelines: 'Xem pipeline',
autoInstall: 'Cài plugin và chuyển đổi',
dataOnly: 'Chỉ chuyển đổi dữ liệu',
dataOnlyHint:
'Dành cho mạng nội bộ hoặc ngoại tuyến. Tự cài các plugin runner tương ứng sau khi chuyển đổi.',
installing: 'Đang cài plugin cần thiết…',
migrating: 'Đang chuyển đổi pipeline…',
summary: 'Đã chuyển {{migrated}}; {{remaining}} cần xử lý.',
installFailed:
'Cài đặt thất bại. Mở chi tiết để xem giai đoạn lỗi rồi thử lại, hoặc chỉ chuyển dữ liệu.',
activationRetryHint:
'Sau khi kiểm tra môi trường chạy, làm mới, chọn pipeline này và xác nhận để chỉ thử kích hoạt lại. Cấu hình đã lưu sẽ không được di chuyển lần nữa.',
details: 'Chi tiết chuyển đổi',
notices: {
runtimeUnavailable:
'Môi trường chạy plugin bị ngắt kết nối. Khôi phục kết nối rồi thử lại, hoặc chỉ di chuyển dữ liệu.',
executionFailed:
'Di chuyển thất bại. Kiểm tra nhật ký máy chủ rồi thử lại.',
pluginRequired:
'Cài đặt hoặc bật plugin runner ở trên, rồi làm mới bản xem trước.',
legacyArchive:
'Cấu hình hoạt động chỉ giữ Runner đã chọn. Tất cả thiết lập Runner cũ, kể cả các thiết lập không dùng, được giữ trong bản sao lưu di chuyển.',
contextDefaults:
'Lịch sử sẽ dùng ngân sách ngữ cảnh và thiết lập tóm tắt mới thay vì giới hạn số lượt cố định.',
modelReasoning:
'Thiết lập suy luận của từng mô hình được giữ lại và do máy chủ áp dụng.',
serialTools: 'Các công cụ vẫn được gọi lần lượt sau khi di chuyển.',
retrievalDefaults:
'Truy xuất dùng giới hạn top-k và độ dài kết quả mới. Hãy kiểm tra sau khi di chuyển.',
boxReset:
'Giữ nguyên cấu hình tái sử dụng sandbox. Không chuyển trạng thái container cũ; sandbox mới dùng cùng quy tắc tái sử dụng.',
persistentHistory:
'Hội thoại mới dùng lịch sử bền vững và riêng biệt. Lịch sử từ xa cũ không được nhập.',
tweaksDefault: 'Langflow tweaks mặc định là một đối tượng rỗng.',
timeoutDefault: 'Dify mặc định có thời gian chờ yêu cầu là 30 giây.',
historyReset:
'Lịch sử và mã phiên cũ không được chuyển. Hội thoại mới sẽ bắt đầu sau khi di chuyển.',
sessionTitle: 'Phiên WeKnora mới dùng tiêu đề do plugin tạo.',
aliasRepaired: 'Tên trường cũ được ánh xạ sang tên đang được hỗ trợ.',
nullDefault: 'Giá trị trống này sẽ dùng giá trị mặc định của plugin mới.',
outputPolicy: 'Thiết lập hiển thị nội dung suy luận hiện tại được giữ lại.',
filteredVariables:
'Chỉ chuyển các biến được hỗ trợ; biến dành riêng do ngữ cảnh hội thoại cung cấp.',
identityPreserved:
'Danh tính người dùng phía nhà cung cấp giữ nguyên nguồn cũ.',
newDefaults:
'Tùy chọn mới dùng giá trị mặc định đã mô tả; các giá trị cũ được hỗ trợ vẫn được giữ.',
externalState:
'Hoàn tất hoặc hủy các tương tác từ xa đang chờ trước khi di chuyển; trạng thái đang chạy không được chuyển.',
pluginVersion:
'Cài phiên bản plugin được yêu cầu rồi làm mới. Phiên bản cũ không hỗ trợ lần di chuyển này.',
schemaChanged:
'Cấu hình Runner đã cài không khớp với đích di chuyển. Kiểm tra phiên bản plugin rồi làm mới.',
runnerExcluded:
'Pipeline này loại trừ plugin Runner cần thiết. Hãy chỉnh thiết lập tiện ích trước.',
boxTemplateInvalid:
'Mẫu tái sử dụng không hợp lệ. Dùng {tên_biến}; không hỗ trợ trường vị trí, chuyển đổi định dạng hoặc truy cập thuộc tính.',
pendingInteraction:
'Có hội thoại đang chờ nhập liệu. Hãy hoàn tất hoặc hủy trước khi di chuyển.',
},
title: 'Di chuyển pipeline',
description:
'Chọn các pipeline cần chuyển đổi. Cấu hình cũ sẽ được sao lưu; các cuộc trò chuyện sẽ bắt đầu lại.',
detected: '{{count}} pipeline cần xem xét di chuyển.',
review: 'Xem xét di chuyển',
readOnly:
'Chỉ người có quyền quản lý không gian mới được di chuyển pipeline.',
previewError: 'Không lấy được bản xem trước. Hãy làm mới.',
submitting: 'Đang gửi các pipeline đã chọn…',
running: 'Đang di chuyển. Đóng hộp thoại không hủy tác vụ.',
finished: 'Tác vụ đã kết thúc. Hãy kiểm tra kết quả từng pipeline.',
failed: 'Tác vụ thất bại. Hãy kiểm tra kết quả từng pipeline.',
lost: 'Mất theo dõi tác vụ; chưa biết kết quả. Hãy làm mới bản xem trước trước khi thao tác tiếp.',
requestError: 'Yêu cầu chưa hoàn tất. Làm mới bản xem trước rồi chọn lại.',
warningFallback: 'Kiểm tra cài đặt này trước khi di chuyển.',
blockerFallback:
'Không thể di chuyển an toàn cài đặt hoặc trạng thái chạy này. Hãy xử lý rồi làm mới bản xem trước.',
changedFields: 'Trường thay đổi',
activationHint:
'Cấu hình đã lưu nhưng đang chờ kích hoạt. Nhờ quản trị viên kiểm tra môi trường rồi làm mới. Không chạy lại di chuyển một cách mù quáng.',
pluginHint:
'Thiếu plugin? Cài đặt hoặc bật runner trong Tiện ích mở rộng, rồi làm mới bản xem trước.',
extensions: 'Mở Tiện ích',
results: 'Kết quả từng pipeline',
selection: 'Đã chọn {{count}} (tối đa 50)',
confirm: 'Tôi xác nhận di chuyển các pipeline đã chọn.',
refresh: 'Làm mới bản xem trước',
execute: 'Di chuyển đã chọn',
legacyGate:
'Cấu hình cũ chỉ đọc cho đến khi di chuyển. Lưu và gỡ lỗi bị tắt để tránh chuyển đổi ngầm.',
states: {
ready: 'Sẵn sàng',
needs_plugin: 'Cần plugin',
blocked: 'Bị chặn',
already_current: 'Đã cập nhật',
not_legacy: 'Không phải bản cũ',
activation_pending: 'Chờ kích hoạt',
pending: 'Đang chờ',
migrated: 'Đã di chuyển',
stale: 'Bản xem trước đã cũ',
failed: 'Thất bại',
},
};
@@ -0,0 +1,105 @@
export default {
complete: '完成',
checkingPlugin: '检查插件',
preparingPlugin: '准备安装',
installDetails: '安装详情',
stageProgress: '阶段进度',
downloaded: '已下载 {{size}}',
processed: '已处理 {{completed}} / {{total}} 条流水线',
installErrors: {
plugin_version_unavailable:
'扩展市场尚未提供所需插件版本,请等待上架后重试,或选择仅迁移数据。',
plugin_download_timeout: '下载插件超时,请检查网络后重试。',
plugin_marketplace_unavailable: '扩展市场暂时无法提供插件包,请稍后重试。',
plugin_download_failed: '插件下载失败,请检查网络后重试。',
dependency_prepare_failed: '插件依赖安装失败,请检查运行时环境后重试。',
plugin_launch_failed: '插件启动失败,请检查运行时环境后重试。',
},
autoDescription:
'旧版运行方式已改为插件。迁移全部流水线并保留现有设置,原配置会自动备份;迁移后开始新会话。',
viewPipelines: '查看流水线',
autoInstall: '自动安装插件并迁移',
dataOnly: '仅迁移数据',
dataOnlyHint: '适合离线或内网环境,迁移后请自行安装对应的运行器插件。',
installing: '正在安装所需插件…',
migrating: '正在迁移流水线…',
summary: '已迁移 {{migrated}} 条,{{remaining}} 条需要处理。',
installFailed:
'插件安装失败。展开详情查看失败阶段,处理后重试,或选择仅迁移数据。',
activationRetryHint:
'检查运行环境后刷新,选中此流水线并确认,即可仅重试激活,不会再次迁移已保存的配置。',
details: '迁移详情',
notices: {
runtimeUnavailable:
'插件运行时未连接,请恢复连接后重试,或选择仅迁移数据。',
executionFailed: '迁移执行失败,请查看服务日志后重试。',
pluginRequired: '请先安装或启用上方所示的运行器插件,再刷新预览。',
legacyArchive:
'活动配置仅保留所选 Runner;全部旧 Runner 配置(包括未启用的配置)保存在迁移备份中。',
contextDefaults:
'历史上下文将采用新的容量预算与摘要默认值,不再按固定轮数截断。',
modelReasoning: '各模型的推理设置会保留,并由主程序应用。',
serialTools: '迁移后工具调用仍按顺序执行。',
retrievalDefaults: '检索采用新的 top-k 与结果长度默认值,请在迁移后检查。',
boxReset:
'沙箱复用范围会保留;旧容器状态不迁移,后续按原复用规则创建沙箱。',
persistentHistory:
'新会话采用持久化、相互隔离的历史记录,不导入原有远端历史。',
tweaksDefault: 'Langflow tweaks 默认设为空对象。',
timeoutDefault: 'Dify 请求超时默认设为 30 秒。',
historyReset: '原有会话历史和会话标识不迁移,迁移后开始新会话。',
sessionTitle: '新的 WeKnora 会话使用插件生成的标题。',
aliasRepaired: '旧字段名称将映射为当前支持的名称。',
nullDefault: '此空值将使用新插件的默认值。',
outputPolicy: '现有推理内容显示设置会保留。',
filteredVariables: '仅迁移支持的变量;保留变量由会话上下文提供。',
identityPreserved: '服务端用户身份沿用旧配置的来源。',
newDefaults: '新增插件选项采用已说明的默认值,原有受支持配置会保留。',
externalState: '迁移前请完成或取消等待中的远端交互,其运行状态不会转移。',
pluginVersion: '请安装所需插件版本后刷新。旧版本不支持此次迁移。',
schemaChanged:
'已安装 Runner 的配置格式与迁移目标不一致,请检查插件版本后刷新。',
runnerExcluded: '此流水线排除了所需 Runner 插件,请先调整扩展设置。',
boxTemplateInvalid:
'沙箱复用模板格式有误,请使用 {变量名};不支持位置参数、格式转换或属性访问。',
pendingInteraction: '有会话正在等待输入,请完成或取消后再迁移。',
},
title: '流水线迁移',
description: '选择要迁移的流水线。原配置会备份,迁移后将开始新会话。',
detected: '{{count}} 条流水线需要检查迁移。',
review: '检查迁移',
readOnly: '只有工作空间管理者可以迁移流水线。',
previewError: '无法获取预览,请刷新重试。',
submitting: '正在提交所选流水线…',
running: '正在迁移。关闭对话框不会取消任务。',
finished: '任务已结束,请逐条检查下方结果。',
failed: '任务失败,请逐条检查下方结果。',
lost: '已失去任务状态连接,结果未知。执行其他操作前请刷新预览。',
requestError: '请求未完成。请刷新预览后重新选择。',
warningFallback: '迁移前请检查此设置。',
blockerFallback: '此设置或运行状态无法安全迁移。请处理后刷新预览。',
changedFields: '变更字段',
activationHint:
'配置已保存,但尚未激活。请工作空间管理员检查运行环境后刷新,不要盲目重复迁移。',
pluginHint: '缺少插件?前往扩展安装或启用对应运行器,然后刷新预览。',
extensions: '打开扩展',
results: '逐条迁移结果',
selection: '已选择 {{count}} 条(最多 50 条)',
confirm: '我确认迁移所选流水线。',
refresh: '刷新预览',
execute: '迁移所选流水线',
legacyGate: '旧版配置在迁移前保持只读。为避免隐式转换,已禁用保存和调试。',
states: {
ready: '可迁移',
needs_plugin: '需要插件',
blocked: '已阻止',
already_current: '已是新版',
not_legacy: '非旧版',
activation_pending: '等待激活',
pending: '等待中',
migrated: '已迁移',
stale: '预览已过期',
failed: '失败',
},
};
@@ -0,0 +1,106 @@
export default {
complete: '完成',
checkingPlugin: '檢查外掛',
preparingPlugin: '準備安裝',
installDetails: '安裝詳情',
stageProgress: '階段進度',
downloaded: '已下載 {{size}}',
processed: '已處理 {{completed}} / {{total}} 條流水線',
installErrors: {
plugin_version_unavailable:
'擴充市集尚未提供所需外掛版本,請等待上架後重試,或選擇僅遷移資料。',
plugin_download_timeout: '下載外掛逾時,請檢查網路後重試。',
plugin_marketplace_unavailable:
'擴充市集暫時無法提供外掛套件,請稍後重試。',
plugin_download_failed: '外掛下載失敗,請檢查網路後重試。',
dependency_prepare_failed: '外掛相依套件安裝失敗,請檢查執行環境後重試。',
plugin_launch_failed: '外掛啟動失敗,請檢查執行環境後重試。',
},
autoDescription:
'舊版執行方式已改為外掛。遷移全部流水線並保留現有設定,原設定會自動備份;遷移後開始新對話。',
viewPipelines: '查看流水線',
autoInstall: '自動安裝外掛並遷移',
dataOnly: '僅遷移資料',
dataOnlyHint: '適合離線或內網環境,遷移後請自行安裝對應的執行器外掛。',
installing: '正在安裝所需外掛…',
migrating: '正在遷移流水線…',
summary: '已遷移 {{migrated}} 條,{{remaining}} 條需要處理。',
installFailed:
'外掛安裝失敗。展開詳情查看失敗階段,處理後重試,或選擇僅遷移資料。',
activationRetryHint:
'檢查執行環境後重新整理,選取此流水線並確認,即可只重試啟用,不會再次遷移已儲存的設定。',
details: '遷移詳情',
notices: {
runtimeUnavailable:
'外掛執行環境未連線,請恢復連線後重試,或選擇僅遷移資料。',
executionFailed: '遷移執行失敗,請查看服務日誌後重試。',
pluginRequired: '請先安裝或啟用上方所示的執行器外掛,再重新整理預覽。',
legacyArchive:
'作用中的設定僅保留所選 Runner;全部舊 Runner 設定(包括未啟用的設定)保存在遷移備份中。',
contextDefaults:
'歷史上下文將採用新的容量預算與摘要預設值,不再按固定輪數截斷。',
modelReasoning: '各模型的推理設定會保留,並由主程式套用。',
serialTools: '遷移後工具呼叫仍依序執行。',
retrievalDefaults: '檢索採用新的 top-k 與結果長度預設值,請在遷移後檢查。',
boxReset:
'沙箱複用範圍會保留;舊容器狀態不遷移,後續按原複用規則建立沙箱。',
persistentHistory:
'新對話採用持久化、相互隔離的歷史記錄,不匯入原有遠端歷史。',
tweaksDefault: 'Langflow tweaks 預設為空物件。',
timeoutDefault: 'Dify 請求逾時預設為 30 秒。',
historyReset: '原有對話歷史與工作階段識別碼不遷移,遷移後開始新對話。',
sessionTitle: '新的 WeKnora 對話使用外掛產生的標題。',
aliasRepaired: '舊欄位名稱將對應至目前支援的名稱。',
nullDefault: '此空值將使用新外掛的預設值。',
outputPolicy: '現有推理內容顯示設定會保留。',
filteredVariables: '僅遷移支援的變數;保留變數由對話上下文提供。',
identityPreserved: '服務端使用者身分沿用舊設定的來源。',
newDefaults: '新增外掛選項採用已說明的預設值,原有受支援設定會保留。',
externalState: '遷移前請完成或取消等待中的遠端互動,其執行狀態不會轉移。',
pluginVersion: '請安裝所需外掛版本後重新整理。舊版本不支援此次遷移。',
schemaChanged:
'已安裝 Runner 的設定格式與遷移目標不一致,請檢查外掛版本後重新整理。',
runnerExcluded: '此流水線排除了所需 Runner 外掛,請先調整擴充設定。',
boxTemplateInvalid:
'沙箱複用範本格式有誤,請使用 {變數名};不支援位置參數、格式轉換或屬性存取。',
pendingInteraction: '有對話正在等待輸入,請完成或取消後再遷移。',
},
title: '流水線遷移',
description: '選擇要遷移的流水線。原設定會備份,遷移後將開始新對話。',
detected: '{{count}} 條流水線需要檢查遷移。',
review: '檢查遷移',
readOnly: '只有工作空間管理者可以遷移流水線。',
previewError: '無法取得預覽,請重新整理。',
submitting: '正在提交所選流水線…',
running: '正在遷移。關閉對話框不會取消任務。',
finished: '任務已結束,請逐條檢查下方結果。',
failed: '任務失敗,請逐條檢查下方結果。',
lost: '已失去任務狀態連線,結果未知。執行其他操作前請重新整理預覽。',
requestError: '請求未完成。請重新整理預覽後重新選擇。',
warningFallback: '遷移前請檢查此設定。',
blockerFallback: '此設定或執行狀態無法安全遷移。請處理後重新整理預覽。',
changedFields: '變更欄位',
activationHint:
'設定已儲存,但尚未啟用。請工作空間管理員檢查執行環境後重新整理,不要盲目重複遷移。',
pluginHint: '缺少外掛?前往擴充功能安裝或啟用對應執行器,再重新整理預覽。',
extensions: '開啟擴充功能',
results: '逐條遷移結果',
selection: '已選擇 {{count}} 條(最多 50 條)',
confirm: '我確認遷移所選流水線。',
refresh: '重新整理預覽',
execute: '遷移所選流水線',
legacyGate: '舊版設定在遷移前保持唯讀。為避免隱式轉換,已停用儲存和除錯。',
states: {
ready: '可遷移',
needs_plugin: '需要外掛',
blocked: '已阻止',
already_current: '已是新版',
not_legacy: '非舊版',
activation_pending: '等待啟用',
pending: '等待中',
migrated: '已遷移',
stale: '預覽已過期',
failed: '失敗',
},
};
+101 -7
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/ru-RU';
const ruRU = {
pipelineMigration,
sidebar: {
home: 'Главная',
extensions: 'Расширения',
@@ -15,6 +18,7 @@ const ruRU = {
editionCloud: 'Cloud',
},
common: {
customValue: 'Свой вариант',
login: 'Войти',
logout: 'Выйти',
accountOptions: 'Настройки',
@@ -76,7 +80,8 @@ const ruRU = {
privacyPolicy: 'Политикой конфиденциальности',
and: 'и',
dataCollectionPolicy: 'Политикой сбора данных',
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
dataCollectionPolicyUrl:
'https://langbot.app/docs/en/insight/data-collection-policy',
loading: 'Загрузка...',
fieldRequired: 'Это поле обязательно для заполнения',
or: 'или',
@@ -85,6 +90,11 @@ const ruRU = {
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
loginLocal: 'Войти с локальной учётной записью',
loginWithPassword: 'Войти с паролем',
loginWithPasskey: 'Войти с помощью Passkey',
passkeyLoginSuccess: 'Passkey успешно подтверждён, вход...',
passkeyLoginFailed: 'Не удалось войти с помощью Passkey',
passkeyNotSupported:
'Passkey не поддерживается в этом браузере или на устройстве',
spaceLoginTitle: 'Войти с аккаунтом LangBot',
spaceLoginDescription:
'Отсканируйте QR-код или перейдите по ссылке ниже для авторизации',
@@ -183,6 +193,37 @@ const ruRU = {
help: 'Помощь',
},
models: {
codex: {
account: 'Подписка ChatGPT',
description:
'Войдите в аккаунт ChatGPT. Подписка не связана с оплатой API OpenAI; доступные модели и лимиты зависят от тарифа.',
disconnected: 'Не подключено',
loading: 'Проверка подключения…',
starting: 'Начало входа…',
pending: 'Ожидание авторизации',
connected: 'Подключено',
expired: 'Срок входа истёк. Получите новый код.',
error: 'Не удалось войти. Проверьте подключение и повторите попытку.',
canceling: 'Отмена входа…',
saveAndSignIn: 'Сохранить и войти',
done: 'Готово',
instructions:
'Введите этот код на странице OpenAI. Не закрывайте это окно до завершения входа.',
copyCode: 'Копировать код',
copied: 'Скопировано',
copyManually: 'Выделите и скопируйте код вручную.',
continueAtOpenAI: 'Продолжить в OpenAI',
expiresAt: 'Код действителен до {{time}}.',
retrying: 'Соединение прервано. Автоматическая повторная попытка…',
cancelSignIn: 'Отменить вход',
tryAgain: 'Повторить',
signIn: 'Войти',
reconnect: 'Переподключить',
disconnect: 'Отключить',
disconnectConfirm:
'Отключить этого провайдера? Его модели перестанут работать до повторного входа. Подписка ChatGPT не будет отменена.',
confirmDisconnect: 'Подтвердить отключение',
},
title: 'Модели',
description: 'Настройка и управление моделями, используемыми в конвейерах',
createModel: 'Создать модель',
@@ -220,7 +261,7 @@ const ruRU = {
reasoningAbility: 'Рассуждение',
reasoningLevel: 'Уровень рассуждений',
reasoningLevels: {
providerDefault: 'По умолчанию провайдера',
providerDefault: 'Использовать настройки поставщика',
disabled: 'Выключено',
enabled: 'Включено',
minimal: 'Минимальный',
@@ -335,6 +376,8 @@ const ruRU = {
providerSaveError: 'Ошибка сохранения провайдера: ',
providerDeleted: 'Провайдер удалён',
providerDeleteError: 'Ошибка удаления провайдера: ',
deleteProviderCascadeConfirmation:
'Удалить этого провайдера и ВСЕ содержащиеся в нём модели? Это действие необратимо, его нельзя отменить.',
deleteProviderConfirmation:
'Вы уверены, что хотите удалить этого провайдера?',
loadError: 'Не удалось загрузить данные',
@@ -562,6 +605,17 @@ const ruRU = {
},
},
agents: {
monitoring: {
description:
'Просмотр события, ответа модели и вызовов инструментов для каждой задачи.',
empty:
'Запусков пока нет. Вызовите событие платформы или запустите отладку.',
input: 'Входные данные',
eventData: 'Данные события',
execution: 'Ход выполнения',
rawEvents: 'Исходные события',
inputUnavailable: 'Входные данные этого запуска не записаны.',
},
eventProcessor: {
configurations: 'Конфигурации обработчиков плагинов',
configTab: 'Настройки',
@@ -599,6 +653,7 @@ const ruRU = {
loadMore: 'Загрузить ещё',
activation:
'Установите плагин, создайте конфигурацию обработчика и привяжите бота.',
status_timeout: 'Время истекло',
status_pending: 'Ожидание',
status_running: 'Выполняется',
status_completed: 'Завершено',
@@ -1044,6 +1099,15 @@ const ruRU = {
connectionSuccess: 'Подключение успешно',
connectionFailed: 'Не удалось подключиться, проверьте URL',
connectionFailedStatus: 'Ошибка подключения',
connectionUnreachable:
'Сервер MCP недоступен. Проверьте, запущен ли он и доступен ли по сети.',
connectionTimeout:
'Время ожидания ответа MCP истекло. Проверьте сервис или увеличьте тайм-аут.',
connectionHttpError:
'Сервер MCP вернул HTTP {{status}}. Проверьте требования доступа и журналы сервера.',
oauthAuthorizationRequired: 'Требуется авторизация OAuth',
oauthAuthorizationRequiredSuggestion:
'Для этого MCP-сервера требуется вход через OAuth. OAuth-вход пока не поддерживается; если сервер это позволяет, добавьте заголовок Authorization вручную.',
boxDisabledStdioRefused:
'MCP-серверы в режиме stdio требуют песочницу Box, которая отключена в конфигурации (box.enabled = false).',
boxUnavailableStdioRefused:
@@ -1501,6 +1565,20 @@ const ruRU = {
bindSpaceWarning:
'После привязки ваш email для входа будет изменён с {{localEmail}} на email аккаунта LangBot.',
bindSpaceSuccess: 'Аккаунт LangBot успешно привязан',
passkeySectionTitle: 'Ключи доступа (Passkey)',
passkeySectionDesc:
'Безопасный вход без пароля с помощью биометрии или аппаратного ключа',
addPasskey: 'Добавить ключ доступа',
passkeyName: 'Название ключа',
passkeyNamePlaceholder: 'например, MacBook Touch ID, YubiKey',
passkeyCreated: 'Создан {{date}}',
passkeyLastUsed: 'Последнее использование: {{date}}',
noPasskeys: 'Нет зарегистрированных ключей доступа',
deletePasskeyConfirm:
'Вы уверены, что хотите удалить этот ключ доступа? Вы больше не сможете использовать его для входа.',
passkeyAddedSuccess: 'Ключ доступа успешно добавлен',
passkeyDeleteSuccess: 'Ключ доступа удален',
passkeyRenameSuccess: 'Ключ доступа успешно переименован',
bindSpaceFailed: 'Не удалось привязать аккаунт LangBot',
bindSpaceInvalidState:
'Недействительный запрос привязки. Повторите попытку из настроек аккаунта.',
@@ -1725,7 +1803,16 @@ const ruRU = {
queryVariables: {
title: 'Переменные запроса',
},
loadError: 'Не удалось загрузить данные мониторинга',
partialMessages:
'Показано {{shown}} из {{total}} сообщений. Трассировки диалогов могут быть неполными.',
partialModelCalls: 'Показано {{shown}} из {{total}} вызовов модели.',
partialToolCalls:
'Показано {{shown}} из {{total}} вызовов инструментов. Трассировки диалогов могут быть неполными.',
partialErrors: 'Показано {{shown}} из {{total}} ошибок.',
trafficChart: {
unavailable: 'Агрегированные данные трафика недоступны',
truncated: 'Диапазон трафика обрезан. Выберите более короткий период.',
title: 'Обзор трафика',
messages: 'Сообщения',
llmCalls: 'Вызовы LLM',
@@ -1866,6 +1953,8 @@ const ruRU = {
'Бот получил сообщение. Можно перейти к следующему шагу.',
messageReceivedLocalAccountWarning:
'Подключение бота настроено правильно, и сообщение получено. Поскольку вход выполнен не через аккаунт LangBot, вызовы модели могут завершаться ошибкой; перейдите к следующему шагу, чтобы добавить собственную модель.',
pageBotPreviewFailed:
'Не удалось загрузить тестовый чат. Сохраните настройки ещё раз, чтобы повторить попытку.',
pageBotTestPrompt:
'Бот для веб-страницы включён. Нажмите на значок чата в правом нижнем углу и отправьте сообщение, чтобы проверить полный сценарий диалога.',
pageBotTestNotice:
@@ -1887,14 +1976,18 @@ const ruRU = {
'Отслеживайте активность бота для проверки подключения к платформе.',
},
aiEngine: {
defaultModelUnavailable:
'Модель по умолчанию недоступна. Повторите попытку.',
defaultRunnerUnavailable: 'Local Agent ещё не готов. Повторите попытку.',
preparingDefault: 'Подготовка ИИ по умолчанию…',
title: 'Настройте ИИ-движок',
description:
'Выберите ИИ-движок, который будет управлять интеллектом вашего бота.',
optionalDescription:
'Этот шаг необязателен. Выберите, как продолжить настройку текущего Agent.',
'ИИ уже настроен. Используйте его или измените способ подключения.',
externalTitle: 'Подключить Agent внешней платформы',
externalDescription:
'Подключите Dify, n8n, Coze или другую платформу и замените Pipeline бота.',
'Подключите Dify, n8n, Coze или другой внешний Agent через плагин Runner.',
ownModelTitle: 'Использовать собственную модель',
ownModelDescription:
'Добавьте провайдера, затем найдите модель автоматически или укажите её вручную, чтобы завершить настройку.',
@@ -1907,7 +2000,7 @@ const ruRU = {
selectModelTitle: 'Выберите модель',
selectScannedModelTitle: 'Выберите модель',
selectScannedModelDescription:
'Выбранная модель станет основной моделью нового Pipeline, и бот переключится на неё.',
'Выбранная модель заменит основную модель в пайплайне этого бота.',
scanModelMode: 'Найти модели',
manualModelMode: 'Добавить вручную',
scanningModels: 'Поиск доступных моделей…',
@@ -1926,12 +2019,13 @@ const ruRU = {
manualModelOptions: 'Дополнительные возможности модели',
editProvider: 'Изменить провайдера',
rescanModels: 'Повторить поиск моделей',
moreFeaturesTitle: 'Добавить возможности Agent',
moreFeaturesTitle: 'Использовать настройки по умолчанию',
moreFeaturesDescription:
'Откройте рабочую панель, чтобы добавить инструменты, базы знаний и другие возможности только что автоматически созданному Agent.',
'Продолжить с Local Agent и рекомендованной моделью. Инструменты и базы знаний можно добавить позже.',
runnerDescription:
'Выберите Runner для внешнего Agent и настройте подключение.',
backToChoices: 'Вернуться к вариантам',
backToList: 'Вернуться к списку',
createExternal: 'Создать и привязать',
finishWithModel: 'Использовать выбранную модель и завершить',
openWorkbench: 'Открыть рабочую панель',
+100 -7
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/th-TH';
const thTH = {
pipelineMigration,
sidebar: {
home: 'หน้าแรก',
extensions: 'ส่วนขยาย',
@@ -14,6 +17,7 @@ const thTH = {
editionCloud: 'Cloud',
},
common: {
customValue: 'กำหนดเอง',
login: 'เข้าสู่ระบบ',
logout: 'ออกจากระบบ',
accountOptions: 'การตั้งค่า',
@@ -76,7 +80,8 @@ const thTH = {
privacyPolicy: 'นโยบายความเป็นส่วนตัว',
and: 'และ',
dataCollectionPolicy: 'นโยบายการเก็บรวบรวมข้อมูล',
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
dataCollectionPolicyUrl:
'https://langbot.app/docs/en/insight/data-collection-policy',
loading: 'กำลังโหลด...',
fieldRequired: 'ช่องนี้จำเป็นต้องกรอก',
or: 'หรือ',
@@ -85,6 +90,10 @@ const thTH = {
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น',
loginWithPassword: 'เข้าสู่ระบบด้วยรหัสผ่าน',
loginWithPasskey: 'เข้าสู่ระบบด้วย Passkey',
passkeyLoginSuccess: 'ยืนยัน Passkey สำเร็จ กำลังเข้าสู่ระบบ...',
passkeyLoginFailed: 'เข้าสู่ระบบด้วย Passkey ล้มเหลว',
passkeyNotSupported: 'เบราว์เซอร์หรืออุปกรณ์นี้ไม่รองรับ Passkey',
spaceLoginTitle: 'เข้าสู่ระบบด้วยบัญชี LangBot',
spaceLoginDescription:
'สแกน QR code หรือเข้าชมลิงก์ด้านล่างเพื่อยืนยันสิทธิ์',
@@ -180,6 +189,37 @@ const thTH = {
help: 'ขอความช่วยเหลือ',
},
models: {
codex: {
account: 'การสมัครสมาชิก ChatGPT',
description:
'ลงชื่อเข้าใช้ด้วยบัญชี ChatGPT การใช้งานผ่านการสมัครสมาชิกแยกจากการเรียกเก็บเงิน OpenAI API รุ่นโมเดลและขีดจำกัดการใช้งานขึ้นอยู่กับแพ็กเกจของคุณ',
disconnected: 'ยังไม่ได้เชื่อมต่อ',
loading: 'กำลังตรวจสอบการเชื่อมต่อ…',
starting: 'กำลังเริ่มลงชื่อเข้าใช้…',
pending: 'กำลังรอการอนุญาต',
connected: 'เชื่อมต่อแล้ว',
expired: 'การลงชื่อเข้าใช้หมดอายุ เริ่มใหม่เพื่อรับรหัสใหม่',
error: 'ไม่สามารถลงชื่อเข้าใช้ได้ ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง',
canceling: 'กำลังยกเลิกการลงชื่อเข้าใช้…',
saveAndSignIn: 'บันทึกและลงชื่อเข้าใช้',
done: 'เสร็จสิ้น',
instructions:
'ป้อนรหัสนี้บนหน้า OpenAI เปิดกล่องโต้ตอบนี้ไว้จนกว่าจะลงชื่อเข้าใช้เสร็จ',
copyCode: 'คัดลอกรหัส',
copied: 'คัดลอกแล้ว',
copyManually: 'เลือกรหัสและคัดลอกด้วยตนเอง',
continueAtOpenAI: 'ดำเนินการต่อที่ OpenAI',
expiresAt: 'รหัสหมดอายุเวลา {{time}}',
retrying: 'การเชื่อมต่อขัดข้อง กำลังลองใหม่โดยอัตโนมัติ…',
cancelSignIn: 'ยกเลิกการลงชื่อเข้าใช้',
tryAgain: 'ลองอีกครั้ง',
signIn: 'ลงชื่อเข้าใช้',
reconnect: 'เชื่อมต่อใหม่',
disconnect: 'ยกเลิกการเชื่อมต่อ',
disconnectConfirm:
'ยกเลิกการเชื่อมต่อผู้ให้บริการนี้หรือไม่? โมเดลจะหยุดทำงานจนกว่าคุณจะลงชื่อเข้าใช้อีกครั้ง การดำเนินการนี้ไม่ได้ยกเลิกการสมัครสมาชิก ChatGPT',
confirmDisconnect: 'ยืนยันการยกเลิกการเชื่อมต่อ',
},
title: 'โมเดล',
description: 'กำหนดค่าและจัดการโมเดลที่สามารถใช้ใน Pipeline',
createModel: 'สร้างโมเดล',
@@ -216,7 +256,7 @@ const thTH = {
reasoningAbility: 'ความสามารถในการให้เหตุผล',
reasoningLevel: 'ระดับการให้เหตุผล',
reasoningLevels: {
providerDefault: 'ค่าเริ่มต้นของผู้ให้บริการ',
providerDefault: 'ใช้ค่าเริ่มต้นของผู้ให้บริการ',
disabled: 'ปิด',
enabled: 'เปิด',
minimal: 'ต่ำสุด',
@@ -323,6 +363,8 @@ const thTH = {
providerSaveError: 'บันทึกผู้ให้บริการล้มเหลว: ',
providerDeleted: 'ลบผู้ให้บริการแล้ว',
providerDeleteError: 'ลบผู้ให้บริการล้มเหลว: ',
deleteProviderCascadeConfirmation:
'ลบผู้ให้บริการนี้และโมเดลทั้งหมดที่อยู่ภายในหรือไม่? การดำเนินการนี้ไม่สามารถย้อนกลับหรือยกเลิกได้',
deleteProviderConfirmation: 'คุณแน่ใจหรือไม่ว่าต้องการลบผู้ให้บริการนี้?',
loadError: 'โหลดข้อมูลล้มเหลว',
chat: 'แชท',
@@ -548,6 +590,16 @@ const thTH = {
},
},
agents: {
monitoring: {
description:
'ดูเหตุการณ์เริ่มต้น ผลลัพธ์โมเดล และการเรียกเครื่องมือของแต่ละงาน',
empty: 'ยังไม่มีการทำงาน เริ่มเหตุการณ์หรือทดสอบการดีบักเพื่อดูบันทึก',
input: 'ข้อมูลเริ่มต้น',
eventData: 'ข้อมูลเหตุการณ์',
execution: 'ขั้นตอนการทำงาน',
rawEvents: 'เหตุการณ์ดิบ',
inputUnavailable: 'ไม่มีการบันทึกข้อมูลเริ่มต้นของการทำงานนี้',
},
eventProcessor: {
configurations: 'การตั้งค่าตัวประมวลผลปลั๊กอิน',
configTab: 'การตั้งค่า',
@@ -584,6 +636,7 @@ const thTH = {
destination: 'ปลายทางการส่ง',
loadMore: 'โหลดเพิ่มเติม',
activation: 'ติดตั้งปลั๊กอิน สร้างการตั้งค่าตัวประมวลผล แล้วเชื่อมโยงบอท',
status_timeout: 'หมดเวลา',
status_pending: 'รอดำเนินการ',
status_running: 'กำลังทำงาน',
status_completed: 'เสร็จสิ้น',
@@ -1020,6 +1073,15 @@ const thTH = {
connectionSuccess: 'เชื่อมต่อสำเร็จ',
connectionFailed: 'เชื่อมต่อล้มเหลว กรุณาตรวจสอบ URL',
connectionFailedStatus: 'เชื่อมต่อล้มเหลว',
connectionUnreachable:
'ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์ MCP ได้ โปรดตรวจสอบว่าบริการทำงานและเข้าถึงได้',
connectionTimeout:
'เซิร์ฟเวอร์ MCP ไม่ตอบกลับภายในเวลาที่กำหนด โปรดตรวจสอบบริการหรือเพิ่มเวลารอ',
connectionHttpError:
'เซิร์ฟเวอร์ MCP ส่งคืน HTTP {{status}} โปรดตรวจสอบข้อกำหนดการเข้าถึงและบันทึกของเซิร์ฟเวอร์',
oauthAuthorizationRequired: 'ต้องมีการอนุญาต OAuth',
oauthAuthorizationRequiredSuggestion:
'MCP server นี้ต้องเข้าสู่ระบบด้วย OAuth ซึ่งยังไม่รองรับในขณะนี้ หาก server อนุญาต คุณสามารถเพิ่ม Authorization header ด้วยตนเองได้',
boxDisabledStdioRefused:
'MCP server แบบ stdio ต้องใช้ Sandbox Box ซึ่งถูกปิดใช้งานในการตั้งค่า (box.enabled = false)',
boxUnavailableStdioRefused:
@@ -1469,6 +1531,20 @@ const thTH = {
bindSpaceWarning:
'หลังจากผูกแล้ว อีเมลเข้าสู่ระบบของคุณจะเปลี่ยนจาก {{localEmail}} เป็นอีเมลบัญชี LangBot',
bindSpaceSuccess: 'ผูกบัญชี LangBot สำเร็จ',
passkeySectionTitle: 'พาสคีย์ (Passkey)',
passkeySectionDesc:
'เข้าสู่ระบบอย่างปลอดภัยโดยไม่ต้องใช้รหัสผ่านด้วยไบโอเมตริกซ์หรือคีย์ความปลอดภัย',
addPasskey: 'เพิ่มพาสคีย์',
passkeyName: 'ชื่อคีย์',
passkeyNamePlaceholder: 'เช่น MacBook Touch ID, YubiKey',
passkeyCreated: 'สร้างเมื่อ {{date}}',
passkeyLastUsed: 'ใช้งานล่าสุด: {{date}}',
noPasskeys: 'ยังไม่มีพาสคีย์ที่ลงทะเบียน',
deletePasskeyConfirm:
'คุณแน่ใจหรือไม่ว่าต้องการลบพาสคีย์นี้? คุณจะไม่สามารถใช้คีย์นี้เข้าสู่ระบบได้อีก',
passkeyAddedSuccess: 'เพิ่มพาสคีย์สำเร็จ',
passkeyDeleteSuccess: 'ลบพาสคีย์แล้ว',
passkeyRenameSuccess: 'เปลี่ยนชื่อพาสคีย์สำเร็จ',
bindSpaceFailed: 'ผูกบัญชี LangBot ล้มเหลว',
bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี',
setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
@@ -1691,7 +1767,17 @@ const thTH = {
queryVariables: {
title: 'ตัวแปรคำค้นหา',
},
loadError: 'โหลดข้อมูลการตรวจสอบไม่สำเร็จ',
partialMessages:
'แสดง {{shown}} จาก {{total}} ข้อความ ประวัติการสนทนาอาจไม่ครบถ้วน',
partialModelCalls: 'แสดง {{shown}} จาก {{total}} การเรียกโมเดล',
partialToolCalls:
'แสดง {{shown}} จาก {{total}} การเรียกเครื่องมือ ประวัติการสนทนาอาจไม่ครบถ้วน',
partialErrors: 'แสดง {{shown}} จาก {{total}} ข้อผิดพลาด',
trafficChart: {
unavailable: 'ไม่มีข้อมูลสรุปปริมาณการใช้งาน',
truncated:
'ช่วงข้อมูลปริมาณการใช้งานถูกตัดทอน โปรดเลือกช่วงเวลาที่สั้นลง',
title: 'ภาพรวมปริมาณการใช้งาน',
messages: 'ข้อความ',
llmCalls: 'การเรียก LLM',
@@ -1830,6 +1916,8 @@ const thTH = {
messageReceived: 'Bot ได้รับข้อความแล้ว คุณสามารถไปยังขั้นตอนถัดไปได้',
messageReceivedLocalAccountWarning:
'การเชื่อมต่อฝั่ง Bot ได้รับการกำหนดค่าอย่างถูกต้องและได้รับข้อความแล้ว เนื่องจากคุณไม่ได้เข้าสู่ระบบด้วยบัญชี LangBot การเรียกใช้โมเดลอาจล้มเหลว โปรดไปยังขั้นตอนถัดไปเพื่อเพิ่มโมเดลของคุณเอง',
pageBotPreviewFailed:
'โหลดแชททดสอบไม่สำเร็จ โปรดบันทึกการตั้งค่าอีกครั้งเพื่อลองใหม่',
pageBotTestPrompt:
'เปิดใช้งาน Page Bot แล้ว คลิกฟองแชตที่มุมขวาล่างและส่งข้อความเพื่อตรวจสอบขั้นตอนการสนทนาทั้งหมด',
pageBotTestNotice:
@@ -1849,13 +1937,17 @@ const thTH = {
'ตรวจสอบกิจกรรม Bot เพื่อยืนยันว่าการเชื่อมต่อแพลตฟอร์มทำงานอยู่',
},
aiEngine: {
defaultModelUnavailable:
'ไม่มีโมเดลเริ่มต้นที่พร้อมใช้งาน โปรดลองอีกครั้ง',
defaultRunnerUnavailable: 'Local Agent ยังไม่พร้อม โปรดลองอีกครั้ง',
preparingDefault: 'กำลังเตรียม AI เริ่มต้น…',
title: 'กำหนดค่าเครื่องมือ AI',
description: 'เลือกเครื่องมือ AI ที่จะขับเคลื่อนความฉลาดของ Bot',
optionalDescription:
'ขั้นตอนนี้ไม่บังคับ เลือกวิธีที่คุณต้องการดำเนินการต่อกับ Agent ปัจจุบัน',
'AI เริ่มต้นพร้อมแล้ว ใช้งานได้ทันทีหรือเลือกวิธีเชื่อมต่ออื่น',
externalTitle: 'เชื่อมต่อ Agent จากแพลตฟอร์มภายนอก',
externalDescription:
'เชื่อมต่อ Dify, n8n, Coze หรือแพลตฟอร์มอื่น และแทนที่ Pipeline ของ Bot',
'เชื่อมต่อ Dify, n8n, Coze หรือ Agent ภายนอกผ่านปลั๊กอิน Runner',
ownModelTitle: 'ใช้โมเดลของฉันเอง',
ownModelDescription:
'เพิ่มผู้ให้บริการ แล้วสแกนหรือกรอกโมเดลด้วยตนเองเพื่อเสร็จสิ้นการตั้งค่า',
@@ -1868,7 +1960,7 @@ const thTH = {
selectModelTitle: 'เลือกโมเดล',
selectScannedModelTitle: 'เลือกโมเดล',
selectScannedModelDescription:
'โมเดลที่เลือกจะเป็นโมเดลหลักของ Pipeline ใหม่ และ Bot จะเปลี่ยนไปใช้โมเดลนี้',
'โมเดลที่เลือกจะแทนที่โมเดลหลักในไปป์ไลน์ของบอทนี้',
scanModelMode: 'สแกนโมเดล',
manualModelMode: 'เพิ่มด้วยตนเอง',
scanningModels: 'กำลังสแกนโมเดลที่พร้อมใช้งาน…',
@@ -1885,12 +1977,13 @@ const thTH = {
manualModelOptions: 'ความสามารถเพิ่มเติมของโมเดล',
editProvider: 'แก้ไขผู้ให้บริการ',
rescanModels: 'สแกนโมเดลอีกครั้ง',
moreFeaturesTitle: 'เพิ่มความสามารถให้ Agent',
moreFeaturesTitle: 'ใช้การตั้งค่าเริ่มต้น',
moreFeaturesDescription:
'เปิดหน้าทำงานเพื่อเพิ่มเครื่องมือ ฐานความรู้ และความสามารถอื่น ๆ ให้ Agent ที่เพิ่งสร้างขึ้นโดยอัตโนมัติ',
'ใช้ Local Agent และโมเดลที่แนะนำต่อไป เพิ่มเครื่องมือและฐานความรู้ได้ภายหลัง',
runnerDescription:
'เลือก Runner สำหรับ Agent ภายนอกและกำหนดค่าการเชื่อมต่อ',
backToChoices: 'กลับไปยังตัวเลือก',
backToList: 'กลับไปยังรายการ',
createExternal: 'สร้างและผูก',
finishWithModel: 'ใช้โมเดลที่เลือกและเสร็จสิ้น',
openWorkbench: 'เปิดหน้าทำงาน',
+99 -7
View File
@@ -1,4 +1,7 @@
import pipelineMigration from './pipeline-migration/vi-VN';
const viVN = {
pipelineMigration,
sidebar: {
home: 'Trang chủ',
extensions: 'Tiện ích mở rộng',
@@ -15,6 +18,7 @@ const viVN = {
editionCloud: 'Cloud',
},
common: {
customValue: 'Tùy chỉnh',
login: 'Đăng nhập',
logout: 'Đăng xuất',
accountOptions: 'Cài đặt',
@@ -77,7 +81,8 @@ const viVN = {
privacyPolicy: 'Chính sách bảo mật',
and: 'và',
dataCollectionPolicy: 'Chính sách thu thập dữ liệu',
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
dataCollectionPolicyUrl:
'https://langbot.app/docs/en/insight/data-collection-policy',
loading: 'Đang tải...',
fieldRequired: 'Trường này là bắt buộc',
or: 'hoặc',
@@ -86,6 +91,10 @@ const viVN = {
'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây',
loginLocal: 'Đăng nhập với tài khoản cục bộ',
loginWithPassword: 'Đăng nhập bằng mật khẩu',
loginWithPasskey: 'Đăng nhập bằng Passkey',
passkeyLoginSuccess: 'Xác thực Passkey thành công, đang đăng nhập...',
passkeyLoginFailed: 'Đăng nhập bằng Passkey thất bại',
passkeyNotSupported: 'Trình duyệt hoặc thiết bị này không hỗ trợ Passkey',
spaceLoginTitle: 'Đăng nhập bằng tài khoản LangBot',
spaceLoginDescription:
'Quét mã QR hoặc truy cập liên kết bên dưới để ủy quyền',
@@ -183,6 +192,37 @@ const viVN = {
help: 'Trợ giúp',
},
models: {
codex: {
account: 'Gói đăng ký ChatGPT',
description:
'Đăng nhập bằng tài khoản ChatGPT. Gói đăng ký độc lập với thanh toán API OpenAI; mô hình và giới hạn sử dụng tùy thuộc vào gói của bạn.',
disconnected: 'Chưa kết nối',
loading: 'Đang kiểm tra kết nối…',
starting: 'Đang bắt đầu đăng nhập…',
pending: 'Đang chờ cấp quyền',
connected: 'Đã kết nối',
expired: 'Phiên đăng nhập đã hết hạn. Hãy lấy mã mới.',
error: 'Không thể đăng nhập. Kiểm tra kết nối và thử lại.',
canceling: 'Đang hủy đăng nhập…',
saveAndSignIn: 'Lưu và đăng nhập',
done: 'Xong',
instructions:
'Nhập mã này trên trang OpenAI. Giữ hộp thoại này mở cho đến khi đăng nhập hoàn tất.',
copyCode: 'Sao chép mã',
copied: 'Đã sao chép',
copyManually: 'Chọn và sao chép mã thủ công.',
continueAtOpenAI: 'Tiếp tục tại OpenAI',
expiresAt: 'Mã hết hạn lúc {{time}}.',
retrying: 'Kết nối bị gián đoạn. Đang tự động thử lại…',
cancelSignIn: 'Hủy đăng nhập',
tryAgain: 'Thử lại',
signIn: 'Đăng nhập',
reconnect: 'Kết nối lại',
disconnect: 'Ngắt kết nối',
disconnectConfirm:
'Ngắt kết nối nhà cung cấp này? Các mô hình sẽ ngừng hoạt động cho đến khi bạn đăng nhập lại. Thao tác này không hủy gói ChatGPT của bạn.',
confirmDisconnect: 'Xác nhận ngắt kết nối',
},
title: 'Mô hình',
description:
'Cấu hình và quản lý các mô hình có thể sử dụng trong Pipeline',
@@ -220,7 +260,7 @@ const viVN = {
reasoningAbility: 'Khả năng suy luận',
reasoningLevel: 'Mức độ suy luận',
reasoningLevels: {
providerDefault: 'Mặc định của nhà cung cấp',
providerDefault: 'Dùng giá trị mặc định của nhà cung cấp',
disabled: 'Tắt',
enabled: 'Bật',
minimal: 'Tối thiểu',
@@ -331,6 +371,8 @@ const viVN = {
providerSaveError: 'Lưu nhà cung cấp thất bại: ',
providerDeleted: 'Đã xóa nhà cung cấp',
providerDeleteError: 'Xóa nhà cung cấp thất bại: ',
deleteProviderCascadeConfirmation:
'Xóa nhà cung cấp này và TẤT CẢ mô hình bên trong? Hành động này không thể đảo ngược hoặc hoàn tác.',
deleteProviderConfirmation:
'Bạn có chắc chắn muốn xóa nhà cung cấp này không?',
loadError: 'Tải dữ liệu thất bại',
@@ -558,6 +600,16 @@ const viVN = {
},
},
agents: {
monitoring: {
description:
'Xem sự kiện kích hoạt, đầu ra mô hình và quá trình gọi công cụ của mỗi tác vụ.',
empty: 'Chưa có lượt chạy. Kích hoạt sự kiện hoặc chạy thử gỡ lỗi.',
input: 'Đầu vào kích hoạt',
eventData: 'Dữ liệu sự kiện',
execution: 'Quá trình thực thi',
rawEvents: 'Sự kiện gốc',
inputUnavailable: 'Đầu vào của lượt chạy này chưa được ghi lại.',
},
eventProcessor: {
configurations: 'Cấu hình bộ xử lý plugin',
configTab: 'Cấu hình',
@@ -594,6 +646,7 @@ const viVN = {
destination: 'Đích gửi',
loadMore: 'Tải thêm',
activation: 'Cài plugin, tạo cấu hình bộ xử lý rồi liên kết bot.',
status_timeout: 'Hết thời gian',
status_pending: 'Đang chờ',
status_running: 'Đang chạy',
status_completed: 'Hoàn tất',
@@ -1036,6 +1089,15 @@ const viVN = {
connectionSuccess: 'Kết nối thành công',
connectionFailed: 'Kết nối thất bại, vui lòng kiểm tra URL',
connectionFailedStatus: 'Kết nối thất bại',
connectionUnreachable:
'Không thể kết nối tới máy chủ MCP. Hãy kiểm tra dịch vụ và kết nối mạng.',
connectionTimeout:
'Máy chủ MCP không phản hồi kịp thời. Hãy kiểm tra dịch vụ hoặc tăng thời gian chờ.',
connectionHttpError:
'Máy chủ MCP trả về HTTP {{status}}. Hãy kiểm tra yêu cầu truy cập và nhật ký máy chủ.',
oauthAuthorizationRequired: 'Yêu cầu ủy quyền OAuth',
oauthAuthorizationRequiredSuggestion:
'MCP server này yêu cầu đăng nhập OAuth. Hiện chưa hỗ trợ đăng nhập OAuth; hãy thêm thủ công tiêu đề Authorization nếu server cho phép.',
boxDisabledStdioRefused:
'MCP server ở chế độ stdio cần Sandbox Box, hiện đã bị tắt trong cấu hình (box.enabled = false).',
boxUnavailableStdioRefused:
@@ -1494,6 +1556,20 @@ const viVN = {
bindSpaceWarning:
'Sau khi liên kết, email đăng nhập của bạn sẽ được đổi từ {{localEmail}} sang email tài khoản LangBot.',
bindSpaceSuccess: 'Liên kết tài khoản LangBot thành công',
passkeySectionTitle: 'Mã khóa truy cập (Passkey)',
passkeySectionDesc:
'Đăng nhập an toàn không cần mật khẩu bằng sinh trắc học hoặc khóa bảo mật',
addPasskey: 'Thêm mã khóa truy cập',
passkeyName: 'Tên khóa',
passkeyNamePlaceholder: 'ví dụ: MacBook Touch ID, YubiKey',
passkeyCreated: 'Được tạo vào {{date}}',
passkeyLastUsed: 'Sử dụng lần cuối: {{date}}',
noPasskeys: 'Chưa có mã khóa truy cập nào được đăng ký',
deletePasskeyConfirm:
'Bạn có chắc chắn muốn xóa mã khóa truy cập này? Bạn sẽ không thể sử dụng nó để đăng nhập nữa.',
passkeyAddedSuccess: 'Đã thêm mã khóa truy cập thành công',
passkeyDeleteSuccess: 'Đã xóa mã khóa truy cập',
passkeyRenameSuccess: 'Đã đổi tên mã khóa truy cập thành công',
bindSpaceFailed: 'Liên kết tài khoản LangBot thất bại',
bindSpaceInvalidState:
'Yêu cầu liên kết không hợp lệ. Vui lòng thử lại từ cài đặt tài khoản.',
@@ -1717,7 +1793,17 @@ const viVN = {
queryVariables: {
title: 'Biến truy vấn',
},
loadError: 'Không thể tải dữ liệu giám sát',
partialMessages:
'Hiển thị {{shown}} trên {{total}} tin nhắn. Dấu vết hội thoại có thể không đầy đủ.',
partialModelCalls: 'Hiển thị {{shown}} trên {{total}} lượt gọi mô hình.',
partialToolCalls:
'Hiển thị {{shown}} trên {{total}} lượt gọi công cụ. Dấu vết hội thoại có thể không đầy đủ.',
partialErrors: 'Hiển thị {{shown}} trên {{total}} lỗi.',
trafficChart: {
unavailable: 'Không có dữ liệu tổng hợp lưu lượng',
truncated:
'Phạm vi lưu lượng bị cắt ngắn. Hãy chọn khoảng thời gian ngắn hơn.',
title: 'Tổng quan lưu lượng',
messages: 'Tin nhắn',
llmCalls: 'Cuộc gọi LLM',
@@ -1857,6 +1943,8 @@ const viVN = {
'Bot đã nhận được tin nhắn. Bạn có thể tiếp tục sang bước tiếp theo.',
messageReceivedLocalAccountWarning:
'Kết nối phía Bot đã được cấu hình đúng và đã nhận được tin nhắn. Vì bạn không đăng nhập bằng tài khoản LangBot, lệnh gọi mô hình có thể thất bại; hãy tiếp tục sang bước tiếp theo để thêm mô hình của riêng bạn.',
pageBotPreviewFailed:
'Không thể tải cuộc trò chuyện thử nghiệm. Hãy lưu lại cấu hình để thử lại.',
pageBotTestPrompt:
'Page Bot đã được bật. Nhấp vào bong bóng trò chuyện ở góc dưới bên phải và gửi tin nhắn để xác minh toàn bộ luồng hội thoại.',
pageBotTestNotice:
@@ -1877,13 +1965,16 @@ const viVN = {
'Giám sát hoạt động Bot để xác minh kết nối nền tảng đang hoạt động.',
},
aiEngine: {
defaultModelUnavailable: 'Chưa có mô hình mặc định. Vui lòng thử lại.',
defaultRunnerUnavailable: 'Local Agent chưa sẵn sàng. Vui lòng thử lại.',
preparingDefault: 'Đang chuẩn bị AI mặc định…',
title: 'Cấu hình công cụ AI',
description: 'Chọn công cụ AI sẽ cung cấp trí tuệ cho Bot của bạn.',
optionalDescription:
'Bước này không bắt buộc. Hãy chọn cách bạn muốn tiếp tục với Agent hiện tại.',
'AI mặc định đã sẵn sàng. Bạn có thể dùng ngay hoặc đổi cách kết nối.',
externalTitle: 'Kết nối Agent từ nền tảng bên ngoài',
externalDescription:
'Kết nối Dify, n8n, Coze hoặc nền tảng khác và thay thế Pipeline của Bot.',
'Kết nối Dify, n8n, Coze hoặc Agent bên ngoài qua plugin Runner.',
ownModelTitle: 'Sử dụng mô hình của riêng tôi',
ownModelDescription:
'Thêm nhà cung cấp, sau đó quét hoặc nhập mô hình thủ công để hoàn tất thiết lập.',
@@ -1896,7 +1987,7 @@ const viVN = {
selectModelTitle: 'Chọn mô hình',
selectScannedModelTitle: 'Chọn mô hình',
selectScannedModelDescription:
'Mô hình đã chọn sẽ là mô hình chính của Pipeline mới và Bot sẽ chuyển sang sử dụng mô hình đó.',
'Mô hình đã chọn sẽ thay thế mô hình chính trong pipeline của bot này.',
scanModelMode: 'Quét mô hình',
manualModelMode: 'Thêm thủ công',
scanningModels: 'Đang quét các mô hình khả dụng…',
@@ -1915,11 +2006,12 @@ const viVN = {
manualModelOptions: 'Khả năng mô hình tùy chọn',
editProvider: 'Chỉnh sửa nhà cung cấp',
rescanModels: 'Quét lại mô hình',
moreFeaturesTitle: 'Thêm khả năng cho Agent',
moreFeaturesTitle: 'Dùng cấu hình mặc định',
moreFeaturesDescription:
'Mở bàn làm việc để thêm công cụ, cơ sở tri thức và các khả năng khác cho Agent vừa được tạo tự động.',
'Tiếp tục dùng Local Agent và mô hình đề xuất. Có thể thêm công cụ và cơ sở tri thức sau.',
runnerDescription: 'Chọn Runner cho Agent bên ngoài và cấu hình kết nối.',
backToChoices: 'Quay lại các tùy chọn',
backToList: 'Quay lại danh sách',
createExternal: 'Tạo và liên kết',
finishWithModel: 'Sử dụng mô hình đã chọn và hoàn tất',
openWorkbench: 'Mở bàn làm việc',
+99 -34
View File
@@ -1,3 +1,5 @@
import pipelineMigration from './pipeline-migration/zh-Hans';
const zhHans = {
sidebarGuide: {
label: '工作台导览',
@@ -159,6 +161,7 @@ const zhHans = {
},
},
},
pipelineMigration,
sidebar: {
home: '首页',
extensions: '扩展',
@@ -174,6 +177,7 @@ const zhHans = {
editionCloud: 'Cloud',
},
common: {
customValue: '自定义',
loadFailed: '加载失败,请重试。',
login: '登录',
logout: '退出登录',
@@ -242,7 +246,8 @@ const zhHans = {
privacyPolicy: '隐私政策',
and: '和',
dataCollectionPolicy: '数据收集政策',
dataCollectionPolicyUrl: 'https://link.langbot.app/zh/docs/data-policy',
dataCollectionPolicyUrl:
'https://langbot.app/docs/zh/insight/data-collection-policy',
loading: '加载中...',
fieldRequired: '此字段为必填项',
or: '或',
@@ -250,6 +255,10 @@ const zhHans = {
spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
loginLocal: '使用本地账号登录',
loginWithPassword: '通过密码登录',
loginWithPasskey: '使用 Passkey 登录',
passkeyLoginSuccess: 'Passkey 验证成功,正在登录...',
passkeyLoginFailed: 'Passkey 登录失败',
passkeyNotSupported: '当前浏览器或设备不支持 Passkey',
spaceLoginTitle: '通过 LangBot 账号登录',
spaceLoginDescription: '扫描二维码或访问下方链接进行授权',
spaceLoginUserCode: '您的验证码',
@@ -338,6 +347,37 @@ const zhHans = {
help: '查看帮助文档',
},
models: {
codex: {
account: 'ChatGPT 订阅',
description:
'使用 ChatGPT 账号登录。订阅权限与 OpenAI API 计费相互独立,可用模型和使用额度取决于你的订阅方案。',
disconnected: '未连接',
loading: '正在检查连接…',
starting: '正在开始登录…',
pending: '等待授权',
connected: '已连接',
expired: '登录已过期,请重试以获取新验证码。',
error: '无法登录,请检查网络连接后重试。',
canceling: '正在取消登录…',
saveAndSignIn: '保存并登录',
done: '完成',
instructions:
'在 OpenAI 页面输入此验证码,登录完成前请保持此对话框打开。',
copyCode: '复制验证码',
copied: '已复制',
copyManually: '请选中并手动复制验证码。',
continueAtOpenAI: '前往 OpenAI 继续',
expiresAt: '验证码将于 {{time}} 过期。',
retrying: '连接中断,正在自动重试…',
cancelSignIn: '取消登录',
tryAgain: '重试',
signIn: '登录',
reconnect: '重新连接',
disconnect: '断开连接',
disconnectConfirm:
'断开此供应商的连接?重新登录前,其模型将无法使用。此操作不会取消你的 ChatGPT 订阅。',
confirmDisconnect: '确认断开',
},
title: '模型配置',
description: '配置和管理可在流水线中使用的模型',
createModel: '创建对话模型',
@@ -374,7 +414,7 @@ const zhHans = {
reasoningAbility: '思考能力',
reasoningLevel: '思考档位',
reasoningLevels: {
providerDefault: 'Provider 默认',
providerDefault: '使用供应商默认值',
disabled: '关闭',
enabled: '开启',
minimal: '最低',
@@ -482,6 +522,8 @@ const zhHans = {
providerSaveError: '保存供应商失败:',
providerDeleted: '供应商已删除',
providerDeleteError: '删除供应商失败:',
deleteProviderCascadeConfirmation:
'确定删除此供应商及其包含的所有模型吗?此操作不可逆,无法撤销。',
deleteProviderConfirmation: '你确定要删除这个供应商吗?',
loadError: '加载数据失败',
chat: '对话',
@@ -872,6 +914,15 @@ const zhHans = {
},
},
agents: {
monitoring: {
description: '查看每次任务的触发事件、模型输出和工具执行过程。',
empty: '暂无运行记录。触发平台事件或运行调试后,可在这里查看。',
input: '触发输入',
eventData: '事件数据',
execution: '执行过程',
rawEvents: '原始运行事件',
inputUnavailable: '这次运行未记录输入内容。',
},
eventProcessor: {
configurations: '插件处理器配置',
configTab: '配置',
@@ -907,6 +958,7 @@ const zhHans = {
destination: '投递目标',
loadMore: '加载更多',
activation: '安装插件,创建处理器配置,再绑定机器人。',
status_timeout: '已超时',
status_pending: '待执行',
status_running: '运行中',
status_completed: '已完成',
@@ -1500,6 +1552,14 @@ const zhHans = {
connectionSuccess: '连接成功',
connectionFailed: '连接失败,请检查URL',
connectionFailedStatus: '连接失败',
connectionUnreachable:
'无法连接到 MCP 服务器,请确认服务已启动且网络可达。',
connectionTimeout: 'MCP 服务器响应超时,请检查服务状态或增加超时时间。',
connectionHttpError:
'MCP 服务器返回 HTTP {{status}},请检查访问要求和服务器日志。',
oauthAuthorizationRequired: '需要 OAuth 授权',
oauthAuthorizationRequiredSuggestion:
'此 MCP 服务器需要 OAuth 登录。当前尚不支持 OAuth 登录;如果服务器允许,可以手动添加 Authorization 请求头。',
boxDisabledStdioRefused:
'Stdio 模式的 MCP 服务器依赖 Box 沙箱,目前已在配置中禁用(box.enabled = false)。',
boxUnavailableStdioRefused:
@@ -1944,6 +2004,19 @@ const zhHans = {
bindSpaceWarning:
'绑定后,您的登录邮箱将从 {{localEmail}} 更改为 LangBot 账号的邮箱。',
bindSpaceSuccess: 'LangBot 账号绑定成功',
passkeySectionTitle: '通行密钥 (Passkey)',
passkeySectionDesc: '使用指纹、面容或硬件安全密钥免密安全登录',
addPasskey: '添加通行密钥',
passkeyName: '密钥名称',
passkeyNamePlaceholder: '例如:MacBook Touch ID、YubiKey',
passkeyCreated: '创建于 {{date}}',
passkeyLastUsed: '上次使用: {{date}}',
noPasskeys: '暂未绑定任何通行密钥',
deletePasskeyConfirm:
'确定要删除此通行密钥吗?删除后将无法使用该密钥登录。',
passkeyAddedSuccess: '通行密钥添加成功',
passkeyDeleteSuccess: '通行密钥已删除',
passkeyRenameSuccess: '通行密钥重命名成功',
bindSpaceFailed: '绑定 LangBot 账号失败',
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
setPasswordHint: '设置密码后可使用邮箱密码登录',
@@ -2242,7 +2315,16 @@ const zhHans = {
queryVariables: {
title: '查询变量',
},
loadError: '监控数据加载失败',
partialMessages:
'显示 {{total}} 条消息中的 {{shown}} 条,对话轨迹可能不完整。',
partialModelCalls: '显示 {{total}} 次模型调用中的 {{shown}} 次。',
partialToolCalls:
'显示 {{total}} 次工具调用中的 {{shown}} 次,对话轨迹可能不完整。',
partialErrors: '显示 {{total}} 条错误中的 {{shown}} 条。',
trafficChart: {
unavailable: '流量聚合数据不可用',
truncated: '流量时间范围已截断,请选择更短的时间范围。',
title: '流量概览',
messages: '消息数',
llmCalls: 'LLM调用',
@@ -2488,7 +2570,7 @@ const zhHans = {
next: '下一步',
finish: '创建并部署',
confirmCreateBot: '确定,创建机器人',
createSuccess: '处理器已创建并关联到机器人!',
createSuccess: '流水线已创建并关联到机器人!',
botCreateSuccess: '机器人创建成功!',
botSaveSuccess: '机器人配置已保存并启用!',
createError: '创建资源失败',
@@ -2496,36 +2578,13 @@ const zhHans = {
completeSaveError: '保存完成状态失败,请重试。',
step: {
platform: '平台接入',
scenarioChannel: '场景与频道',
botConfig: '机器人配置',
aiEngine: 'AI 引擎',
done: '完成',
},
scenario: {
title: '这个机器人要完成什么?',
description: '先选择一个主要结果,机器人创建后还可以继续添加其他行为。',
messageReply: '回复收到的消息',
messageReplyDescription: '使用 AI Pipeline 回复私聊或群聊消息。',
welcomeMembers: '欢迎新成员',
welcomeMembersDescription: '有人加入群组时运行 Agent。',
welcomeMembersPrompt:
'用简短、友好的消息欢迎新群成员。有成员和群组上下文时请合理使用,不要提及内部事件名称或系统细节。',
handleDepartures: '处理成员离群',
handleDeparturesDescription: '有人离开或被移出群组时运行 Agent。',
handleDeparturesPrompt:
'当适合公开回应时,用简短、尊重的消息处理群成员离开。不要猜测成员离开的原因,也不要提及内部事件名称。',
handleModeration: '处理群管理事件',
handleModerationDescription: '群成员受到限制时运行 Agent。',
handleModerationPrompt:
'仅根据现有上下文,写一条简洁、中立的群管理通知,说明成员受到的限制。不要编造细节或提及内部事件名称。',
pipelineBadge: 'Pipeline',
agentBadge: 'Agent',
},
platform: {
title: '选择频道',
description: '这里只显示支持所选场景的频道。',
chooseScenarioFirst: '请先选择场景,再查看可用频道。',
noCompatiblePlatforms: '当前安装的频道都不支持这个场景。',
title: '选择平台',
description: '选择机器人要接入的消息平台。',
},
botConfig: {
title: '配置机器人',
@@ -2537,6 +2596,7 @@ const zhHans = {
messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。',
messageReceivedLocalAccountWarning:
'机器人侧已配置正常并成功收到 IM 消息。当前未通过 LangBot Account 登录,模型调用可能报错;可以进入下一步添加自己的模型。',
pageBotPreviewFailed: '测试聊天加载失败,请重新保存配置后重试。',
pageBotTestPrompt:
'页面机器人已启用。点击右下角聊天气泡并发送一条消息,验证完整对话链路。',
pageBotTestNotice: '仅供测试使用,请嵌入代码到真实外部网页。',
@@ -2552,12 +2612,16 @@ const zhHans = {
logsDescription: '监控机器人活动,确认平台连接是否正常工作。',
},
aiEngine: {
defaultModelUnavailable: '暂无可用的默认对话模型,请重试。',
defaultRunnerUnavailable: '本地 Agent 尚未就绪,请重试。',
preparingDefault: '正在准备默认 AI…',
title: '选择 AI 引擎',
description: '选择驱动机器人智能的 AI 引擎。',
optionalDescription: '这一步可选。选择接下来要如何完善当前 Agent。',
optionalDescription:
'默认 AI 已配置好,你可以直接使用,也可以选择其他接入方式。',
externalTitle: '接入外部平台 Agent',
externalDescription:
'接入 Dify、n8n、Coze 等平台,并替换当前机器人的流水线。',
'通过运行器插件接入 Dify、n8n、Coze 等外部 Agent 平台。',
ownModelTitle: '改成使用自己的模型',
ownModelDescription:
'添加模型供应商,自动扫描或手动填写模型以快速完成引导。',
@@ -2569,7 +2633,7 @@ const zhHans = {
selectModelTitle: '选择模型',
selectScannedModelTitle: '选择一个模型',
selectScannedModelDescription:
'选中的模型将作为新流水线的主模型,机器人会切换到这条流水线。',
'选中的模型将替换当前机器人流水线的主模型。',
scanModelMode: '扫描模型',
manualModelMode: '手动添加',
scanningModels: '正在扫描可用模型…',
@@ -2584,11 +2648,12 @@ const zhHans = {
manualModelOptions: '可选模型能力',
editProvider: '修改供应商',
rescanModels: '重新扫描模型',
moreFeaturesTitle: '给现在的 Agent 配置更多功能',
moreFeaturesTitle: '使用默认配置',
moreFeaturesDescription:
'进入工作台,为刚刚自动生成的 Agent 添加工具、知识库等能力',
'继续使用本地 Agent 和推荐模型,之后可在流水线中添加工具、知识库等功能。',
runnerDescription: '选择外部 Agent 的运行器并完成连接配置。',
backToChoices: '返回选项',
backToList: '返回列表',
createExternal: '创建并绑定',
finishWithModel: '使用所选模型并完成',
openWorkbench: '进入工作台',
@@ -2619,7 +2684,7 @@ const zhHans = {
},
done: {
title: '一切就绪!',
description: '机器人已创建并连接到处理器。你现在可以在工作台中管理它。',
description: '机器人已创建并连接到流水线。你现在可以在工作台中管理它。',
backToWorkbench: '返回工作台',
},
},
+95 -7
View File
@@ -1,3 +1,5 @@
import pipelineMigration from './pipeline-migration/zh-Hant';
const zhHant = {
sidebarGuide: {
label: '工作台導覽',
@@ -146,6 +148,7 @@ const zhHant = {
},
},
},
pipelineMigration,
sidebar: {
home: '首頁',
extensions: '擴展',
@@ -161,6 +164,7 @@ const zhHant = {
editionCloud: 'Cloud',
},
common: {
customValue: '自訂',
login: '登入',
logout: '登出',
accountOptions: '系統設定',
@@ -222,7 +226,8 @@ const zhHant = {
privacyPolicy: '隱私政策',
and: '和',
dataCollectionPolicy: '數據收集政策',
dataCollectionPolicyUrl: 'https://link.langbot.app/zh/docs/data-policy',
dataCollectionPolicyUrl:
'https://langbot.app/docs/zh/insight/data-collection-policy',
loading: '載入中...',
fieldRequired: '此欄位為必填',
or: '或',
@@ -230,6 +235,10 @@ const zhHant = {
spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
loginLocal: '使用本地帳號登入',
loginWithPassword: '透過密碼登入',
loginWithPasskey: '使用 Passkey 登入',
passkeyLoginSuccess: 'Passkey 驗證成功,正在登入...',
passkeyLoginFailed: 'Passkey 登入失敗',
passkeyNotSupported: '目前瀏覽器或裝置不支援 Passkey',
spaceLoginTitle: '透過 LangBot 帳號登入',
spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權',
spaceLoginUserCode: '您的驗證碼',
@@ -319,6 +328,37 @@ const zhHant = {
help: '查看說明文件',
},
models: {
codex: {
account: 'ChatGPT 訂閱',
description:
'使用 ChatGPT 帳號登入。訂閱權限與 OpenAI API 計費相互獨立,可用模型和使用額度取決於你的訂閱方案。',
disconnected: '未連線',
loading: '正在檢查連線…',
starting: '正在開始登入…',
pending: '等待授權',
connected: '已連線',
expired: '登入已過期,請重試以取得新驗證碼。',
error: '無法登入,請檢查網路連線後重試。',
canceling: '正在取消登入…',
saveAndSignIn: '儲存並登入',
done: '完成',
instructions:
'在 OpenAI 頁面輸入此驗證碼,登入完成前請保持此對話框開啟。',
copyCode: '複製驗證碼',
copied: '已複製',
copyManually: '請選取並手動複製驗證碼。',
continueAtOpenAI: '前往 OpenAI 繼續',
expiresAt: '驗證碼將於 {{time}} 過期。',
retrying: '連線中斷,正在自動重試…',
cancelSignIn: '取消登入',
tryAgain: '重試',
signIn: '登入',
reconnect: '重新連線',
disconnect: '中斷連線',
disconnectConfirm:
'中斷此供應商的連線?重新登入前,其模型將無法使用。此操作不會取消你的 ChatGPT 訂閱。',
confirmDisconnect: '確認中斷',
},
title: '模型設定',
description: '設定和管理可在流程線中使用的模型',
createModel: '建立模型',
@@ -355,7 +395,7 @@ const zhHant = {
reasoningAbility: '思考能力',
reasoningLevel: '思考等級',
reasoningLevels: {
providerDefault: '供應商預設',
providerDefault: '使用供應商預設值',
disabled: '關閉',
enabled: '開啟',
minimal: '最低',
@@ -459,6 +499,8 @@ const zhHant = {
providerSaveError: '儲存供應商失敗:',
providerDeleted: '供應商已刪除',
providerDeleteError: '刪除供應商失敗:',
deleteProviderCascadeConfirmation:
'確定刪除此供應商及其包含的所有模型嗎?此操作不可逆,無法復原。',
deleteProviderConfirmation: '您確定要刪除這個供應商嗎?',
loadError: '載入資料失敗',
chat: '對話',
@@ -684,6 +726,15 @@ const zhHant = {
},
},
agents: {
monitoring: {
description: '查看每次任務的觸發事件、模型輸出和工具執行過程。',
empty: '尚無執行紀錄。觸發平台事件或執行除錯後,可在此查看。',
input: '觸發輸入',
eventData: '事件資料',
execution: '執行過程',
rawEvents: '原始執行事件',
inputUnavailable: '此次執行未記錄輸入內容。',
},
eventProcessor: {
configurations: '外掛處理器設定',
configTab: '設定',
@@ -719,6 +770,7 @@ const zhHant = {
destination: '傳送目標',
loadMore: '載入更多',
activation: '安裝外掛、建立處理器設定,再綁定機器人。',
status_timeout: '已逾時',
status_pending: '待執行',
status_running: '執行中',
status_completed: '已完成',
@@ -1145,6 +1197,14 @@ const zhHant = {
connectionSuccess: '連接成功',
connectionFailed: '連接失敗,請檢查URL',
connectionFailedStatus: '連接失敗',
connectionUnreachable:
'無法連接到 MCP 伺服器,請確認服務已啟動且網路可達。',
connectionTimeout: 'MCP 伺服器回應逾時,請檢查服務狀態或增加逾時時間。',
connectionHttpError:
'MCP 伺服器回傳 HTTP {{status}},請檢查存取要求和伺服器日誌。',
oauthAuthorizationRequired: '需要 OAuth 授權',
oauthAuthorizationRequiredSuggestion:
'此 MCP 伺服器需要 OAuth 登入。目前尚不支援 OAuth 登入;如果伺服器允許,可以手動新增 Authorization 請求標頭。',
boxDisabledStdioRefused:
'Stdio 模式的 MCP 伺服器依賴 Box 沙箱,目前已在設定中停用(box.enabled = false)。',
boxUnavailableStdioRefused:
@@ -1574,6 +1634,19 @@ const zhHant = {
bindSpaceWarning:
'綁定後,您的登入電子郵件將從 {{localEmail}} 更改為 LangBot 帳號的電子郵件。',
bindSpaceSuccess: 'LangBot 帳號綁定成功',
passkeySectionTitle: '通行密鑰 (Passkey)',
passkeySectionDesc: '使用指紋、面容或硬體安全金鑰免密安全登入',
addPasskey: '新增通行密鑰',
passkeyName: '金鑰名稱',
passkeyNamePlaceholder: '例如:MacBook Touch ID、YubiKey',
passkeyCreated: '建立於 {{date}}',
passkeyLastUsed: '上次使用: {{date}}',
noPasskeys: '尚未綁定任何通行密鑰',
deletePasskeyConfirm:
'確定要刪除此通行密鑰嗎?刪除後將無法使用該金鑰登入。',
passkeyAddedSuccess: '通行密鑰新增成功',
passkeyDeleteSuccess: '通行密鑰已刪除',
passkeyRenameSuccess: '通行密鑰重新命名成功',
bindSpaceFailed: '綁定 LangBot 帳號失敗',
bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起',
setPasswordHint: '設定密碼後可使用電子郵件密碼登入',
@@ -1794,7 +1867,16 @@ const zhHant = {
queryVariables: {
title: '查詢變數',
},
loadError: '監控資料載入失敗',
partialMessages:
'顯示 {{total}} 則訊息中的 {{shown}} 則,對話軌跡可能不完整。',
partialModelCalls: '顯示 {{total}} 次模型呼叫中的 {{shown}} 次。',
partialToolCalls:
'顯示 {{total}} 次工具呼叫中的 {{shown}} 次,對話軌跡可能不完整。',
partialErrors: '顯示 {{total}} 筆錯誤中的 {{shown}} 筆。',
trafficChart: {
unavailable: '流量彙總資料無法使用',
truncated: '流量時間範圍已截斷,請選擇較短的時間範圍。',
title: '流量概覽',
messages: '訊息',
llmCalls: 'LLM呼叫',
@@ -1929,6 +2011,7 @@ const zhHant = {
messageReceived: '機器人已成功收到 IM 訊息,可以進入下一步。',
messageReceivedLocalAccountWarning:
'機器人側已配置正常並成功收到 IM 訊息。目前未透過 LangBot Account 登入,模型呼叫可能報錯;可以進入下一步新增自己的模型。',
pageBotPreviewFailed: '測試聊天載入失敗,請重新儲存設定後重試。',
pageBotTestPrompt:
'頁面機器人已啟用。點擊右下角聊天氣泡並傳送一則訊息,驗證完整對話流程。',
pageBotTestNotice: '僅供測試使用,請將程式碼嵌入真實的外部網頁。',
@@ -1945,12 +2028,16 @@ const zhHant = {
logsDescription: '監控機器人活動,確認平台連接是否正常運作。',
},
aiEngine: {
defaultModelUnavailable: '暫無可用的預設對話模型,請重試。',
defaultRunnerUnavailable: '本地 Agent 尚未就緒,請重試。',
preparingDefault: '正在準備預設 AI…',
title: '配置 AI 引擎',
description: '選擇驅動機器人智慧的 AI 引擎。',
optionalDescription: '這一步可選。選擇接下來要如何完善目前的 Agent。',
optionalDescription:
'預設 AI 已設定好,你可以直接使用,也可以選擇其他接入方式。',
externalTitle: '接入外部平台 Agent',
externalDescription:
'接入 Dify、n8n、Coze 等平台,並替換目前機器人的流水線。',
'透過執行器外掛接入 Dify、n8n、Coze 等外部 Agent 平台。',
ownModelTitle: '改成使用自己的模型',
ownModelDescription:
'新增模型供應商,自動掃描或手動填寫模型以快速完成引導。',
@@ -1962,7 +2049,7 @@ const zhHant = {
selectModelTitle: '選擇模型',
selectScannedModelTitle: '選擇模型',
selectScannedModelDescription:
'所選模型將成為新流水線的主要模型,機器人也會切換到該流水線。',
'選取的模型將取代目前機器人流水線的主模型。',
scanModelMode: '自動掃描',
manualModelMode: '手動新增',
scanningModels: '正在掃描可用模型…',
@@ -1976,11 +2063,12 @@ const zhHant = {
manualModelOptions: '模型能力(可選)',
editProvider: '編輯供應商',
rescanModels: '重新掃描模型',
moreFeaturesTitle: '給目前的 Agent 配置更多功能',
moreFeaturesTitle: '使用預設設定',
moreFeaturesDescription:
'進入工作台,為剛剛自動產生的 Agent 新增工具、知識庫等能力',
'繼續使用本地 Agent 和推薦模型,之後可在流水線中新增工具、知識庫等功能。',
runnerDescription: '選擇外部 Agent 的運行器並完成連接配置。',
backToChoices: '返回選項',
backToList: '返回列表',
createExternal: '建立並關聯',
finishWithModel: '使用所選模型並完成',
openWorkbench: '進入工作台',