fix: enforce atomic agent registration contract
This commit is contained in:
1 parent
47d323c17b
commit
f64d00673c
58 files changed
+2692
-1667
No files matched your search
@@ -40,6 +40,7 @@ function AgentsContent() {
|
||||
const router = useRouter();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showRegister, setShowRegister] = useState(false);
|
||||
const [registrationDismissible, setRegistrationDismissible] = useState(true);
|
||||
const [deleteTarget, setDeleteTarget] = useState<AgentRow | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
@@ -132,11 +133,27 @@ function AgentsContent() {
|
||||
[router],
|
||||
);
|
||||
|
||||
const handleRegisterSuccess = useCallback(() => {
|
||||
setShowRegister(false);
|
||||
mutate();
|
||||
const refreshAgents = useCallback(() => {
|
||||
void mutate().catch((error: unknown) => {
|
||||
console.error('Failed to refresh agents after registration.', error);
|
||||
});
|
||||
}, [mutate]);
|
||||
|
||||
const handleRegisterSuccess = useCallback(() => {
|
||||
setRegistrationDismissible(true);
|
||||
setShowRegister(false);
|
||||
refreshAgents();
|
||||
}, [refreshAgents]);
|
||||
|
||||
const openRegistration = useCallback(() => {
|
||||
setRegistrationDismissible(true);
|
||||
setShowRegister(true);
|
||||
}, []);
|
||||
|
||||
const closeRegistration = useCallback(() => {
|
||||
if (registrationDismissible) setShowRegister(false);
|
||||
}, [registrationDismissible]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
@@ -162,7 +179,7 @@ function AgentsContent() {
|
||||
{ label: t('agents.title') },
|
||||
]}
|
||||
actions={
|
||||
<button className="btn-brutalist" onClick={() => setShowRegister(true)}>
|
||||
<button className="btn-brutalist" onClick={openRegistration}>
|
||||
{t('agents.registerAgent')}
|
||||
</button>
|
||||
}
|
||||
@@ -194,13 +211,15 @@ function AgentsContent() {
|
||||
|
||||
<Modal
|
||||
isOpen={showRegister}
|
||||
onClose={() => setShowRegister(false)}
|
||||
onClose={closeRegistration}
|
||||
title={t('agents.registerAgent')}
|
||||
width="max-w-2xl"
|
||||
dismissible={registrationDismissible}
|
||||
>
|
||||
<AgentRegistrationForm
|
||||
onRegistered={refreshAgents}
|
||||
onDismissibleChange={setRegistrationDismissible}
|
||||
onSuccess={handleRegisterSuccess}
|
||||
onCancel={() => setShowRegister(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -12,10 +12,7 @@ import Modal from '@/components/ui/Modal';
|
||||
import Link from 'next/link';
|
||||
import type { AgentKey, Operation } from '@elydora/shared';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const HOOKS_INTEGRATIONS = new Set([
|
||||
'claudecode', 'cursor', 'gemini', 'kirocli', 'kiroide', 'opencode', 'copilot', 'letta',
|
||||
]);
|
||||
import { ADAPTER_INTEGRATION_IDS } from '@/features/agent-registration/integrations';
|
||||
|
||||
export default function AgentDetailClient({ agentId }: { agentId: string }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -39,7 +36,9 @@ export default function AgentDetailClient({ agentId }: { agentId: string }) {
|
||||
const [revokeError, setRevokeError] = useState<string | null>(null);
|
||||
|
||||
const agent = agentData?.agent;
|
||||
const supportsFreeze = !agent?.integration_type || HOOKS_INTEGRATIONS.has(agent.integration_type);
|
||||
const supportsFreeze = agent
|
||||
? ADAPTER_INTEGRATION_IDS.has(agent.integration_type)
|
||||
: false;
|
||||
const keys = agentData?.keys ?? [];
|
||||
const operations = opsData?.operations ?? [];
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AgentCredentials } from '@/features/agent-registration/credentials';
|
||||
import type { IntegrationCatalogItem } from '@/features/agent-registration/integrations';
|
||||
import {
|
||||
buildInstallInstructions,
|
||||
SDK_LANGUAGES,
|
||||
type SdkLanguage,
|
||||
} from '@/features/agent-registration/install-instructions';
|
||||
import CopyControl from './CopyControl';
|
||||
|
||||
interface ConnectStepProps {
|
||||
readonly integration: IntegrationCatalogItem;
|
||||
readonly credentials: AgentCredentials;
|
||||
readonly token: string;
|
||||
readonly onBack: () => void;
|
||||
readonly onDone: () => void;
|
||||
}
|
||||
|
||||
export default function ConnectStep({
|
||||
integration,
|
||||
credentials,
|
||||
token,
|
||||
onBack,
|
||||
onDone,
|
||||
}: ConnectStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const [language, setLanguage] = useState<SdkLanguage>('node');
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:8787';
|
||||
const instructions = useMemo(
|
||||
() => buildInstallInstructions(language, { integration, credentials, token, baseUrl }),
|
||||
[baseUrl, credentials, integration, language, token],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<span className="section-label">{t('agentRegistration.integration')}</span>
|
||||
<span className="font-mono text-[12px] text-ink">{integration.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-border" role="tablist" aria-label={t('agentRegistration.sdkLanguage')}>
|
||||
{SDK_LANGUAGES.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={language === item}
|
||||
onClick={() => setLanguage(item)}
|
||||
className={`px-4 py-2 font-mono text-[10px] uppercase tracking-wider border-b-2 -mb-px ${
|
||||
language === item ? 'border-ink text-ink' : 'border-transparent text-ink-dim hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="setup-command-label">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 id="setup-command-label" className="section-label">
|
||||
{integration.mode === 'adapter' ? t('agentRegistration.install') : t('agentRegistration.configure')}
|
||||
</h3>
|
||||
<CopyControl value={instructions.setup} />
|
||||
</div>
|
||||
<pre className="p-4 bg-ink text-[#EAEAE5] font-mono text-[10px] leading-relaxed whitespace-pre-wrap break-all overflow-x-auto">
|
||||
{instructions.setup}
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{instructions.usage && (
|
||||
<section aria-labelledby="record-operation-label">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 id="record-operation-label" className="section-label">{t('agentRegistration.record')}</h3>
|
||||
<CopyControl value={instructions.usage} />
|
||||
</div>
|
||||
<pre className="p-4 bg-ink text-[#EAEAE5] font-mono text-[10px] leading-relaxed whitespace-pre-wrap break-all overflow-x-auto">
|
||||
{instructions.usage}
|
||||
</pre>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{instructions.verify && (
|
||||
<section aria-labelledby="verify-command-label">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 id="verify-command-label" className="section-label">{t('agentRegistration.verify')}</h3>
|
||||
<CopyControl value={instructions.verify} />
|
||||
</div>
|
||||
<code className="block px-3 py-2 border-y border-border font-mono text-[11px] text-ink">
|
||||
{instructions.verify}
|
||||
</code>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{instructions.postInstall && (
|
||||
<ol className="border-t border-border" aria-label={t('agentRegistration.postInstall')}>
|
||||
{instructions.postInstall.map((step, index) => (
|
||||
<li key={step} className="grid grid-cols-[2rem_1fr] gap-2 py-2 border-b border-border font-mono text-[11px] text-ink">
|
||||
<span className="text-ink-dim tabular-nums">{index + 1}</span>
|
||||
<span>{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button type="button" className="btn-ghost" onClick={onBack}>{t('common.back')}</button>
|
||||
<button type="button" className="btn-brutalist" onClick={onDone}>{t('agentRegistration.done')}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CopyControlProps {
|
||||
readonly value: string;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
export default function CopyControl({ value, className = '' }: CopyControlProps) {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<'idle' | 'copied' | 'failed'>('idle');
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setStatus('copied');
|
||||
} catch {
|
||||
setStatus('failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="font-mono text-[10px] uppercase tracking-wider text-ink-dim hover:text-ink"
|
||||
>
|
||||
{status === 'copied' ? t('common.copied') : t('common.copy')}
|
||||
</button>
|
||||
{status === 'failed' && (
|
||||
<span className="font-mono text-[10px] text-red-600" role="alert">
|
||||
{t('agentRegistration.copyFailed')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AgentCredentials } from '@/features/agent-registration/credentials';
|
||||
import CopyControl from './CopyControl';
|
||||
|
||||
const EXPIRATION_OPTIONS = [
|
||||
{ id: '24hours', seconds: 86_400 },
|
||||
{ id: '7days', seconds: 604_800 },
|
||||
{ id: '1month', seconds: 2_592_000 },
|
||||
{ id: '1year', seconds: 31_536_000 },
|
||||
{ id: 'custom', seconds: -1 },
|
||||
{ id: 'neverExpire', seconds: null },
|
||||
] as const;
|
||||
const MAX_CUSTOM_DAYS = 365;
|
||||
|
||||
type ExpirationId = (typeof EXPIRATION_OPTIONS)[number]['id'];
|
||||
|
||||
interface CredentialsStepProps {
|
||||
readonly credentials: AgentCredentials;
|
||||
readonly token?: string;
|
||||
readonly issuingToken: boolean;
|
||||
readonly tokenError: string | null;
|
||||
readonly onIssueToken: (ttlSeconds: number | null) => void;
|
||||
readonly onContinue: () => void;
|
||||
}
|
||||
|
||||
function CredentialRow({ label, value }: { readonly label: string; readonly value: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[7rem_minmax(0,1fr)_auto] items-center gap-3 py-2 border-b border-border last:border-b-0">
|
||||
<dt className="section-label">{label}</dt>
|
||||
<dd className="font-mono text-[11px] text-ink truncate" title={value}>{value}</dd>
|
||||
<dd><CopyControl value={value} /></dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function CredentialsStep({
|
||||
credentials,
|
||||
token,
|
||||
issuingToken,
|
||||
tokenError,
|
||||
onIssueToken,
|
||||
onContinue,
|
||||
}: CredentialsStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expiration, setExpiration] = useState<ExpirationId>('24hours');
|
||||
const [customDays, setCustomDays] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const issueToken = () => {
|
||||
const option = EXPIRATION_OPTIONS.find(({ id }) => id === expiration);
|
||||
if (!option) {
|
||||
setValidationError(t('agentRegistration.invalidExpiration'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (option.seconds === -1) {
|
||||
const days = Number.parseInt(customDays, 10);
|
||||
if (!Number.isSafeInteger(days) || days < 1 || days > MAX_CUSTOM_DAYS) {
|
||||
setValidationError(t('agentRegistration.enterValidDays'));
|
||||
return;
|
||||
}
|
||||
setValidationError(null);
|
||||
onIssueToken(days * 86_400);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
onIssueToken(option.seconds);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<dl>
|
||||
<CredentialRow label={t('agentRegistration.credAgentId')} value={credentials.agentId} />
|
||||
<CredentialRow label={t('agentRegistration.credKeyId')} value={credentials.kid} />
|
||||
<CredentialRow label={t('agentRegistration.credOrgId')} value={credentials.orgId} />
|
||||
</dl>
|
||||
|
||||
<section aria-labelledby="private-key-label">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 id="private-key-label" className="section-label text-red-700">
|
||||
{t('agentRegistration.privateKeySaveNow')}
|
||||
</h3>
|
||||
<CopyControl value={credentials.privateKey} />
|
||||
</div>
|
||||
<code className="block p-3 bg-ink text-[#EAEAE5] font-mono text-[10px] break-all select-all">
|
||||
{credentials.privateKey}
|
||||
</code>
|
||||
<p className="font-mono text-[10px] text-ink-dim mt-2">
|
||||
{t('agentRegistration.privateKeyWarning')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="api-token-label" className="border-t border-border pt-5">
|
||||
<h3 id="api-token-label" className="section-label mb-3">
|
||||
{t('agentRegistration.apiToken')}
|
||||
</h3>
|
||||
|
||||
{token ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-mono text-[10px] text-ink-dim">
|
||||
{t('agentRegistration.tokenIssued')}
|
||||
</span>
|
||||
<CopyControl value={token} />
|
||||
</div>
|
||||
<code className="block p-3 bg-ink text-[#EAEAE5] font-mono text-[10px] break-all select-all">
|
||||
{token}
|
||||
</code>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="token-expiration" className="section-label block mb-1.5">
|
||||
{t('agentRegistration.tokenExpiration')}
|
||||
</label>
|
||||
<select
|
||||
id="token-expiration"
|
||||
value={expiration}
|
||||
onChange={(event) => setExpiration(event.target.value as ExpirationId)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[12px] text-ink focus:outline-none focus:border-ink"
|
||||
disabled={issuingToken}
|
||||
>
|
||||
{EXPIRATION_OPTIONS.map(({ id }) => (
|
||||
<option key={id} value={id}>{t(`tokenExpiration.${id}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{expiration === 'custom' && (
|
||||
<div className="sm:w-28">
|
||||
<label htmlFor="custom-token-days" className="section-label block mb-1.5">
|
||||
{t('agentRegistration.days')}
|
||||
</label>
|
||||
<input
|
||||
id="custom-token-days"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={customDays}
|
||||
onChange={(event) => setCustomDays(event.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[12px] text-ink focus:outline-none focus:border-ink"
|
||||
disabled={issuingToken}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="btn-brutalist" onClick={issueToken} disabled={issuingToken}>
|
||||
{issuingToken ? t('agentRegistration.issuing') : t('agentRegistration.issueApiToken')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(validationError || tokenError) && (
|
||||
<p className="font-mono text-[11px] text-red-600 mt-2" role="alert">
|
||||
{validationError ?? tokenError}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button type="button" className="btn-brutalist" onClick={onContinue} disabled={!token}>
|
||||
{t('common.continue')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { IntegrationCatalogItem } from '@/features/agent-registration/integrations';
|
||||
|
||||
export interface AgentDetails {
|
||||
readonly displayName: string;
|
||||
readonly responsibleEntity?: string;
|
||||
}
|
||||
|
||||
interface DetailsStepProps {
|
||||
readonly integration: IntegrationCatalogItem;
|
||||
readonly submitting: boolean;
|
||||
readonly blocked: boolean;
|
||||
readonly error: string | null;
|
||||
readonly onSubmit: (details: AgentDetails) => void;
|
||||
readonly onBack: () => void;
|
||||
}
|
||||
|
||||
export default function DetailsStep({
|
||||
integration,
|
||||
submitting,
|
||||
blocked,
|
||||
error,
|
||||
onSubmit,
|
||||
onBack,
|
||||
}: DetailsStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [responsibleEntity, setResponsibleEntity] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const normalizedName = displayName.trim();
|
||||
if (!normalizedName) {
|
||||
setValidationError(t('agentRegistration.displayNameRequired'));
|
||||
return;
|
||||
}
|
||||
setValidationError(null);
|
||||
onSubmit({
|
||||
displayName: normalizedName,
|
||||
responsibleEntity: responsibleEntity.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<span className="section-label">{t('agentRegistration.integration')}</span>
|
||||
<span className="font-mono text-[12px] text-ink">{integration.name}</span>
|
||||
</div>
|
||||
|
||||
{(validationError || error) && (
|
||||
<div className="px-3 py-2 border border-red-300 bg-red-50 text-red-700 font-mono text-[11px]" role="alert">
|
||||
{validationError ?? error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="agent-display-name" className="section-label block mb-1.5">
|
||||
{t('agentRegistration.displayName')}
|
||||
</label>
|
||||
<input
|
||||
id="agent-display-name"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink"
|
||||
placeholder={t('agentRegistration.displayNamePlaceholder')}
|
||||
disabled={submitting || blocked}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="agent-responsible-entity" className="section-label block mb-1.5">
|
||||
{t('agentRegistration.responsibleEntity')}
|
||||
</label>
|
||||
<input
|
||||
id="agent-responsible-entity"
|
||||
value={responsibleEntity}
|
||||
onChange={(event) => setResponsibleEntity(event.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink"
|
||||
placeholder={t('agentRegistration.responsibleEntityPlaceholder')}
|
||||
disabled={submitting || blocked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button type="button" className="btn-ghost" onClick={onBack} disabled={submitting || blocked}>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
<button type="submit" className="btn-brutalist" disabled={submitting || blocked}>
|
||||
{submitting ? t('agentRegistration.submitting') : t('agentRegistration.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
INTEGRATION_CATALOG,
|
||||
type IntegrationCatalogItem,
|
||||
type IntegrationMode,
|
||||
} from '@/features/agent-registration/integrations';
|
||||
|
||||
interface IntegrationStepProps {
|
||||
readonly onSelect: (integration: IntegrationCatalogItem) => void;
|
||||
}
|
||||
|
||||
function IntegrationGroup({
|
||||
mode,
|
||||
onSelect,
|
||||
}: {
|
||||
readonly mode: IntegrationMode;
|
||||
readonly onSelect: (integration: IntegrationCatalogItem) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const integrations = INTEGRATION_CATALOG.filter((item) => item.mode === mode);
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`integration-${mode}`}>
|
||||
<h3 id={`integration-${mode}`} className="section-label mb-2">
|
||||
{t(`agentRegistration.integrationGroups.${mode}`)}
|
||||
</h3>
|
||||
<div className="grid sm:grid-cols-2 border-t border-border sm:[&>button:nth-child(odd)]:border-r">
|
||||
{integrations.map((integration) => (
|
||||
<button
|
||||
key={integration.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(integration)}
|
||||
className="group flex items-center justify-between gap-3 px-3 py-3 text-left border-b border-border hover:bg-surface focus-visible:bg-surface focus-visible:outline-none"
|
||||
>
|
||||
<span className="font-mono text-[12px] text-ink">{integration.name}</span>
|
||||
<span className="font-mono text-[10px] text-ink-dim opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export default function IntegrationStep({ onSelect }: IntegrationStepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<IntegrationGroup mode="adapter" onSelect={onSelect} />
|
||||
<IntegrationGroup mode="sdk" onSelect={onSelect} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const STEPS = ['integration', 'details', 'credentials', 'connect'] as const;
|
||||
export type RegistrationStep = (typeof STEPS)[number];
|
||||
|
||||
export default function RegistrationProgress({ current }: { current: RegistrationStep }) {
|
||||
const { t } = useTranslation();
|
||||
const activeIndex = STEPS.indexOf(current);
|
||||
|
||||
return (
|
||||
<ol className="grid grid-cols-4 border-b border-border mb-6" aria-label={t('agentRegistration.progress')}>
|
||||
{STEPS.map((step, index) => (
|
||||
<li
|
||||
key={step}
|
||||
className={`pb-2 font-mono text-[10px] uppercase tracking-wider border-b -mb-px ${
|
||||
index <= activeIndex ? 'border-ink text-ink' : 'border-transparent text-ink-dim'
|
||||
}`}
|
||||
aria-current={index === activeIndex ? 'step' : undefined}
|
||||
>
|
||||
<span className="mr-1.5 tabular-nums">{index + 1}</span>
|
||||
<span className="hidden sm:inline">{t(`agentRegistration.steps.${step}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { useEffect, useRef, useId } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ModalProps {
|
||||
@@ -9,28 +9,78 @@ interface ModalProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
width?: string;
|
||||
dismissible?: boolean;
|
||||
}
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children, width = 'max-w-lg' }: ModalProps) {
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
width = 'max-w-lg',
|
||||
dismissible = true,
|
||||
}: ModalProps) {
|
||||
const titleId = useId();
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const previousFocusRef = useRef<Element | null>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
// Save previous focus on open; restore on unmount (close)
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
if (!isOpen) return;
|
||||
|
||||
previousFocusRef.current = document.activeElement;
|
||||
document.body.style.overflow = 'hidden';
|
||||
requestAnimationFrame(() => {
|
||||
dialogRef.current?.focus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
if (previousFocusRef.current instanceof HTMLElement) {
|
||||
previousFocusRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, [isOpen, handleKeyDown]);
|
||||
}, [isOpen]);
|
||||
|
||||
// Keydown listener — uses ref so it never changes identity
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && dismissible) {
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Tab' && dialogRef.current) {
|
||||
const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0]!;
|
||||
const last = focusable[focusable.length - 1]!;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [dismissible, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -39,32 +89,40 @@ export default function Modal({ isOpen, onClose, title, children, width = 'max-w
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 md:left-[260px] z-[100] flex items-center justify-center modal-overlay"
|
||||
onClick={(e) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
if (dismissible && e.target === overlayRef.current) onCloseRef.current();
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={`${width} w-full mx-4 bg-bg border border-ink relative fade-in`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{/* Crosshair corners */}
|
||||
<span className="crosshair ch-tl" />
|
||||
<span className="crosshair ch-tr" />
|
||||
<span className="crosshair ch-bl" />
|
||||
<span className="crosshair ch-br" />
|
||||
<span className="crosshair ch-tl" aria-hidden="true" />
|
||||
<span className="crosshair ch-tr" aria-hidden="true" />
|
||||
<span className="crosshair ch-bl" aria-hidden="true" />
|
||||
<span className="crosshair ch-br" aria-hidden="true" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 sm:px-6 py-4 border-b border-border">
|
||||
<h2 className="font-sans text-lg font-semibold tracking-tight text-ink min-w-0 truncate">
|
||||
<h2 id={titleId} className="font-sans text-lg font-semibold tracking-tight text-ink min-w-0 truncate">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center text-ink-dim hover:text-ink transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M1 1l12 12M13 1L1 13" />
|
||||
</svg>
|
||||
</button>
|
||||
{dismissible && (
|
||||
<button
|
||||
onClick={() => onCloseRef.current()}
|
||||
className="w-8 h-8 flex items-center justify-center text-ink-dim hover:text-ink transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<path d="M1 1l12 12M13 1L1 13" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface AgentCredentials {
|
||||
readonly agentId: string;
|
||||
readonly kid: string;
|
||||
readonly publicKey: string;
|
||||
readonly privateKey: string;
|
||||
readonly orgId: string;
|
||||
}
|
||||
|
||||
function requireJwkValue(value: string | undefined, field: 'd' | 'x'): string {
|
||||
if (!value) {
|
||||
throw new Error(`Web Crypto did not export the Ed25519 JWK ${field} value.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function generateAgentCredentials(orgId: string): Promise<AgentCredentials> {
|
||||
const normalizedOrgId = orgId.trim();
|
||||
if (!normalizedOrgId) {
|
||||
throw new Error('An active organization is required to register an agent.');
|
||||
}
|
||||
|
||||
const agentId = `agent-${crypto.randomUUID()}`;
|
||||
const kid = `${agentId}-key-1`;
|
||||
const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']);
|
||||
const [privateJwk, publicJwk] = await Promise.all([
|
||||
crypto.subtle.exportKey('jwk', keyPair.privateKey),
|
||||
crypto.subtle.exportKey('jwk', keyPair.publicKey),
|
||||
]);
|
||||
|
||||
return {
|
||||
agentId,
|
||||
kid,
|
||||
publicKey: requireJwkValue(publicJwk.x, 'x'),
|
||||
privateKey: requireJwkValue(privateJwk.d, 'd'),
|
||||
orgId: normalizedOrgId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { IntegrationCatalogItem } from './integrations';
|
||||
import type { AgentCredentials } from './credentials';
|
||||
|
||||
export const SDK_LANGUAGES = ['node', 'python', 'go'] as const;
|
||||
export type SdkLanguage = (typeof SDK_LANGUAGES)[number];
|
||||
|
||||
interface InstructionInput {
|
||||
readonly integration: IntegrationCatalogItem;
|
||||
readonly credentials: AgentCredentials;
|
||||
readonly token: string;
|
||||
readonly baseUrl: string;
|
||||
}
|
||||
|
||||
export interface InstallInstructions {
|
||||
readonly setup: string;
|
||||
readonly usage?: string;
|
||||
readonly verify?: string;
|
||||
readonly postInstall?: readonly string[];
|
||||
}
|
||||
|
||||
function quoted(value: string): string {
|
||||
return `"${value.replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
|
||||
function adapterCommand(
|
||||
language: SdkLanguage,
|
||||
{ integration, credentials, token, baseUrl }: InstructionInput,
|
||||
): string {
|
||||
const values = {
|
||||
agent: integration.id,
|
||||
orgId: quoted(credentials.orgId),
|
||||
agentId: quoted(credentials.agentId),
|
||||
privateKey: quoted(credentials.privateKey),
|
||||
kid: quoted(credentials.kid),
|
||||
token: quoted(token),
|
||||
baseUrl: quoted(baseUrl),
|
||||
};
|
||||
|
||||
if (language === 'go') {
|
||||
return [
|
||||
'go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora@latest',
|
||||
`elydora install --agent ${values.agent} --org-id ${values.orgId} --agent-id ${values.agentId} --private-key ${values.privateKey} --kid ${values.kid} --token ${values.token} --base-url ${values.baseUrl}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const executable = language === 'node'
|
||||
? 'npx @elydora/sdk install'
|
||||
: 'python -m pip install elydora\nelydora install';
|
||||
return `${executable} --agent ${values.agent} --org_id ${values.orgId} --agent_id ${values.agentId} --private_key ${values.privateKey} --kid ${values.kid} --token ${values.token} --base_url ${values.baseUrl}`;
|
||||
}
|
||||
|
||||
function postInstallSteps(integration: IntegrationCatalogItem): readonly string[] | undefined {
|
||||
if (integration.postInstall === 'review-hooks') {
|
||||
return ['Open the agent and run /hooks to review the active Elydora hooks.'];
|
||||
}
|
||||
if (integration.postInstall === 'start-kiro') {
|
||||
return [
|
||||
'Kiro CLI v2: kiro-cli --agent elydora-audit',
|
||||
'Kiro CLI v3: kiro-cli --v3',
|
||||
];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sdkSetup(language: SdkLanguage, input: InstructionInput): string {
|
||||
const { credentials, token, baseUrl } = input;
|
||||
if (language === 'python') {
|
||||
return `python -m pip install elydora
|
||||
|
||||
from elydora import ElydoraClient
|
||||
|
||||
client = ElydoraClient(
|
||||
org_id=${JSON.stringify(credentials.orgId)},
|
||||
agent_id=${JSON.stringify(credentials.agentId)},
|
||||
private_key=${JSON.stringify(credentials.privateKey)},
|
||||
base_url=${JSON.stringify(baseUrl)},
|
||||
token=${JSON.stringify(token)},
|
||||
)
|
||||
client.set_kid(${JSON.stringify(credentials.kid)})`;
|
||||
}
|
||||
|
||||
if (language === 'go') {
|
||||
return `go get github.com/Elydora-Infrastructure/Elydora-Go-SDK
|
||||
|
||||
client, err := elydora.NewClient(&elydora.Config{
|
||||
OrgID: ${JSON.stringify(credentials.orgId)},
|
||||
AgentID: ${JSON.stringify(credentials.agentId)},
|
||||
PrivateKey: ${JSON.stringify(credentials.privateKey)},
|
||||
BaseURL: ${JSON.stringify(baseUrl)},
|
||||
Token: ${JSON.stringify(token)},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}`;
|
||||
}
|
||||
|
||||
return `npm install @elydora/sdk
|
||||
|
||||
import { ElydoraClient } from '@elydora/sdk';
|
||||
|
||||
const client = new ElydoraClient({
|
||||
orgId: ${JSON.stringify(credentials.orgId)},
|
||||
agentId: ${JSON.stringify(credentials.agentId)},
|
||||
privateKey: ${JSON.stringify(credentials.privateKey)},
|
||||
kid: ${JSON.stringify(credentials.kid)},
|
||||
baseUrl: ${JSON.stringify(baseUrl)},
|
||||
});
|
||||
client.setToken(${JSON.stringify(token)});`;
|
||||
}
|
||||
|
||||
function sdkUsage(language: SdkLanguage): string {
|
||||
if (language === 'python') {
|
||||
return `operation = client.create_operation(
|
||||
operation_type="ai.tool_use",
|
||||
subject={"session_id": "session-123"},
|
||||
action={"tool": "shell"},
|
||||
payload={"command": "example"},
|
||||
)
|
||||
receipt = client.submit_operation(operation)`;
|
||||
}
|
||||
|
||||
if (language === 'go') {
|
||||
return `operation, err := client.CreateOperation(&elydora.CreateOperationParams{
|
||||
OperationType: "ai.tool_use",
|
||||
Subject: map[string]any{"session_id": "session-123"},
|
||||
Action: map[string]any{"tool": "shell"},
|
||||
Payload: map[string]any{"command": "example"},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_, err = client.SubmitOperation(operation)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}`;
|
||||
}
|
||||
|
||||
return `const operation = client.createOperation({
|
||||
operationType: 'ai.tool_use',
|
||||
subject: { session_id: 'session-123' },
|
||||
action: { tool: 'shell' },
|
||||
payload: { command: 'example' },
|
||||
});
|
||||
await client.submitOperation(operation);`;
|
||||
}
|
||||
|
||||
export function buildInstallInstructions(
|
||||
language: SdkLanguage,
|
||||
input: InstructionInput,
|
||||
): InstallInstructions {
|
||||
if (input.integration.mode === 'adapter') {
|
||||
return {
|
||||
setup: adapterCommand(language, input),
|
||||
verify: language === 'node' ? 'npx @elydora/sdk status' : 'elydora status',
|
||||
postInstall: postInstallSteps(input.integration),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
setup: sdkSetup(language, input),
|
||||
usage: sdkUsage(language),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
INTEGRATION_TYPES,
|
||||
type IntegrationType,
|
||||
} from '../../../../server/src/shared/types/enums';
|
||||
|
||||
export { INTEGRATION_TYPES };
|
||||
|
||||
export type IntegrationMode = 'adapter' | 'sdk';
|
||||
export type PostInstallAction = 'review-hooks' | 'start-kiro';
|
||||
|
||||
export interface IntegrationCatalogItem {
|
||||
readonly id: IntegrationType;
|
||||
readonly name: string;
|
||||
readonly mode: IntegrationMode;
|
||||
readonly postInstall?: PostInstallAction;
|
||||
}
|
||||
|
||||
const INTEGRATION_METADATA: Record<
|
||||
IntegrationType,
|
||||
Omit<IntegrationCatalogItem, 'id'>
|
||||
> = {
|
||||
augment: { name: 'Augment Code', mode: 'adapter' },
|
||||
claudecode: { name: 'Claude Code', mode: 'adapter' },
|
||||
cline: { name: 'Cline', mode: 'adapter' },
|
||||
codex: { name: 'OpenAI Codex', mode: 'adapter', postInstall: 'review-hooks' },
|
||||
copilot: { name: 'GitHub Copilot CLI', mode: 'adapter' },
|
||||
cursor: { name: 'Cursor', mode: 'adapter' },
|
||||
droid: { name: 'Factory Droid', mode: 'adapter', postInstall: 'review-hooks' },
|
||||
gemini: { name: 'Gemini CLI', mode: 'adapter' },
|
||||
grok: { name: 'Grok Build', mode: 'adapter' },
|
||||
kimi: { name: 'Kimi CLI', mode: 'adapter', postInstall: 'review-hooks' },
|
||||
kirocli: { name: 'Kiro CLI', mode: 'adapter', postInstall: 'start-kiro' },
|
||||
kiroide: { name: 'Kiro IDE', mode: 'adapter' },
|
||||
letta: { name: 'Letta Code', mode: 'adapter' },
|
||||
opencode: { name: 'OpenCode', mode: 'adapter' },
|
||||
qwen: { name: 'Qwen Code', mode: 'adapter', postInstall: 'review-hooks' },
|
||||
enterprise: { name: 'Enterprise Agent', mode: 'sdk' },
|
||||
gui: { name: 'GUI Agent', mode: 'sdk' },
|
||||
sdk: { name: 'SDK', mode: 'sdk' },
|
||||
other: { name: 'Other', mode: 'sdk' },
|
||||
};
|
||||
|
||||
export const INTEGRATION_CATALOG: readonly IntegrationCatalogItem[] =
|
||||
INTEGRATION_TYPES.map((id) => ({ id, ...INTEGRATION_METADATA[id] }));
|
||||
|
||||
export const ADAPTER_INTEGRATION_IDS: ReadonlySet<IntegrationType> = new Set(
|
||||
INTEGRATION_CATALOG.filter(({ mode }) => mode === 'adapter').map(({ id }) => id),
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { IntegrationType, RegisterAgentResponse } from '@elydora/shared';
|
||||
import type { AgentCredentials } from './credentials';
|
||||
|
||||
export class RegistrationContractError extends Error {
|
||||
override readonly name = 'RegistrationContractError';
|
||||
}
|
||||
|
||||
function contractError(message: string): never {
|
||||
throw new RegistrationContractError(message);
|
||||
}
|
||||
|
||||
export function assertRegistrationResponse(
|
||||
response: RegisterAgentResponse,
|
||||
credentials: AgentCredentials,
|
||||
integrationType: IntegrationType,
|
||||
): void {
|
||||
if (response?.agent?.agent_id !== credentials.agentId) {
|
||||
contractError('The registration response returned a different agent ID.');
|
||||
}
|
||||
if (response?.agent?.org_id !== credentials.orgId) {
|
||||
contractError('The registration response returned a different organization ID.');
|
||||
}
|
||||
if (response?.agent?.integration_type !== integrationType) {
|
||||
contractError('The registration response returned a different integration type.');
|
||||
}
|
||||
|
||||
const registeredKey = Array.isArray(response?.keys)
|
||||
? response.keys.find(({ kid }) => kid === credentials.kid)
|
||||
: undefined;
|
||||
if (!registeredKey || registeredKey.public_key !== credentials.publicKey) {
|
||||
contractError('The registration response did not confirm the generated public key.');
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"delete": "Delete",
|
||||
"download": "Download",
|
||||
"continue": "Continue",
|
||||
"back": "Back",
|
||||
"signOut": "Sign Out",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
@@ -306,34 +307,40 @@
|
||||
"submitting": "Registering...",
|
||||
"submit": "Register Agent",
|
||||
"failedToRegister": "Failed to register agent.",
|
||||
"agentRegistered": "Agent Registered",
|
||||
"privateKeySaveNow": "Private Key — Save This Now",
|
||||
"privateKeyWarning": "This will not be shown again. Store it as an environment variable or in a secrets manager.",
|
||||
"apiToken": "API Token",
|
||||
"apiTokenDesc": "Use this as your ELYDORA_TOKEN environment variable. This is your bearer token for API authentication.",
|
||||
"tokenExpiration": "Token Expiration",
|
||||
"customDays": "Number of days",
|
||||
"days": "days",
|
||||
"enterValidDays": "Enter a valid number of days (minimum 1).",
|
||||
"enterValidDays": "Enter a whole number from 1 to 365.",
|
||||
"issuing": "Issuing...",
|
||||
"issueApiToken": "Issue API Token",
|
||||
"failedToIssueToken": "Failed to issue token.",
|
||||
"credAgentId": "Agent ID",
|
||||
"credKeyId": "Key ID",
|
||||
"credOrgId": "Org ID",
|
||||
"credAlgorithm": "Algorithm",
|
||||
"chooseIntegration": "Choose Integration",
|
||||
"chooseIntegrationDesc": "How will this agent connect to Elydora?",
|
||||
"supported": "Supported (one-command install)",
|
||||
"sdkIntegration": "SDK Integration",
|
||||
"install": "Install",
|
||||
"backToList": "Back",
|
||||
"sdkTutorial": "SDK Tutorial",
|
||||
"aiReadyPrompt": "AI-Ready Prompt",
|
||||
"aiReadyPromptDesc": "Copy this prompt and paste it into your AI agent to set up Elydora automatically.",
|
||||
"copyPrompt": "Copy Prompt",
|
||||
"done": "Done",
|
||||
"step": "Step {{current}} of {{total}}"
|
||||
"progress": "Agent registration progress",
|
||||
"steps": {
|
||||
"integration": "Integration",
|
||||
"details": "Details",
|
||||
"credentials": "Credentials",
|
||||
"connect": "Connect"
|
||||
},
|
||||
"integrationGroups": {
|
||||
"adapter": "CLI adapters",
|
||||
"sdk": "Custom SDK"
|
||||
},
|
||||
"integration": "Integration",
|
||||
"copyFailed": "Copy failed.",
|
||||
"invalidExpiration": "Select a valid expiration.",
|
||||
"tokenIssued": "Issued once. Store it securely.",
|
||||
"configure": "Configure",
|
||||
"record": "Record an operation",
|
||||
"verify": "Verify",
|
||||
"postInstall": "Post-install steps",
|
||||
"sdkLanguage": "SDK language"
|
||||
},
|
||||
"chain": {
|
||||
"title": "Chain Visualization",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"delete": "删除",
|
||||
"download": "下载",
|
||||
"continue": "继续",
|
||||
"back": "返回",
|
||||
"signOut": "退出登录",
|
||||
"copy": "复制",
|
||||
"copied": "已复制",
|
||||
@@ -306,34 +307,40 @@
|
||||
"submitting": "注册中...",
|
||||
"submit": "注册代理",
|
||||
"failedToRegister": "注册代理失败。",
|
||||
"agentRegistered": "代理注册成功",
|
||||
"privateKeySaveNow": "私钥 — 请立即保存",
|
||||
"privateKeyWarning": "此私钥不会再次显示。请将其存储为环境变量或保存在密钥管理器中。",
|
||||
"apiToken": "API 令牌",
|
||||
"apiTokenDesc": "将其用作 ELYDORA_TOKEN 环境变量。这是您的 API 认证令牌。",
|
||||
"tokenExpiration": "令牌有效期",
|
||||
"customDays": "天数",
|
||||
"days": "天",
|
||||
"enterValidDays": "请输入有效的天数(最少 1 天)。",
|
||||
"enterValidDays": "请输入 1 至 365 的整数天数。",
|
||||
"issuing": "签发中...",
|
||||
"issueApiToken": "签发 API 令牌",
|
||||
"failedToIssueToken": "签发令牌失败。",
|
||||
"credAgentId": "代理 ID",
|
||||
"credKeyId": "密钥 ID",
|
||||
"credOrgId": "组织 ID",
|
||||
"credAlgorithm": "算法",
|
||||
"chooseIntegration": "选择集成方式",
|
||||
"chooseIntegrationDesc": "此代理将如何连接到 Elydora?",
|
||||
"supported": "支持的(一键安装)",
|
||||
"sdkIntegration": "SDK 集成",
|
||||
"install": "安装",
|
||||
"backToList": "返回",
|
||||
"sdkTutorial": "SDK 教程",
|
||||
"aiReadyPrompt": "AI 就绪提示",
|
||||
"aiReadyPromptDesc": "复制此提示并粘贴到您的 AI 代理中以自动设置 Elydora。",
|
||||
"copyPrompt": "复制提示",
|
||||
"done": "完成",
|
||||
"step": "第 {{current}} 步,共 {{total}} 步"
|
||||
"progress": "代理注册进度",
|
||||
"steps": {
|
||||
"integration": "集成",
|
||||
"details": "详情",
|
||||
"credentials": "凭据",
|
||||
"connect": "连接"
|
||||
},
|
||||
"integrationGroups": {
|
||||
"adapter": "CLI 适配器",
|
||||
"sdk": "自定义 SDK"
|
||||
},
|
||||
"integration": "集成",
|
||||
"copyFailed": "复制失败。",
|
||||
"invalidExpiration": "请选择有效期限。",
|
||||
"tokenIssued": "仅显示一次,请安全保存。",
|
||||
"configure": "配置",
|
||||
"record": "记录操作",
|
||||
"verify": "验证",
|
||||
"postInstall": "安装后步骤",
|
||||
"sdkLanguage": "SDK 语言"
|
||||
},
|
||||
"chain": {
|
||||
"title": "链可视化",
|
||||
|
||||
Reference in new issue
Block a user