mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-20 00:14:50 +00:00
Merge remote-tracking branch 'origin/main' into branding-upd
# Conflicts: # docsgpt/core/models/anthropic.yaml # docsgpt/core/models/openai.yaml # frontend/src/admin/AdminUI.tsx
This commit is contained in:
commit
a25bec6821
300 files changed
+27029
-1745
No files matched your search
@@ -119,6 +119,8 @@ const EVENT_LABELS: Record<string, string> = {
|
||||
scim_created: 'Provisioned',
|
||||
scim_deactivated: 'Deactivated (SCIM)',
|
||||
scim_reactivated: 'Activated (SCIM)',
|
||||
quota_policy_set: 'Quota set',
|
||||
quota_policy_deleted: 'Quota removed',
|
||||
};
|
||||
|
||||
export function eventLabel(event: string): string {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import adminService, { type QuotaScope } from '../api/services/adminService';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../components/ui/select';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { fmtNumber } from './AdminUI';
|
||||
import {
|
||||
fmtUsd,
|
||||
formToPolicy,
|
||||
isEmptyForm,
|
||||
policyToForm,
|
||||
usagePercent,
|
||||
type Budget,
|
||||
type BudgetMode,
|
||||
type QuotaForm,
|
||||
type QuotaPolicy,
|
||||
} from './quotaUtils';
|
||||
|
||||
const MODES: { value: BudgetMode; label: string }[] = [
|
||||
{ value: 'inherit', label: 'Not set here' },
|
||||
{ value: 'limit', label: 'Limit' },
|
||||
{ value: 'unlimited', label: 'Unlimited' },
|
||||
];
|
||||
|
||||
export function UsageBar({
|
||||
label,
|
||||
budget,
|
||||
kind,
|
||||
caption,
|
||||
}: {
|
||||
label: string;
|
||||
budget: Budget;
|
||||
kind: 'tokens' | 'cost';
|
||||
caption?: string;
|
||||
}) {
|
||||
const fmt = (n: number) => (kind === 'cost' ? fmtUsd(n) : fmtNumber(n));
|
||||
const percent = usagePercent(budget.used, budget.limit);
|
||||
const tone =
|
||||
percent >= 100
|
||||
? 'bg-red-500'
|
||||
: percent >= 80
|
||||
? 'bg-amber-500'
|
||||
: 'bg-[#7D54D1]';
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="tabular-nums">
|
||||
{fmt(budget.used)}
|
||||
{budget.limit === null ? ' · no limit' : ` of ${fmt(budget.limit)}`}
|
||||
</span>
|
||||
</div>
|
||||
{budget.limit !== null ? (
|
||||
<div
|
||||
className="bg-muted mt-1 h-1.5 w-full overflow-hidden rounded-full"
|
||||
role="progressbar"
|
||||
aria-label={label}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(percent)}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full ${tone}`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{caption ? (
|
||||
<p className="text-muted-foreground mt-1 text-xs">{caption}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BudgetField({
|
||||
label,
|
||||
hint,
|
||||
mode,
|
||||
value,
|
||||
step,
|
||||
onMode,
|
||||
onValue,
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
mode: BudgetMode;
|
||||
value: string;
|
||||
step: string;
|
||||
onMode: (mode: BudgetMode) => void;
|
||||
onValue: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-foreground text-sm font-medium">{label}</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Select value={mode} onValueChange={(v) => onMode(v as BudgetMode)}>
|
||||
<SelectTrigger className="w-40" aria-label={`${label} mode`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODES.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{mode === 'limit' ? (
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step={step}
|
||||
inputMode="decimal"
|
||||
value={value}
|
||||
aria-label={label}
|
||||
placeholder={hint}
|
||||
onChange={(e) => onValue(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits the ``all``-bucket policy of one subject. Saving a form with neither
|
||||
* budget set removes the policy, since a policy without an opinion is not stored.
|
||||
*/
|
||||
export default function QuotaEditor({
|
||||
scope,
|
||||
subjectId,
|
||||
policy,
|
||||
inheritHint,
|
||||
onSaved,
|
||||
}: {
|
||||
scope: QuotaScope;
|
||||
subjectId: string | null;
|
||||
policy: QuotaPolicy | null;
|
||||
inheritHint: string;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const token = useSelector(selectToken);
|
||||
const [form, setForm] = useState<QuotaForm>(() => policyToForm(policy));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setForm(policyToForm(policy));
|
||||
setError(null);
|
||||
}, [policy, scope, subjectId]);
|
||||
|
||||
const patch = (fields: Partial<QuotaForm>) =>
|
||||
setForm((prev) => ({ ...prev, ...fields }));
|
||||
|
||||
const submit = async (request: () => Promise<Response>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await request();
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (res.ok && json.success !== false) onSaved();
|
||||
else setError(json.message || 'Could not save the quota.');
|
||||
} catch {
|
||||
setError('Could not save the quota.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = () =>
|
||||
submit(() => adminService.deleteQuota(scope, subjectId, 'all', token));
|
||||
|
||||
const save = () => {
|
||||
if (isEmptyForm(form)) {
|
||||
if (policy) remove();
|
||||
return;
|
||||
}
|
||||
const result = formToPolicy(form, policy);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
submit(() => adminService.setQuota(scope, subjectId, result.policy, token));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-xs">{inheritHint}</p>
|
||||
{policy && !policy.enabled ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
This policy is disabled and is not enforced. Saving keeps it disabled.
|
||||
</p>
|
||||
) : null}
|
||||
<BudgetField
|
||||
label="Tokens"
|
||||
hint="e.g. 2000000"
|
||||
step="1"
|
||||
mode={form.tokenMode}
|
||||
value={form.tokenLimit}
|
||||
onMode={(tokenMode) => patch({ tokenMode })}
|
||||
onValue={(tokenLimit) => patch({ tokenLimit })}
|
||||
/>
|
||||
<BudgetField
|
||||
label="Cost (USD)"
|
||||
hint="e.g. 25"
|
||||
step="0.01"
|
||||
mode={form.costMode}
|
||||
value={form.costLimit}
|
||||
onMode={(costMode) => patch({ costMode })}
|
||||
onValue={(costLimit) => patch({ costLimit })}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-foreground text-sm font-medium">Note</p>
|
||||
<Input
|
||||
value={form.note}
|
||||
maxLength={500}
|
||||
aria-label="Note"
|
||||
placeholder="Optional, visible to admins only"
|
||||
onChange={(e) => patch({ note: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
{policy ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={remove}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy || (isEmptyForm(form) && !policy)}
|
||||
onClick={save}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import adminService, { type QuotaScope } from '../api/services/adminService';
|
||||
import teamsService from '../api/services/teamsService';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '../components/ui/table';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import {
|
||||
LoadError,
|
||||
Loading,
|
||||
Pill,
|
||||
fmtDate,
|
||||
fmtNumber,
|
||||
fmtRelative,
|
||||
} from './AdminUI';
|
||||
import QuotaEditor from './QuotaEditor';
|
||||
import { describeBudget, type QuotaPolicy } from './quotaUtils';
|
||||
|
||||
type TeamPolicy = QuotaPolicy & {
|
||||
team_name?: string | null;
|
||||
team_slug?: string | null;
|
||||
member_count?: number | null;
|
||||
};
|
||||
|
||||
type Editing = {
|
||||
scope: QuotaScope;
|
||||
subjectId: string | null;
|
||||
title: string;
|
||||
policy: QuotaPolicy | null;
|
||||
};
|
||||
|
||||
const HINTS: Record<QuotaScope, string> = {
|
||||
instance:
|
||||
'Applies to every user that no team allowance or user override covers.',
|
||||
team: 'Each member gets this allowance; it is not a shared pool. A member of several teams gets the most generous one.',
|
||||
user: 'Overrides team allowances and the instance default for this user.',
|
||||
};
|
||||
|
||||
function PolicyCells({ policy }: { policy: QuotaPolicy }) {
|
||||
return (
|
||||
<>
|
||||
<TableCell className="tabular-nums">
|
||||
{describeBudget(policy.token_limit, policy.token_unlimited, 'tokens')}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{describeBudget(policy.cost_limit_usd, policy.cost_unlimited, 'cost')}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-56 truncate">
|
||||
{policy.note || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground whitespace-nowrap">
|
||||
{fmtRelative(policy.updated_at)}
|
||||
</TableCell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Quotas() {
|
||||
const token = useSelector(selectToken);
|
||||
const [data, setData] = useState<any | null>(null);
|
||||
const [teams, setTeams] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<Editing | null>(null);
|
||||
const [teamPick, setTeamPick] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [quotasRes, teamsJson] = await Promise.all([
|
||||
adminService.getQuotas(token),
|
||||
teamsService.listAll(token).catch(() => ({})),
|
||||
]);
|
||||
setData(await quotasRes.json().catch(() => ({ success: false })));
|
||||
setTeams(teamsJson?.teams ?? []);
|
||||
} catch {
|
||||
setData({ success: false });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// The editor covers the ``all`` bucket; other buckets are listed read-only.
|
||||
const isAll = (p: QuotaPolicy) => p.bucket === 'all';
|
||||
const instancePolicy: QuotaPolicy | null =
|
||||
(data?.instance ?? []).find(isAll) ?? null;
|
||||
const teamPolicies: TeamPolicy[] = data?.teams ?? [];
|
||||
const userPolicies: QuotaPolicy[] = data?.users ?? [];
|
||||
const teamsWithoutPolicy = useMemo(() => {
|
||||
const covered = new Set(
|
||||
teamPolicies.filter(isAll).map((p) => String(p.subject_id)),
|
||||
);
|
||||
return teams.filter((team) => !covered.has(String(team.id)));
|
||||
}, [teams, teamPolicies]);
|
||||
|
||||
if (data === null && loading) return <Loading />;
|
||||
if (!data?.success) return <LoadError message="Failed to load quotas." />;
|
||||
|
||||
const bucketPill = (policy: QuotaPolicy) => (
|
||||
<>
|
||||
{isAll(policy) ? null : <Pill tone="muted">{policy.bucket} traffic</Pill>}
|
||||
{policy.enabled ? null : <Pill tone="muted">Disabled</Pill>}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-8">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Usage is counted per user over each calendar {data.period} (UTC). The
|
||||
current window resets {fmtDate(data.resets_at)}. A request is refused
|
||||
once a budget is used up; the request that crosses it still completes.
|
||||
</p>
|
||||
|
||||
{(data.unpriced_models ?? []).length > 0 ? (
|
||||
<div className="rounded-2xl border border-amber-300 bg-amber-50 px-5 py-4 text-sm dark:border-amber-800 dark:bg-amber-950/30">
|
||||
<p className="text-foreground font-medium">
|
||||
Models without a price are invisible to cost limits
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
These were used this {data.period} and recorded at $0:{' '}
|
||||
{(data.unpriced_models as any[])
|
||||
.map((m) => `${m.model_id} (${fmtNumber(m.tokens)} tokens)`)
|
||||
.join(', ')}
|
||||
. Use a token limit for them, or declare their rates in the model
|
||||
catalog.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-foreground font-bold">Instance default</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
scope: 'instance',
|
||||
subjectId: null,
|
||||
title: 'Instance default',
|
||||
policy: instancePolicy,
|
||||
})
|
||||
}
|
||||
>
|
||||
{instancePolicy ? 'Edit' : 'Set default'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{instancePolicy
|
||||
? `${instancePolicy.enabled ? '' : 'Disabled · '}Tokens: ${describeBudget(instancePolicy.token_limit, instancePolicy.token_unlimited, 'tokens')} · Cost: ${describeBudget(instancePolicy.cost_limit_usd, instancePolicy.cost_unlimited, 'cost')}`
|
||||
: 'No default: users without a team allowance or override are unlimited.'}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-foreground font-bold">Team allowances</p>
|
||||
{teamsWithoutPolicy.length > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={teamPick} onValueChange={setTeamPick}>
|
||||
<SelectTrigger className="w-52" aria-label="Team">
|
||||
<SelectValue placeholder="Choose a team" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teamsWithoutPolicy.map((team) => (
|
||||
<SelectItem key={team.id} value={String(team.id)}>
|
||||
{team.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!teamPick}
|
||||
onClick={() => {
|
||||
const team = teams.find((tm) => String(tm.id) === teamPick);
|
||||
setEditing({
|
||||
scope: 'team',
|
||||
subjectId: teamPick,
|
||||
title: team?.name ?? 'Team',
|
||||
policy: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add allowance
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{teamPolicies.length === 0 ? (
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
No team has an allowance.
|
||||
</p>
|
||||
) : (
|
||||
<TableContainer className="mt-3">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Team</TableHeader>
|
||||
<TableHeader>Members</TableHeader>
|
||||
<TableHeader>Tokens</TableHeader>
|
||||
<TableHeader>Cost</TableHeader>
|
||||
<TableHeader>Note</TableHeader>
|
||||
<TableHeader>Updated</TableHeader>
|
||||
<TableHeader className="text-right">Actions</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{teamPolicies.map((policy) => (
|
||||
<TableRow key={`${policy.subject_id}:${policy.bucket}`}>
|
||||
<TableCell>
|
||||
<span className="mr-2">
|
||||
{policy.team_name ?? policy.subject_id}
|
||||
</span>
|
||||
{bucketPill(policy)}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{fmtNumber(policy.member_count)}
|
||||
</TableCell>
|
||||
<PolicyCells policy={policy} />
|
||||
<TableCell className="text-right">
|
||||
{isAll(policy) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
scope: 'team',
|
||||
subjectId: policy.subject_id,
|
||||
title: policy.team_name ?? 'Team',
|
||||
policy,
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p className="text-foreground font-bold">User overrides</p>
|
||||
{userPolicies.length === 0 ? (
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
No user has an override. Add one from a user's menu on the
|
||||
Users tab.
|
||||
</p>
|
||||
) : (
|
||||
<TableContainer className="mt-3">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>User</TableHeader>
|
||||
<TableHeader>Tokens</TableHeader>
|
||||
<TableHeader>Cost</TableHeader>
|
||||
<TableHeader>Note</TableHeader>
|
||||
<TableHeader>Updated</TableHeader>
|
||||
<TableHeader className="text-right">Actions</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{userPolicies.map((policy) => (
|
||||
<TableRow key={`${policy.subject_id}:${policy.bucket}`}>
|
||||
<TableCell>
|
||||
<span className="mr-2 break-all">
|
||||
{policy.subject_id}
|
||||
</span>
|
||||
{bucketPill(policy)}
|
||||
</TableCell>
|
||||
<PolicyCells policy={policy} />
|
||||
<TableCell className="text-right">
|
||||
{isAll(policy) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
scope: 'user',
|
||||
subjectId: policy.subject_id,
|
||||
title: policy.subject_id ?? 'User',
|
||||
policy,
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Modal
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditing(null);
|
||||
}}
|
||||
title={editing ? `Quota · ${editing.title}` : 'Quota'}
|
||||
>
|
||||
{editing ? (
|
||||
<QuotaEditor
|
||||
scope={editing.scope}
|
||||
subjectId={editing.subjectId}
|
||||
policy={editing.policy}
|
||||
inheritHint={HINTS[editing.scope]}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
setTeamPick('');
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import adminService from '../api/services/adminService';
|
||||
import teamsService from '../api/services/teamsService';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { LoadError, Loading, fmtDate } from './AdminUI';
|
||||
import QuotaEditor, { UsageBar } from './QuotaEditor';
|
||||
import {
|
||||
sourceLabel,
|
||||
type BucketStatus,
|
||||
type Budget,
|
||||
type QuotaPolicy,
|
||||
} from './quotaUtils';
|
||||
|
||||
/** A user's effective limits and usage, with the editor for their override. */
|
||||
export default function UserQuotaModal({
|
||||
userId,
|
||||
onClose,
|
||||
}: {
|
||||
userId: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const token = useSelector(selectToken);
|
||||
const [data, setData] = useState<any | null>(null);
|
||||
const [teamNames, setTeamNames] = useState<Record<string, string>>({});
|
||||
|
||||
// Bumped per request so a slow response for a previous user is discarded
|
||||
// instead of showing (and letting the editor save) that user's policy.
|
||||
const requestRef = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const request = ++requestRef.current;
|
||||
setData(null);
|
||||
if (!userId) return;
|
||||
try {
|
||||
const [res, teamsJson] = await Promise.all([
|
||||
adminService.getUserQuota(userId, token),
|
||||
teamsService.listAll(token).catch(() => ({})),
|
||||
]);
|
||||
const json = await res.json().catch(() => ({ success: false }));
|
||||
if (request !== requestRef.current) return;
|
||||
setData(json);
|
||||
setTeamNames(
|
||||
Object.fromEntries(
|
||||
(teamsJson?.teams ?? []).map((team: any) => [
|
||||
String(team.id),
|
||||
team.name,
|
||||
]),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
if (request === requestRef.current) setData({ success: false });
|
||||
}
|
||||
}, [userId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const overall: BucketStatus | undefined = (data?.effective ?? []).find(
|
||||
(status: BucketStatus) => status.bucket === 'all',
|
||||
);
|
||||
const override: QuotaPolicy | null =
|
||||
(data?.policies ?? []).find((p: QuotaPolicy) => p.bucket === 'all') ?? null;
|
||||
const caption = (budget: Budget) =>
|
||||
sourceLabel(
|
||||
budget,
|
||||
budget.source_id ? teamNames[budget.source_id] : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={userId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
title={userId ? `Quota · ${userId}` : 'Quota'}
|
||||
>
|
||||
{data === null ? (
|
||||
<Loading />
|
||||
) : !data.success ? (
|
||||
<LoadError message="Failed to load this user's quota." />
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{overall ? (
|
||||
<div className="space-y-3">
|
||||
<UsageBar
|
||||
label={`Tokens this ${data.period}`}
|
||||
kind="tokens"
|
||||
budget={overall.tokens}
|
||||
caption={caption(overall.tokens)}
|
||||
/>
|
||||
<UsageBar
|
||||
label={`Cost this ${data.period}`}
|
||||
kind="cost"
|
||||
budget={overall.cost}
|
||||
caption={caption(overall.cost)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Resets {fmtDate(overall.resets_at)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="border-border border-t pt-4">
|
||||
<p className="text-foreground mb-2 text-sm font-bold">
|
||||
User override
|
||||
</p>
|
||||
<QuotaEditor
|
||||
scope="user"
|
||||
subjectId={userId}
|
||||
policy={override}
|
||||
inheritHint="Overrides team allowances and the instance default for this user. Leave a budget as “Not set here” to keep what the user inherits."
|
||||
onSaved={load}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Eye,
|
||||
Gauge,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
fmtNumber,
|
||||
fmtRelative,
|
||||
} from './AdminUI';
|
||||
import UserQuotaModal from './UserQuotaModal';
|
||||
|
||||
type AdminUser = {
|
||||
user_id: string;
|
||||
@@ -70,6 +72,7 @@ export default function Users() {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [menuUserId, setMenuUserId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<any | null>(null);
|
||||
const [quotaUserId, setQuotaUserId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
@@ -157,7 +160,14 @@ export default function Users() {
|
||||
isAdmin: boolean,
|
||||
active: boolean,
|
||||
): Action[] => {
|
||||
const acts: Action[] = [];
|
||||
const acts: Action[] = [
|
||||
{
|
||||
key: 'quota',
|
||||
label: 'Quota',
|
||||
icon: Gauge,
|
||||
perform: () => setQuotaUserId(userId),
|
||||
},
|
||||
];
|
||||
if (isAdmin) {
|
||||
acts.push({
|
||||
key: 'revoke',
|
||||
@@ -433,6 +443,11 @@ export default function Users() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<UserQuotaModal
|
||||
userId={quotaUserId}
|
||||
onClose={() => setQuotaUserId(null)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={detail !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Tabs, TabsList, TabsTrigger } from '../components/ui/tabs';
|
||||
import Admins from './Admins';
|
||||
import Audit from './Audit';
|
||||
import Overview from './Overview';
|
||||
import Quotas from './Quotas';
|
||||
import Usage from './Usage';
|
||||
import Users from './Users';
|
||||
|
||||
@@ -19,6 +20,7 @@ const TABS = [
|
||||
{ key: 'users', label: 'Users', path: '/admin/users' },
|
||||
{ key: 'admins', label: 'Admins', path: '/admin/roles' },
|
||||
{ key: 'usage', label: 'Usage', path: '/admin/usage' },
|
||||
{ key: 'quotas', label: 'Quotas', path: '/admin/quotas' },
|
||||
{ key: 'audit', label: 'Audit', path: '/admin/audit' },
|
||||
];
|
||||
|
||||
@@ -63,6 +65,7 @@ export default function Admin() {
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="roles" element={<Admins />} />
|
||||
<Route path="usage" element={<Usage />} />
|
||||
<Route path="quotas" element={<Quotas />} />
|
||||
<Route path="audit" element={<Audit />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
describeBudget,
|
||||
formToPolicy,
|
||||
isEmptyForm,
|
||||
policyToForm,
|
||||
sourceLabel,
|
||||
usagePercent,
|
||||
type QuotaPolicy,
|
||||
} from './quotaUtils';
|
||||
|
||||
const policy = (fields: Partial<QuotaPolicy>): QuotaPolicy => ({
|
||||
scope: 'user',
|
||||
subject_id: 'u1',
|
||||
bucket: 'all',
|
||||
token_limit: null,
|
||||
token_unlimited: false,
|
||||
cost_limit_usd: null,
|
||||
cost_unlimited: false,
|
||||
enabled: true,
|
||||
...fields,
|
||||
});
|
||||
|
||||
describe('policyToForm', () => {
|
||||
it('starts a missing policy as inherit', () => {
|
||||
const form = policyToForm(null);
|
||||
expect(form.tokenMode).toBe('inherit');
|
||||
expect(form.costMode).toBe('inherit');
|
||||
expect(isEmptyForm(form)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps zero as a limit, not as inherit', () => {
|
||||
const form = policyToForm(policy({ token_limit: 0, cost_unlimited: true }));
|
||||
expect(form.tokenMode).toBe('limit');
|
||||
expect(form.tokenLimit).toBe('0');
|
||||
expect(form.costMode).toBe('unlimited');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formToPolicy', () => {
|
||||
const base = policyToForm(null);
|
||||
|
||||
it('round-trips limits and trims the note', () => {
|
||||
const result = formToPolicy({
|
||||
...base,
|
||||
tokenMode: 'limit',
|
||||
tokenLimit: ' 5000 ',
|
||||
costMode: 'limit',
|
||||
costLimit: '2.5',
|
||||
note: ' trial ',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
policy: {
|
||||
bucket: 'all',
|
||||
enabled: true,
|
||||
token_limit: 5000,
|
||||
token_unlimited: false,
|
||||
cost_limit_usd: 2.5,
|
||||
cost_unlimited: false,
|
||||
note: 'trial',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a disabled policy disabled', () => {
|
||||
const form = { ...base, tokenMode: 'limit' as const, tokenLimit: '10' };
|
||||
const stored = policy({ enabled: false });
|
||||
const result = formToPolicy(form, stored);
|
||||
expect(result.ok && result.policy.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('sends unlimited without a limit', () => {
|
||||
const result = formToPolicy({
|
||||
...base,
|
||||
tokenMode: 'unlimited',
|
||||
tokenLimit: '99',
|
||||
});
|
||||
expect(result.ok && result.policy.token_limit).toBeNull();
|
||||
expect(result.ok && result.policy.token_unlimited).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['', '1.5', '-1', 'abc', '1e3'])(
|
||||
'rejects token limit %j',
|
||||
(tokenLimit) => {
|
||||
expect(formToPolicy({ ...base, tokenMode: 'limit', tokenLimit }).ok).toBe(
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['', '-0.01', 'abc', 'Infinity'])(
|
||||
'rejects cost limit %j',
|
||||
(costLimit) => {
|
||||
expect(formToPolicy({ ...base, costMode: 'limit', costLimit }).ok).toBe(
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('usagePercent', () => {
|
||||
it('handles unlimited, zero and overshoot', () => {
|
||||
expect(usagePercent(50, null)).toBe(0);
|
||||
expect(usagePercent(0, 0)).toBe(100);
|
||||
expect(usagePercent(25, 100)).toBe(25);
|
||||
expect(usagePercent(500, 100)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('labels', () => {
|
||||
it('describes budgets', () => {
|
||||
expect(describeBudget(null, true, 'tokens')).toBe('Unlimited');
|
||||
expect(describeBudget(null, false, 'cost')).toBe('—');
|
||||
expect(describeBudget(1000, false, 'tokens')).toContain('tokens');
|
||||
});
|
||||
|
||||
it('names the layer a limit came from', () => {
|
||||
expect(sourceLabel({ limit: null, used: 0 })).toBe('No limit set');
|
||||
expect(sourceLabel({ limit: 1, used: 0, source: 'team' }, 'Eng')).toBe(
|
||||
'Team: Eng',
|
||||
);
|
||||
expect(sourceLabel({ limit: 1, used: 0, source: 'default' })).toBe(
|
||||
'Plan default',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
// Pure helpers behind the quota editor and usage bars.
|
||||
|
||||
export type BudgetMode = 'inherit' | 'limit' | 'unlimited';
|
||||
|
||||
export type QuotaPolicy = {
|
||||
scope: 'instance' | 'team' | 'user';
|
||||
subject_id: string | null;
|
||||
bucket: string;
|
||||
token_limit: number | null;
|
||||
token_unlimited: boolean;
|
||||
cost_limit_usd: number | null;
|
||||
cost_unlimited: boolean;
|
||||
enabled: boolean;
|
||||
note?: string | null;
|
||||
updated_by?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type Budget = {
|
||||
limit: number | null;
|
||||
used: number;
|
||||
source?: string | null;
|
||||
source_id?: string | null;
|
||||
};
|
||||
|
||||
export type BucketStatus = {
|
||||
bucket: string;
|
||||
tokens: Budget;
|
||||
cost: Budget;
|
||||
resets_at: string;
|
||||
};
|
||||
|
||||
export type QuotaForm = {
|
||||
tokenMode: BudgetMode;
|
||||
tokenLimit: string;
|
||||
costMode: BudgetMode;
|
||||
costLimit: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
const mode = (limit: number | null, unlimited: boolean): BudgetMode => {
|
||||
if (unlimited) return 'unlimited';
|
||||
return limit === null || limit === undefined ? 'inherit' : 'limit';
|
||||
};
|
||||
|
||||
export function policyToForm(policy?: QuotaPolicy | null): QuotaForm {
|
||||
return {
|
||||
tokenMode: policy
|
||||
? mode(policy.token_limit, policy.token_unlimited)
|
||||
: 'inherit',
|
||||
tokenLimit: policy?.token_limit != null ? String(policy.token_limit) : '',
|
||||
costMode: policy
|
||||
? mode(policy.cost_limit_usd, policy.cost_unlimited)
|
||||
: 'inherit',
|
||||
costLimit:
|
||||
policy?.cost_limit_usd != null ? String(policy.cost_limit_usd) : '',
|
||||
note: policy?.note ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export type FormResult =
|
||||
{ ok: true; policy: Record<string, unknown> } | { ok: false; error: string };
|
||||
|
||||
// An empty form (both budgets inherited) is not a policy: the caller deletes instead.
|
||||
export function isEmptyForm(form: QuotaForm): boolean {
|
||||
return form.tokenMode === 'inherit' && form.costMode === 'inherit';
|
||||
}
|
||||
|
||||
// ``existing`` carries the stored ``enabled`` flag through an edit: the form has
|
||||
// no control for it, and a body without it would switch the policy back on.
|
||||
export function formToPolicy(
|
||||
form: QuotaForm,
|
||||
existing?: QuotaPolicy | null,
|
||||
): FormResult {
|
||||
const policy: Record<string, unknown> = {
|
||||
bucket: 'all',
|
||||
enabled: existing?.enabled ?? true,
|
||||
token_limit: null,
|
||||
token_unlimited: form.tokenMode === 'unlimited',
|
||||
cost_limit_usd: null,
|
||||
cost_unlimited: form.costMode === 'unlimited',
|
||||
note: form.note.trim() || null,
|
||||
};
|
||||
if (form.tokenMode === 'limit') {
|
||||
const raw = form.tokenLimit.trim();
|
||||
if (!/^\d+$/.test(raw))
|
||||
return { ok: false, error: 'Token limit must be a whole number.' };
|
||||
const tokens = Number(raw);
|
||||
if (!Number.isSafeInteger(tokens))
|
||||
return { ok: false, error: 'Token limit is too large.' };
|
||||
policy.token_limit = tokens;
|
||||
}
|
||||
if (form.costMode === 'limit') {
|
||||
const raw = form.costLimit.trim();
|
||||
const cost = Number(raw);
|
||||
if (raw === '' || !Number.isFinite(cost) || cost < 0)
|
||||
return { ok: false, error: 'Cost limit must be a number, 0 or more.' };
|
||||
policy.cost_limit_usd = cost;
|
||||
}
|
||||
return { ok: true, policy };
|
||||
}
|
||||
|
||||
export function usagePercent(used: number, limit: number | null): number {
|
||||
if (limit === null || limit === undefined) return 0;
|
||||
if (limit <= 0) return 100;
|
||||
return Math.min(100, Math.max(0, (used / limit) * 100));
|
||||
}
|
||||
|
||||
export function fmtUsd(value?: number | null): string {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: value != null && value < 1 ? 4 : 2,
|
||||
}).format(value ?? 0);
|
||||
}
|
||||
|
||||
export function describeBudget(
|
||||
limit: number | null,
|
||||
unlimited: boolean,
|
||||
kind: 'tokens' | 'cost',
|
||||
): string {
|
||||
if (unlimited) return 'Unlimited';
|
||||
if (limit === null || limit === undefined) return '—';
|
||||
return kind === 'cost'
|
||||
? fmtUsd(limit)
|
||||
: `${new Intl.NumberFormat().format(limit)} tokens`;
|
||||
}
|
||||
|
||||
export function sourceLabel(budget: Budget, teamName?: string): string {
|
||||
if (!budget.source) return 'No limit set';
|
||||
if (budget.source === 'user') return 'User override';
|
||||
if (budget.source === 'team')
|
||||
return teamName ? `Team: ${teamName}` : 'Team allowance';
|
||||
if (budget.source === 'instance') return 'Instance default';
|
||||
return 'Plan default';
|
||||
}
|
||||
@@ -1587,7 +1587,7 @@ function WorkflowBuilderInner() {
|
||||
size="icon-sm"
|
||||
onClick={() => setShowWorkflowSettings(!showWorkflowSettings)}
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground size-auto p-1"
|
||||
aria-label="Workflow settings"
|
||||
aria-label="Edit workflow details"
|
||||
title={
|
||||
workflowDescription
|
||||
? `${workflowName || 'New Workflow'} — ${workflowDescription}`
|
||||
|
||||
@@ -2,6 +2,7 @@ const endpoints = {
|
||||
USER: {
|
||||
CONFIG: '/api/config',
|
||||
ME: '/api/user/me',
|
||||
QUOTA: '/api/user/quota',
|
||||
NEW_TOKEN: '/api/generate_token',
|
||||
OIDC_LOGIN: '/api/auth/oidc/login',
|
||||
OIDC_TOKEN: '/api/auth/oidc/token',
|
||||
@@ -10,9 +11,6 @@ const endpoints = {
|
||||
MODELS: '/api/models',
|
||||
DOCS: '/api/sources',
|
||||
DOCS_PAGINATED: '/api/sources/paginated',
|
||||
API_KEYS: '/api/get_api_keys',
|
||||
CREATE_API_KEY: '/api/create_api_key',
|
||||
DELETE_API_KEY: '/api/delete_api_key',
|
||||
AGENT: (id: string) => `/api/get_agent?id=${id}`,
|
||||
AGENTS: '/api/get_agents',
|
||||
GUARDRAIL_CATALOG: '/api/guardrails/catalog',
|
||||
@@ -155,6 +153,10 @@ const endpoints = {
|
||||
DEVICE_PAIRINGS: '/api/devices/pairings',
|
||||
DEVICE_PAIRING: (deviceCode: string) =>
|
||||
`/api/devices/pairings/${deviceCode}`,
|
||||
ACCESS_TOKENS: '/api/user/tokens',
|
||||
ACCESS_TOKEN: (id: string) => `/api/user/tokens/${id}`,
|
||||
ACCESS_TOKEN_REGENERATE: (id: string) =>
|
||||
`/api/user/tokens/${id}/regenerate`,
|
||||
},
|
||||
V1: {
|
||||
CHAT_COMPLETIONS: '/v1/chat/completions',
|
||||
@@ -172,6 +174,12 @@ const endpoints = {
|
||||
USAGE: '/api/admin/usage',
|
||||
AUDIT: '/api/admin/audit',
|
||||
DEVICE_AUDIT: '/api/admin/devices/audit',
|
||||
QUOTAS: '/api/admin/quotas',
|
||||
QUOTA_INSTANCE: '/api/admin/quotas/instance',
|
||||
QUOTA_TEAM: (id: string) =>
|
||||
`/api/admin/quotas/teams/${encodeURIComponent(id)}`,
|
||||
QUOTA_USER: (id: string) =>
|
||||
`/api/admin/quotas/users/${encodeURIComponent(id)}`,
|
||||
},
|
||||
CONVERSATION: {
|
||||
ANSWER: '/api/answer',
|
||||
|
||||
@@ -10,6 +10,14 @@ const qs = (params: Record<string, string | number | undefined>): string => {
|
||||
return str ? `?${str}` : '';
|
||||
};
|
||||
|
||||
export type QuotaScope = 'instance' | 'team' | 'user';
|
||||
|
||||
const quotaUrl = (scope: QuotaScope, subjectId?: string | null): string => {
|
||||
if (scope === 'team') return endpoints.ADMIN.QUOTA_TEAM(subjectId ?? '');
|
||||
if (scope === 'user') return endpoints.ADMIN.QUOTA_USER(subjectId ?? '');
|
||||
return endpoints.ADMIN.QUOTA_INSTANCE;
|
||||
};
|
||||
|
||||
const adminService = {
|
||||
getOverview: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.ADMIN.OVERVIEW, token),
|
||||
@@ -54,6 +62,23 @@ const adminService = {
|
||||
token: string | null,
|
||||
): Promise<any> =>
|
||||
apiClient.get(`${endpoints.ADMIN.DEVICE_AUDIT}${qs(params)}`, token),
|
||||
getQuotas: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.ADMIN.QUOTAS, token),
|
||||
getUserQuota: (userId: string, token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.ADMIN.QUOTA_USER(userId), token),
|
||||
setQuota: (
|
||||
scope: QuotaScope,
|
||||
subjectId: string | null,
|
||||
policy: Record<string, unknown>,
|
||||
token: string | null,
|
||||
): Promise<any> => apiClient.put(quotaUrl(scope, subjectId), policy, token),
|
||||
deleteQuota: (
|
||||
scope: QuotaScope,
|
||||
subjectId: string | null,
|
||||
bucket: string,
|
||||
token: string | null,
|
||||
): Promise<any> =>
|
||||
apiClient.delete(`${quotaUrl(scope, subjectId)}${qs({ bucket })}`, token),
|
||||
};
|
||||
|
||||
export default adminService;
|
||||
@@ -0,0 +1,200 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import apiClient from '../client';
|
||||
import patService, { AccessTokenApiError } from './patService';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const response = (body: unknown, status = 200) =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as unknown as Response;
|
||||
|
||||
const TOKEN_ROW = {
|
||||
id: '7f1c2c1e-0f59-4d0a-9d55-0f6a3d1d7a11',
|
||||
name: 'ci',
|
||||
token_prefix: 'dgpt_pat_ab12cd',
|
||||
scopes: ['agents:read'],
|
||||
resource_filter: {},
|
||||
status: 'active',
|
||||
expires_at: null,
|
||||
last_used_at: null,
|
||||
last_used_ip: null,
|
||||
created_at: '2026-09-01T10:00:00+00:00',
|
||||
revoked_at: null,
|
||||
};
|
||||
|
||||
describe('patService.list', () => {
|
||||
it('GETs /api/user/tokens with the session token and returns the body', async () => {
|
||||
const body = {
|
||||
success: true,
|
||||
tokens: [TOKEN_ROW],
|
||||
scopes: [{ name: 'agents:read', description: 'View agents' }],
|
||||
policy: { enabled: true },
|
||||
};
|
||||
const spy = vi.spyOn(apiClient, 'get').mockResolvedValue(response(body));
|
||||
|
||||
const result = await patService.list('session-jwt');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('/api/user/tokens', 'session-jwt');
|
||||
expect(result.tokens).toEqual([TOKEN_ROW]);
|
||||
expect(result.scopes[0].name).toBe('agents:read');
|
||||
});
|
||||
|
||||
it('throws with the server message on a non-2xx response', async () => {
|
||||
vi.spyOn(apiClient, 'get').mockResolvedValue(
|
||||
response({ success: false, message: 'Authentication required' }, 401),
|
||||
);
|
||||
|
||||
await expect(patService.list(null)).rejects.toMatchObject({
|
||||
name: 'AccessTokenApiError',
|
||||
message: 'Authentication required',
|
||||
status: 401,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('patService.create', () => {
|
||||
it('POSTs the payload and returns the one-time plaintext token', async () => {
|
||||
const spy = vi.spyOn(apiClient, 'post').mockResolvedValue(
|
||||
response(
|
||||
{
|
||||
success: true,
|
||||
token: 'dgpt_pat_secret',
|
||||
personal_access_token: TOKEN_ROW,
|
||||
},
|
||||
201,
|
||||
),
|
||||
);
|
||||
const payload = {
|
||||
name: 'ci',
|
||||
scopes: ['agents:read'],
|
||||
resource_filter: { agents: [TOKEN_ROW.id] },
|
||||
expires_in_days: 30,
|
||||
};
|
||||
|
||||
const result = await patService.create(payload, 'session-jwt');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'/api/user/tokens',
|
||||
payload,
|
||||
'session-jwt',
|
||||
);
|
||||
expect(result.token).toBe('dgpt_pat_secret');
|
||||
expect(result.personal_access_token.id).toBe(TOKEN_ROW.id);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[400, 'Unknown scopes: nope:read'],
|
||||
[403, 'Personal access tokens are not available on this server'],
|
||||
[409, 'A token with this name already exists'],
|
||||
])('surfaces the %i error message', async (status, message) => {
|
||||
vi.spyOn(apiClient, 'post').mockResolvedValue(
|
||||
response({ success: false, message }, status),
|
||||
);
|
||||
|
||||
const error = await patService
|
||||
.create({ name: 'ci', scopes: ['agents:read'] }, 'session-jwt')
|
||||
.catch((e) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(AccessTokenApiError);
|
||||
expect(error.message).toBe(message);
|
||||
expect(error.status).toBe(status);
|
||||
});
|
||||
|
||||
it('throws an empty message when the error body is not JSON', async () => {
|
||||
vi.spyOn(apiClient, 'post').mockResolvedValue({
|
||||
ok: false,
|
||||
status: 502,
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token <');
|
||||
},
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(
|
||||
patService.create({ name: 'ci', scopes: ['agents:read'] }, null),
|
||||
).rejects.toMatchObject({ message: '', status: 502 });
|
||||
});
|
||||
|
||||
it('treats success:false on a 2xx as a failure', async () => {
|
||||
vi.spyOn(apiClient, 'post').mockResolvedValue(
|
||||
response({ success: false, message: 'nope' }, 200),
|
||||
);
|
||||
|
||||
await expect(
|
||||
patService.create({ name: 'ci', scopes: ['agents:read'] }, null),
|
||||
).rejects.toThrow('nope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('patService.revoke', () => {
|
||||
it('DELETEs the token by id', async () => {
|
||||
const spy = vi
|
||||
.spyOn(apiClient, 'delete')
|
||||
.mockResolvedValue(response({ success: true }));
|
||||
|
||||
await expect(
|
||||
patService.revoke(TOKEN_ROW.id, 'session-jwt'),
|
||||
).resolves.toEqual({ success: true });
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
`/api/user/tokens/${TOKEN_ROW.id}`,
|
||||
'session-jwt',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the token is not found', async () => {
|
||||
vi.spyOn(apiClient, 'delete').mockResolvedValue(
|
||||
response({ success: false, message: 'Token not found' }, 404),
|
||||
);
|
||||
|
||||
await expect(patService.revoke('missing', null)).rejects.toThrow(
|
||||
'Token not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('patService.regenerate', () => {
|
||||
it('POSTs the chosen lifetime to the regenerate endpoint', async () => {
|
||||
const body = {
|
||||
success: true,
|
||||
token: 'dgpt_pat_newsecret',
|
||||
personal_access_token: TOKEN_ROW,
|
||||
};
|
||||
const spy = vi.spyOn(apiClient, 'post').mockResolvedValue(response(body));
|
||||
|
||||
const result = await patService.regenerate(TOKEN_ROW.id, 30, 'session-jwt');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
`/api/user/tokens/${TOKEN_ROW.id}/regenerate`,
|
||||
{ expires_in_days: 30 },
|
||||
'session-jwt',
|
||||
);
|
||||
expect(result.token).toBe('dgpt_pat_newsecret');
|
||||
});
|
||||
|
||||
it('sends an empty body to keep the original lifetime', async () => {
|
||||
const spy = vi
|
||||
.spyOn(apiClient, 'post')
|
||||
.mockResolvedValue(response({ success: true, token: 't' }));
|
||||
|
||||
await patService.regenerate(TOKEN_ROW.id, undefined, null);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
`/api/user/tokens/${TOKEN_ROW.id}/regenerate`,
|
||||
{},
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces the server message', async () => {
|
||||
vi.spyOn(apiClient, 'post').mockResolvedValue(
|
||||
response({ success: false, message: 'Token not found' }, 404),
|
||||
);
|
||||
|
||||
await expect(patService.regenerate('missing', 30, null)).rejects.toThrow(
|
||||
'Token not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import apiClient from '../client';
|
||||
import endpoints from '../endpoints';
|
||||
|
||||
export interface PersonalAccessToken {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Display prefix only (e.g. `dgpt_pat_ab12cd`); never the full secret. */
|
||||
token_prefix: string;
|
||||
scopes: string[];
|
||||
/** `{family: [resource ids]}`; a missing family means "all resources". */
|
||||
resource_filter: Record<string, string[]>;
|
||||
status: string;
|
||||
expires_at: string | null;
|
||||
last_used_at: string | null;
|
||||
last_used_ip: string | null;
|
||||
created_at: string | null;
|
||||
/** Set once the secret has been regenerated; the lifetime then counts from here. */
|
||||
regenerated_at?: string | null;
|
||||
revoked_at: string | null;
|
||||
}
|
||||
|
||||
export interface AccessTokenScope {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AccessTokenPolicy {
|
||||
enabled: boolean;
|
||||
default_lifetime_days: number;
|
||||
max_lifetime_days: number;
|
||||
allow_non_expiring: boolean;
|
||||
max_per_user: number;
|
||||
filterable_families: string[];
|
||||
}
|
||||
|
||||
export interface AccessTokenListResponse {
|
||||
tokens: PersonalAccessToken[];
|
||||
scopes: AccessTokenScope[];
|
||||
policy: AccessTokenPolicy;
|
||||
}
|
||||
|
||||
export interface CreateAccessTokenPayload {
|
||||
name: string;
|
||||
scopes: string[];
|
||||
resource_filter?: Record<string, string[]>;
|
||||
/** `null`/omitted = server default, `0` = never expires. */
|
||||
expires_in_days?: number | null;
|
||||
}
|
||||
|
||||
export interface CreateAccessTokenResponse {
|
||||
/** Plaintext secret. The server returns it exactly once. */
|
||||
token: string;
|
||||
personal_access_token: PersonalAccessToken;
|
||||
}
|
||||
|
||||
/** Error carrying the server's user-facing `message` and the HTTP status. */
|
||||
export class AccessTokenApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'AccessTokenApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
// apiClient resolves to the raw fetch Response (the app convention). Parse it
|
||||
// here and turn `{success:false, message}` / non-2xx into a thrown error so
|
||||
// callers can show the server's message inline.
|
||||
const parse = async <T>(response: Response): Promise<T> => {
|
||||
let body: { success?: boolean; message?: unknown } | null = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
if (!response.ok || body?.success === false) {
|
||||
throw new AccessTokenApiError(
|
||||
typeof body?.message === 'string' ? body.message : '',
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return body as T;
|
||||
};
|
||||
|
||||
const patService = {
|
||||
list: async (token: string | null): Promise<AccessTokenListResponse> =>
|
||||
parse<AccessTokenListResponse>(
|
||||
await apiClient.get(endpoints.USER.ACCESS_TOKENS, token),
|
||||
),
|
||||
|
||||
create: async (
|
||||
payload: CreateAccessTokenPayload,
|
||||
token: string | null,
|
||||
): Promise<CreateAccessTokenResponse> =>
|
||||
parse<CreateAccessTokenResponse>(
|
||||
await apiClient.post(endpoints.USER.ACCESS_TOKENS, payload, token),
|
||||
),
|
||||
|
||||
/**
|
||||
* New secret for the same token (name, scopes and restrictions stay); the old
|
||||
* secret stops working at once. `expiresInDays` omitted = the lifetime the
|
||||
* token was last issued with.
|
||||
*/
|
||||
regenerate: async (
|
||||
id: string,
|
||||
expiresInDays: number | undefined,
|
||||
token: string | null,
|
||||
): Promise<CreateAccessTokenResponse> =>
|
||||
parse<CreateAccessTokenResponse>(
|
||||
await apiClient.post(
|
||||
endpoints.USER.ACCESS_TOKEN_REGENERATE(encodeURIComponent(id)),
|
||||
expiresInDays === undefined ? {} : { expires_in_days: expiresInDays },
|
||||
token,
|
||||
),
|
||||
),
|
||||
|
||||
revoke: async (
|
||||
id: string,
|
||||
token: string | null,
|
||||
): Promise<{ success: boolean }> =>
|
||||
parse<{ success: boolean }>(
|
||||
await apiClient.delete(
|
||||
endpoints.USER.ACCESS_TOKEN(encodeURIComponent(id)),
|
||||
token,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
export default patService;
|
||||
@@ -7,6 +7,8 @@ const userService = {
|
||||
throttledApiClient.get(endpoints.USER.CONFIG, null),
|
||||
getMe: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.ME, token),
|
||||
getQuota: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.QUOTA, token),
|
||||
getNewToken: (): Promise<any> =>
|
||||
throttledApiClient.get(endpoints.USER.NEW_TOKEN, null),
|
||||
// Token deliberately null: a stale Authorization header must not be able
|
||||
@@ -19,12 +21,6 @@ const userService = {
|
||||
apiClient.get(`${endpoints.USER.DOCS}`, token),
|
||||
getDocsWithPagination: (query: string, token: string | null): Promise<any> =>
|
||||
apiClient.get(`${endpoints.USER.DOCS_PAGINATED}?${query}`, token),
|
||||
getAPIKeys: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.API_KEYS, token),
|
||||
createAPIKey: (data: any, token: string | null): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.CREATE_API_KEY, data, token),
|
||||
deleteAPIKey: (data: any, token: string | null): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.DELETE_API_KEY, data, token),
|
||||
getAgent: (id: string, token: string | null): Promise<any> =>
|
||||
throttledApiClient.get(endpoints.USER.AGENT(id), token),
|
||||
getAgents: (token: string | null): Promise<any> =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExternalLink, Plus } from 'lucide-react';
|
||||
import { Plus, Share } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { ShareConversationModal } from '../modals/ShareConversationModal';
|
||||
@@ -73,11 +73,7 @@ export default function ActionButtons({
|
||||
onClick={() => setShareModalState(true)}
|
||||
className="text-muted-foreground hover:text-foreground rounded-full"
|
||||
>
|
||||
<ExternalLink
|
||||
className="size-5"
|
||||
strokeWidth={1.75}
|
||||
aria-label="share"
|
||||
/>
|
||||
<Share className="size-5" strokeWidth={1.75} aria-label="share" />
|
||||
</Button>
|
||||
{isShareModalOpen && (
|
||||
<ShareConversationModal
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function AttachFileButton({ onChange }: AttachFileButtonProps) {
|
||||
<img
|
||||
src={ClipIcon}
|
||||
alt="Attach"
|
||||
className="mr-1 h-3.5 w-3.5 sm:mr-1.5 sm:h-4 sm:w-4"
|
||||
className="mr-1 h-3.5 w-3.5 sm:mr-1.5 sm:h-4 sm:w-4 dark:invert"
|
||||
/>
|
||||
<span className="xs:text-xs dark:text-foreground text-muted-foreground text-xs font-medium sm:text-sm">
|
||||
{t('conversation.attachments.attach')}
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function SourcesTrigger({
|
||||
<img
|
||||
src={SourceIcon}
|
||||
alt="Sources"
|
||||
className="mr-1 h-3.5 w-3.5 shrink-0 sm:mr-1.5 sm:h-4"
|
||||
className="mr-1 h-3.5 w-3.5 shrink-0 sm:mr-1.5 sm:h-4 sm:w-4 dark:invert"
|
||||
/>
|
||||
<span className="xs:text-xs dark:text-foreground text-muted-foreground truncate overflow-hidden text-xs font-medium sm:text-sm">
|
||||
{selectedDocs && selectedDocs.length > 0
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function ToolsTrigger({
|
||||
<img
|
||||
src={ToolIcon}
|
||||
alt="Tools"
|
||||
className="mr-1 h-3.5 w-3.5 shrink-0 sm:mr-1.5 sm:h-4 sm:w-4"
|
||||
className="mr-1 h-3.5 w-3.5 shrink-0 sm:mr-1.5 sm:h-4 sm:w-4 dark:invert"
|
||||
/>
|
||||
<span className="xs:text-xs dark:text-foreground text-muted-foreground truncate overflow-hidden text-xs font-medium sm:text-sm">
|
||||
{t('settings.tools.label')}
|
||||
|
||||
@@ -32,6 +32,15 @@ interface MultiSelectProps {
|
||||
emptyText?: string;
|
||||
searchPlaceholder?: string;
|
||||
className?: string;
|
||||
/**
|
||||
* Set when the MultiSelect sits inside a Modal. A non-modal popover there
|
||||
* cannot scroll (the dialog's scroll lock swallows the wheel, since the
|
||||
* dropdown is portalled outside it) and never closes on an outside click
|
||||
* (Radix defers that to the document `click`, which Modal stops from
|
||||
* propagating). A modal popover owns its own scroll lock and dismisses on
|
||||
* pointerdown instead.
|
||||
*/
|
||||
modal?: boolean;
|
||||
}
|
||||
|
||||
export function MultiSelect({
|
||||
@@ -42,6 +51,7 @@ export function MultiSelect({
|
||||
emptyText = 'No results found.',
|
||||
searchPlaceholder = 'Search...',
|
||||
className,
|
||||
modal = false,
|
||||
}: MultiSelectProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
@@ -63,7 +73,7 @@ export function MultiSelect({
|
||||
.map((option) => option.label);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={setOpen} modal={modal}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExternalLink, X } from 'lucide-react';
|
||||
import { Share, X } from 'lucide-react';
|
||||
import {
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
@@ -135,7 +135,7 @@ export default function ConversationTile({
|
||||
|
||||
const menuOptions: ConversationMenuOption[] = [
|
||||
{
|
||||
icon: <ExternalLink className="size-3.5" strokeWidth={1.75} />,
|
||||
icon: <Share className="size-4" strokeWidth={1.75} />,
|
||||
label: t('convTile.share'),
|
||||
onClick: (event: SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
@@ -216,11 +216,13 @@ export default function ConversationTile({
|
||||
<div className="dark:text-muted-foreground flex text-white">
|
||||
{isEdit ? (
|
||||
<div className="flex gap-1">
|
||||
<img
|
||||
src={CheckMark2}
|
||||
alt="Edit"
|
||||
className="mr-2 h-4 w-4 cursor-pointer text-white hover:opacity-50"
|
||||
id={`img-${conversation.id}`}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t('convTile.save')}
|
||||
title={t('convTile.save')}
|
||||
className="mr-2 h-auto w-auto bg-transparent p-0 hover:bg-transparent hover:opacity-50"
|
||||
onClick={(event: SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
handleSaveConversation({
|
||||
@@ -228,12 +230,15 @@ export default function ConversationTile({
|
||||
name: conversationName,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<img src={CheckMark2} alt="" className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Exit"
|
||||
aria-label={t('cancel')}
|
||||
title={t('cancel')}
|
||||
id={`img-${conversation.id}`}
|
||||
className="mt-px mr-4 h-auto w-auto bg-transparent p-0 hover:bg-transparent hover:opacity-50"
|
||||
onClick={(event: SyntheticEvent) => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import i18n from 'i18next';
|
||||
|
||||
import { baseURL } from '../api/client';
|
||||
import conversationService from '../api/services/conversationService';
|
||||
import { Doc } from '../models/misc';
|
||||
import { Answer, FEEDBACK, RetrievalPayload } from './conversationModels';
|
||||
import { isQuotaError, quotaErrorMessage } from './quotaError';
|
||||
import { ToolCallsType } from './types';
|
||||
|
||||
/**
|
||||
@@ -48,7 +51,9 @@ async function _handlePreStreamHttpError(
|
||||
if (text) {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
if (isQuotaError(parsed)) {
|
||||
message = quotaErrorMessage(parsed, i18n.t.bind(i18n), i18n.language);
|
||||
} else if (parsed && typeof parsed === 'object') {
|
||||
message =
|
||||
(typeof parsed.message === 'string' && parsed.message) ||
|
||||
(typeof parsed.error === 'string' && parsed.error) ||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isQuotaError, quotaErrorMessage } from './quotaError';
|
||||
|
||||
const t = ((key: string, values: Record<string, string>) =>
|
||||
`${key}|${values.used}|${values.limit}|${values.resetsAt}`) as any;
|
||||
|
||||
describe('isQuotaError', () => {
|
||||
it('matches only the quota error code', () => {
|
||||
expect(isQuotaError({ error_code: 'quota-exceeded' })).toBe(true);
|
||||
expect(isQuotaError({ message: 'Exceeding usage limit' })).toBe(false);
|
||||
expect(isQuotaError(null)).toBe(false);
|
||||
expect(isQuotaError('quota-exceeded')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quotaErrorMessage', () => {
|
||||
it('formats token budgets as numbers', () => {
|
||||
const message = quotaErrorMessage(
|
||||
{
|
||||
dimension: 'tokens',
|
||||
usage: 1200000,
|
||||
limit: 1000000,
|
||||
resets_at: '2026-10-01T00:00:00+00:00',
|
||||
},
|
||||
t,
|
||||
'en-US',
|
||||
);
|
||||
const [key, used, limit, resetsAt] = message.split('|');
|
||||
expect(key).toBe('conversation.quotaExceeded.tokens');
|
||||
expect([used, limit]).toEqual(['1,200,000', '1,000,000']);
|
||||
expect(resetsAt).not.toBe('');
|
||||
});
|
||||
|
||||
it('formats cost budgets as dollars', () => {
|
||||
const message = quotaErrorMessage(
|
||||
{ dimension: 'cost', usage: 5.25, limit: 5 },
|
||||
t,
|
||||
'en-US',
|
||||
);
|
||||
expect(message).toBe('conversation.quotaExceeded.cost|$5.25|$5.00|');
|
||||
});
|
||||
|
||||
it('tolerates a malformed reset time', () => {
|
||||
const message = quotaErrorMessage(
|
||||
{ dimension: 'tokens', usage: 1, limit: 1, resets_at: 'soon' },
|
||||
t,
|
||||
'en-US',
|
||||
);
|
||||
expect(message.endsWith('|')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
export type QuotaErrorBody = {
|
||||
error_code?: string;
|
||||
dimension?: string;
|
||||
usage?: number;
|
||||
limit?: number;
|
||||
resets_at?: string;
|
||||
};
|
||||
|
||||
export function isQuotaError(body: unknown): body is QuotaErrorBody {
|
||||
return (
|
||||
!!body &&
|
||||
typeof body === 'object' &&
|
||||
(body as QuotaErrorBody).error_code === 'quota-exceeded'
|
||||
);
|
||||
}
|
||||
|
||||
/** The chat message for a 429 ``quota-exceeded`` body, in the user's language. */
|
||||
export function quotaErrorMessage(
|
||||
body: QuotaErrorBody,
|
||||
t: TFunction,
|
||||
locale?: string,
|
||||
): string {
|
||||
const isCost = body.dimension === 'cost';
|
||||
const amount = (value?: number) =>
|
||||
isCost
|
||||
? new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(value ?? 0)
|
||||
: new Intl.NumberFormat(locale).format(value ?? 0);
|
||||
const reset = body.resets_at ? new Date(body.resets_at) : null;
|
||||
const resetsAt =
|
||||
reset && !Number.isNaN(reset.getTime())
|
||||
? new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(reset)
|
||||
: '';
|
||||
return t(
|
||||
isCost
|
||||
? 'conversation.quotaExceeded.cost'
|
||||
: 'conversation.quotaExceeded.tokens',
|
||||
{ used: amount(body.usage), limit: amount(body.limit), resetsAt },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import de from './de.json';
|
||||
import en from './en.json';
|
||||
import es from './es.json';
|
||||
import jp from './jp.json';
|
||||
import ru from './ru.json';
|
||||
import zhTW from './zh-TW.json';
|
||||
import zh from './zh.json';
|
||||
|
||||
type Tree = { [key: string]: string | Tree };
|
||||
|
||||
const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
|
||||
|
||||
// Plural categories differ per language (ru adds few/many), so compare the
|
||||
// keys with the suffix stripped.
|
||||
const flatten = (tree: Tree, prefix = ''): string[] =>
|
||||
Object.entries(tree).flatMap(([key, value]) =>
|
||||
typeof value === 'string'
|
||||
? [prefix + key.replace(PLURAL_SUFFIX, '')]
|
||||
: flatten(value, `${prefix}${key}.`),
|
||||
);
|
||||
|
||||
const keysOf = (locale: { settings: object }): string[] => {
|
||||
const block = (locale.settings as Tree).accessTokens as Tree;
|
||||
return Array.from(new Set(flatten(block))).sort();
|
||||
};
|
||||
|
||||
const LOCALES = { es, de, jp, ru, zh, zhTW };
|
||||
|
||||
describe('settings.accessTokens locale block', () => {
|
||||
it.each(Object.entries(LOCALES))(
|
||||
'%s has the same keys as en',
|
||||
(_name, locale) => {
|
||||
expect(keysOf(locale)).toEqual(keysOf(en));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(Object.entries(LOCALES))(
|
||||
'%s is translated, not an English copy',
|
||||
(_name, locale) => {
|
||||
const block = (locale.settings as Tree).accessTokens as Tree;
|
||||
const source = (en.settings as Tree).accessTokens as Tree;
|
||||
expect(block.label).not.toBe(source.label);
|
||||
expect((block.created as Tree).warning).not.toBe(
|
||||
(source.created as Tree).warning,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('every locale provides both plural forms i18next falls back through', () => {
|
||||
[en, ...Object.values(LOCALES)].forEach((locale) => {
|
||||
const relative = ((locale.settings as Tree).accessTokens as Tree)
|
||||
.relative as Tree;
|
||||
expect(relative.days_one).toBeTruthy();
|
||||
expect(relative.days_other).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
+141
-10
@@ -268,6 +268,19 @@
|
||||
},
|
||||
"exposureHint": "Lade diese Quelle vorab in den Prompt oder lass den Agenten sie bei Bedarf als Werkzeug durchsuchen."
|
||||
},
|
||||
"graphRetrieval": {
|
||||
"title": "Graph-Abruf",
|
||||
"tag": "ohne Neuimport",
|
||||
"seedStrategy": "Suche beginnt bei",
|
||||
"seedStrategyHint": "Entitäten eignen sich für die meisten Dokumente. Beziehungen erreichen auch Entitäten, die in der Frage nicht vorkommen – ideal für Inhalte, die beschreiben, wie Dinge zusammenhängen.",
|
||||
"seedEntities": "Entitäten (empfohlen)",
|
||||
"seedRelationships": "Beziehungen",
|
||||
"passageNodes": "Textabschnitte in die Suche einbeziehen",
|
||||
"passageNodesHint": "Ein Abschnitt wird gefunden, wenn er zur Frage passt oder mit etwas Passendem verbunden ist. Am besten bei Graphen, die mit dieser Version erstellt wurden.",
|
||||
"blendVector": "Mit Vektorsuche kombinieren",
|
||||
"blendVectorHint": "Ergänzt Ergebnisse der Vektorsuche, damit kein Abschnitt verloren geht, den der Graph übersieht.",
|
||||
"agentToolHint": "Agenten können diesen Beziehungen auch selbst folgen, wenn die Bereitstellung dieser Quelle „Suchwerkzeug auf Abruf“ ist oder ein agentischer Agent sie nutzt."
|
||||
},
|
||||
"prescreen": {
|
||||
"enable": "LLM-Vorfilterung aktivieren",
|
||||
"warning": "Ruft eine größere Kandidatenmenge ab und filtert sie mit einem LLM. Das erhöht Latenz und Kosten pro Anfrage.",
|
||||
@@ -321,16 +334,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "Chatbots",
|
||||
"name": "Name",
|
||||
"key": "API-Schlüssel",
|
||||
"sourceDoc": "Quelldokument",
|
||||
"createNew": "Neu erstellen",
|
||||
"noData": "Keine vorhandenen Chatbots",
|
||||
"deleteConfirmation": "Bist du sicher, dass du den API-Schlüssel '{{name}}' löschen möchtest?",
|
||||
"description": "Hier kannst du deine Chatbots erstellen und verwalten. Chatbots können als Widgets auf Websites eingebunden oder in deinen Anwendungen verwendet werden."
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Analytik",
|
||||
"subtitle": "Nachrichtenvolumen, Token-Verbrauch und Nutzerfeedback in deinem Konto verfolgen",
|
||||
@@ -372,6 +375,17 @@
|
||||
"toolCalls": "Werkzeugaufrufe",
|
||||
"runSuccess": "Erfolgsquote",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"quota": {
|
||||
"title": "Ihr Nutzungskontingent",
|
||||
"resets": "Wird zurückgesetzt: {{resetsAt}}",
|
||||
"tokens": "Tokens",
|
||||
"cost": "Kosten",
|
||||
"usedOf": "{{used}} von {{limit}}",
|
||||
"scope": {
|
||||
"direct": "Chat ohne Agent",
|
||||
"agent": "Über Agenten"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -747,6 +761,118 @@
|
||||
"testFailed": "Verbindungstest fehlgeschlagen"
|
||||
}
|
||||
},
|
||||
"accessTokens": {
|
||||
"label": "Zugriffstoken",
|
||||
"subtitle": "Mit persönlichen Zugriffstoken können Skripte, die CLI und CI-Pipelines die DocsGPT-API in deinem Namen aufrufen. Gib jedem Token nur die Berechtigungen, die es braucht.",
|
||||
"createToken": "Token erstellen",
|
||||
"disabledNotice": "Persönliche Zugriffstoken sind auf diesem Server nicht verfügbar. Sie setzen eine kontobasierte Anmeldung voraus und müssen von deinem Administrator aktiviert werden.",
|
||||
"limitReached_one": "Du hast das Limit von {{count}} aktiven Token erreicht. Widerrufe eines, um ein neues zu erstellen.",
|
||||
"limitReached_other": "Du hast das Limit von {{count}} aktiven Token erreicht. Widerrufe eines, um ein neues zu erstellen.",
|
||||
"loadError": "Zugriffstoken konnten nicht geladen werden. Bitte versuche es erneut.",
|
||||
"revokeError": "Das Token konnte nicht widerrufen werden. Bitte versuche es erneut.",
|
||||
"empty": "Noch keine Zugriffstoken",
|
||||
"emptyHint": "Erstelle ein Token, um die DocsGPT-API aus Skripten und Kommandozeilen-Tools zu nutzen.",
|
||||
"name": "Name",
|
||||
"scopes": "Berechtigungen",
|
||||
"resources": "Ressourcen",
|
||||
"createdAt": "Erstellt",
|
||||
"lastUsed": "Zuletzt verwendet",
|
||||
"expires": "Läuft ab",
|
||||
"actions": "Aktionen",
|
||||
"allResources": "Alle Ressourcen",
|
||||
"never": "Nie",
|
||||
"expired": "Abgelaufen am {{date}}",
|
||||
"expiresSoon": "Läuft ab am {{date}}",
|
||||
"moreScopes": "+{{count}} weitere",
|
||||
"showLess": "Weniger anzeigen",
|
||||
"revoke": "Widerrufen",
|
||||
"revokeAria": "Token {{name}} widerrufen",
|
||||
"revokeWarning": "Token „{{name}}“ widerrufen? Alles, was es noch verwendet, funktioniert sofort nicht mehr. Das kann nicht rückgängig gemacht werden.",
|
||||
"restrictionCount": {
|
||||
"agents_one": "{{count}} Agent",
|
||||
"agents_other": "{{count}} Agenten",
|
||||
"sources_one": "{{count}} Quelle",
|
||||
"sources_other": "{{count}} Quellen",
|
||||
"prompts_one": "{{count}} Prompt",
|
||||
"prompts_other": "{{count}} Prompts",
|
||||
"tools_one": "{{count}} Tool",
|
||||
"tools_other": "{{count}} Tools",
|
||||
"workflows_one": "{{count}} Workflow",
|
||||
"workflows_other": "{{count}} Workflows"
|
||||
},
|
||||
"relative": {
|
||||
"now": "Gerade eben",
|
||||
"minutes_one": "Vor {{count}} Minute",
|
||||
"minutes_other": "Vor {{count}} Minuten",
|
||||
"hours_one": "Vor {{count}} Stunde",
|
||||
"hours_other": "Vor {{count}} Stunden",
|
||||
"days_one": "Vor {{count}} Tag",
|
||||
"days_other": "Vor {{count}} Tagen"
|
||||
},
|
||||
"families": {
|
||||
"agents": "Agenten",
|
||||
"sources": "Quellen",
|
||||
"prompts": "Prompts",
|
||||
"tools": "Tools",
|
||||
"models": "Modelle",
|
||||
"workflows": "Workflows",
|
||||
"schedules": "Zeitpläne",
|
||||
"conversations": "Unterhaltungen",
|
||||
"analytics": "Analysen",
|
||||
"teams": "Teams",
|
||||
"chat": "Chat"
|
||||
},
|
||||
"create": {
|
||||
"title": "Zugriffstoken erstellen",
|
||||
"subtitle": "Das Token handelt in deinem Namen, beschränkt auf die Berechtigungen und Ressourcen, die du hier auswählst.",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "z. B. CI-Pipeline",
|
||||
"expiration": "Ablauf",
|
||||
"expiryDays_one": "{{count}} Tag",
|
||||
"expiryDays_other": "{{count}} Tage",
|
||||
"noExpiration": "Kein Ablaufdatum",
|
||||
"noExpirationHint": "Token ohne Ablaufdatum sind riskanter, falls sie in falsche Hände geraten. Ein Ablaufdatum ist die bessere Wahl.",
|
||||
"expiresOn": "Läuft ab am {{date}}",
|
||||
"scopes": "Berechtigungen",
|
||||
"scopesHint": "Lege fest, was dieses Token darf. Schreibzugriff schließt Lesezugriff ein.",
|
||||
"impliedByWrite": "Im Schreibzugriff enthalten",
|
||||
"restrict": "Auf bestimmte Ressourcen beschränken",
|
||||
"restrictHint": "Optional. Beschränke das Token auf die ausgewählten Elemente. Lass eine Liste leer, um alle zuzulassen.",
|
||||
"restrictNoFamilies": "Wähle zuerst eine Berechtigung für Agenten, Quellen, Prompts, Tools oder Chat, um deren Ressourcen zu beschränken.",
|
||||
"allSelected": "Alle (keine Beschränkung)",
|
||||
"noResources": "Nichts gefunden.",
|
||||
"searchResources": "Suchen...",
|
||||
"resourcesError": "Die Liste konnte nicht geladen werden.",
|
||||
"retry": "Erneut versuchen",
|
||||
"cancel": "Abbrechen",
|
||||
"submit": "Token erstellen",
|
||||
"submitting": "Wird erstellt",
|
||||
"error": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut."
|
||||
},
|
||||
"created": {
|
||||
"title": "Token erstellt",
|
||||
"subtitle": "Dein neues Token „{{name}}“ ist einsatzbereit.",
|
||||
"warning": "Kopiere das Token jetzt und bewahre es an einem sicheren Ort auf. Aus Sicherheitsgründen wird es nicht noch einmal angezeigt.",
|
||||
"tokenLabel": "Dein Token",
|
||||
"usageTitle": "So verwendest du es",
|
||||
"usageHint": "Exportiere es als Umgebungsvariable und sende es als Bearer-Token im Authorization-Header.",
|
||||
"done": "Fertig"
|
||||
},
|
||||
"regenerate": {
|
||||
"action": "Neu generieren",
|
||||
"aria": "Token {{name}} neu generieren",
|
||||
"title": "Token neu generieren",
|
||||
"warning": "Neues Geheimnis für \"{{name}}\" erzeugen? Das aktuelle Geheimnis funktioniert sofort nicht mehr, alles, was es verwendet, muss aktualisiert werden. Name, Berechtigungen und Einschränkungen bleiben gleich.",
|
||||
"newExpiration": "Neues Ablaufdatum",
|
||||
"submit": "Neu generieren",
|
||||
"submitting": "Wird neu generiert…",
|
||||
"error": "Das Token konnte nicht neu generiert werden. Bitte erneut versuchen.",
|
||||
"done": {
|
||||
"title": "Token neu generiert",
|
||||
"subtitle": "Das neue Geheimnis für \"{{name}}\" ist bereit. Das alte funktioniert nicht mehr."
|
||||
}
|
||||
}
|
||||
},
|
||||
"scrollTabsLeft": "Tabs nach links scrollen",
|
||||
"tabsAriaLabel": "Einstellungs-Tabs",
|
||||
"scrollTabsRight": "Tabs nach rechts scrollen"
|
||||
@@ -1063,6 +1189,7 @@
|
||||
"share": "Teilen",
|
||||
"delete": "Löschen",
|
||||
"rename": "Umbenennen",
|
||||
"save": "Speichern",
|
||||
"deleteWarning": "Bist du sicher, dass du diese Konversation löschen möchtest?"
|
||||
},
|
||||
"pagination": {
|
||||
@@ -1146,6 +1273,10 @@
|
||||
"running": "Läuft…",
|
||||
"denied": "Vom Benutzer abgelehnt",
|
||||
"failed": "fehlgeschlagen"
|
||||
},
|
||||
"quotaExceeded": {
|
||||
"tokens": "Sie haben {{used}} von Ihrem Kontingent von {{limit}} Tokens verbraucht. Es wird am {{resetsAt}} zurückgesetzt.",
|
||||
"cost": "Sie haben {{used}} von Ihrem Nutzungsbudget von {{limit}} verbraucht. Es wird am {{resetsAt}} zurückgesetzt."
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
+141
-10
@@ -272,6 +272,19 @@
|
||||
},
|
||||
"exposureHint": "Pre-fetch this source into the prompt, or let the agent search it on demand as a tool."
|
||||
},
|
||||
"graphRetrieval": {
|
||||
"title": "Graph retrieval",
|
||||
"tag": "no re-ingest",
|
||||
"seedStrategy": "Start the walk from",
|
||||
"seedStrategyHint": "Entities suit most documents. Relationships can reach an entity the question never names, and suit content that describes how things connect.",
|
||||
"seedEntities": "Entities (recommended)",
|
||||
"seedRelationships": "Relationships",
|
||||
"passageNodes": "Include passages in the walk",
|
||||
"passageNodesHint": "Lets a passage be found both by matching the question and by being connected to what does. Works best on graphs built with this version.",
|
||||
"blendVector": "Blend with vector search",
|
||||
"blendVectorHint": "Adds plain vector search results, so a passage the graph misses is not lost.",
|
||||
"agentToolHint": "Agents can also follow these relationships themselves when this source's exposure is “On-demand search tool”, or when an agentic agent uses it."
|
||||
},
|
||||
"prescreen": {
|
||||
"enable": "Enable LLM prescreen",
|
||||
"warning": "Fetches a larger candidate set and uses an LLM to filter it. This adds query-time latency and cost.",
|
||||
@@ -325,16 +338,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "Chatbots",
|
||||
"name": "Name",
|
||||
"key": "API Key",
|
||||
"sourceDoc": "Source Document",
|
||||
"createNew": "Create New",
|
||||
"noData": "No existing Chatbots",
|
||||
"deleteConfirmation": "Are you sure you want to delete the API key '{{name}}'?",
|
||||
"description": "Here you can create and manage your chatbots. Chatbots can be deployed to websites as widgets or used inside your applications."
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Analytics",
|
||||
"subtitle": "Track message volume, token usage, and user feedback across your account",
|
||||
@@ -377,6 +380,17 @@
|
||||
"toolCalls": "Tool Calls",
|
||||
"runSuccess": "Run Success",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"quota": {
|
||||
"title": "Your usage quota",
|
||||
"resets": "Resets {{resetsAt}}",
|
||||
"tokens": "Tokens",
|
||||
"cost": "Cost",
|
||||
"usedOf": "{{used}} of {{limit}}",
|
||||
"scope": {
|
||||
"direct": "Chat without an agent",
|
||||
"agent": "Through agents"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -752,6 +766,118 @@
|
||||
"testFailed": "Connection test failed"
|
||||
}
|
||||
},
|
||||
"accessTokens": {
|
||||
"label": "Access Tokens",
|
||||
"subtitle": "Personal access tokens let scripts, the CLI and CI pipelines call the DocsGPT API on your behalf. Give each token only the scopes it needs.",
|
||||
"createToken": "Create token",
|
||||
"disabledNotice": "Personal access tokens are not available on this server. They require account-based sign-in and have to be enabled by your administrator.",
|
||||
"limitReached_one": "You have reached the limit of {{count}} active token. Revoke one to create another.",
|
||||
"limitReached_other": "You have reached the limit of {{count}} active tokens. Revoke one to create another.",
|
||||
"loadError": "Failed to load access tokens. Please try again.",
|
||||
"revokeError": "Failed to revoke the token. Please try again.",
|
||||
"empty": "No access tokens yet",
|
||||
"emptyHint": "Create a token to use the DocsGPT API from scripts and command-line tools.",
|
||||
"name": "Name",
|
||||
"scopes": "Scopes",
|
||||
"resources": "Resources",
|
||||
"createdAt": "Created",
|
||||
"lastUsed": "Last used",
|
||||
"expires": "Expires",
|
||||
"actions": "Actions",
|
||||
"allResources": "All resources",
|
||||
"never": "Never",
|
||||
"expired": "Expired {{date}}",
|
||||
"expiresSoon": "Expires {{date}}",
|
||||
"moreScopes": "+{{count}} more",
|
||||
"showLess": "Show less",
|
||||
"revoke": "Revoke",
|
||||
"revokeAria": "Revoke token {{name}}",
|
||||
"revokeWarning": "Revoke the token \"{{name}}\"? Anything still using it will stop working immediately. This cannot be undone.",
|
||||
"restrictionCount": {
|
||||
"agents_one": "{{count}} agent",
|
||||
"agents_other": "{{count}} agents",
|
||||
"sources_one": "{{count}} source",
|
||||
"sources_other": "{{count}} sources",
|
||||
"prompts_one": "{{count}} prompt",
|
||||
"prompts_other": "{{count}} prompts",
|
||||
"tools_one": "{{count}} tool",
|
||||
"tools_other": "{{count}} tools",
|
||||
"workflows_one": "{{count}} workflow",
|
||||
"workflows_other": "{{count}} workflows"
|
||||
},
|
||||
"relative": {
|
||||
"now": "Just now",
|
||||
"minutes_one": "{{count}} minute ago",
|
||||
"minutes_other": "{{count}} minutes ago",
|
||||
"hours_one": "{{count}} hour ago",
|
||||
"hours_other": "{{count}} hours ago",
|
||||
"days_one": "{{count}} day ago",
|
||||
"days_other": "{{count}} days ago"
|
||||
},
|
||||
"families": {
|
||||
"agents": "Agents",
|
||||
"sources": "Sources",
|
||||
"prompts": "Prompts",
|
||||
"tools": "Tools",
|
||||
"models": "Models",
|
||||
"workflows": "Workflows",
|
||||
"schedules": "Schedules",
|
||||
"conversations": "Conversations",
|
||||
"analytics": "Analytics",
|
||||
"teams": "Teams",
|
||||
"chat": "Chat"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create access token",
|
||||
"subtitle": "The token acts as you, limited to the scopes and resources you pick here.",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "e.g. CI pipeline",
|
||||
"expiration": "Expiration",
|
||||
"expiryDays_one": "{{count}} day",
|
||||
"expiryDays_other": "{{count}} days",
|
||||
"noExpiration": "No expiration",
|
||||
"noExpirationHint": "Tokens that never expire are riskier if leaked. Prefer an expiration date.",
|
||||
"expiresOn": "Expires on {{date}}",
|
||||
"scopes": "Scopes",
|
||||
"scopesHint": "Choose what this token may do. Write access includes read access.",
|
||||
"impliedByWrite": "Included with write access",
|
||||
"restrict": "Restrict to specific resources",
|
||||
"restrictHint": "Optional. Limit the token to the items you select. Leave a list empty to allow all of them.",
|
||||
"restrictNoFamilies": "Select a scope for agents, sources, prompts, tools or chat first to restrict its resources.",
|
||||
"allSelected": "All (no restriction)",
|
||||
"noResources": "Nothing found.",
|
||||
"searchResources": "Search...",
|
||||
"resourcesError": "Failed to load the list.",
|
||||
"retry": "Retry",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Create token",
|
||||
"submitting": "Creating",
|
||||
"error": "Failed to create the token. Please try again."
|
||||
},
|
||||
"created": {
|
||||
"title": "Token created",
|
||||
"subtitle": "Your new token \"{{name}}\" is ready.",
|
||||
"warning": "Copy the token now and store it somewhere safe. For security reasons it won't be shown again.",
|
||||
"tokenLabel": "Your token",
|
||||
"usageTitle": "How to use it",
|
||||
"usageHint": "Export it as an environment variable, then send it as a Bearer token in the Authorization header.",
|
||||
"done": "Done"
|
||||
},
|
||||
"regenerate": {
|
||||
"action": "Regenerate",
|
||||
"aria": "Regenerate token {{name}}",
|
||||
"title": "Regenerate token",
|
||||
"warning": "Generate a new secret for \"{{name}}\"? The current secret stops working immediately, so anything using it must be updated. The name, scopes and restrictions stay the same.",
|
||||
"newExpiration": "New expiration",
|
||||
"submit": "Regenerate",
|
||||
"submitting": "Regenerating…",
|
||||
"error": "Failed to regenerate the token. Please try again.",
|
||||
"done": {
|
||||
"title": "Token regenerated",
|
||||
"subtitle": "The new secret for \"{{name}}\" is ready. The old one no longer works."
|
||||
}
|
||||
}
|
||||
},
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"tabsAriaLabel": "Settings tabs",
|
||||
"scrollTabsRight": "Scroll tabs right"
|
||||
@@ -1068,6 +1194,7 @@
|
||||
"share": "Share",
|
||||
"delete": "Delete",
|
||||
"rename": "Rename",
|
||||
"save": "Save",
|
||||
"deleteWarning": "Are you sure you want to delete this conversation?"
|
||||
},
|
||||
"pagination": {
|
||||
@@ -1157,6 +1284,10 @@
|
||||
"running": "Running…",
|
||||
"denied": "Denied by user",
|
||||
"failed": "failed"
|
||||
},
|
||||
"quotaExceeded": {
|
||||
"tokens": "You've used {{used}} of your {{limit}} token quota. It resets {{resetsAt}}.",
|
||||
"cost": "You've used {{used}} of your {{limit}} usage budget. It resets {{resetsAt}}."
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
+141
-10
@@ -268,6 +268,19 @@
|
||||
},
|
||||
"exposureHint": "Precarga esta fuente en el prompt, o deja que el agente la busque bajo demanda como herramienta."
|
||||
},
|
||||
"graphRetrieval": {
|
||||
"title": "Recuperación por grafo",
|
||||
"tag": "sin reingesta",
|
||||
"seedStrategy": "Iniciar el recorrido desde",
|
||||
"seedStrategyHint": "Las entidades funcionan para la mayoría de documentos. Las relaciones pueden llegar a una entidad que la pregunta no menciona; son ideales para contenido que describe cómo se conectan las cosas.",
|
||||
"seedEntities": "Entidades (recomendado)",
|
||||
"seedRelationships": "Relaciones",
|
||||
"passageNodes": "Incluir fragmentos en el recorrido",
|
||||
"passageNodesHint": "Un fragmento puede encontrarse por coincidir con la pregunta o por estar conectado con lo que coincide. Funciona mejor en grafos creados con esta versión.",
|
||||
"blendVector": "Combinar con búsqueda vectorial",
|
||||
"blendVectorHint": "Añade resultados de la búsqueda vectorial para no perder fragmentos que el grafo pase por alto.",
|
||||
"agentToolHint": "Los agentes también pueden seguir estas relaciones por sí mismos cuando la exposición de esta fuente es «Herramienta de búsqueda bajo demanda» o cuando la usa un agente agéntico."
|
||||
},
|
||||
"prescreen": {
|
||||
"enable": "Habilitar preselección con LLM",
|
||||
"warning": "Obtiene un conjunto de candidatos más grande y usa un LLM para filtrarlo. Esto añade latencia y costo por consulta.",
|
||||
@@ -321,16 +334,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "Chatbots",
|
||||
"name": "Nombre",
|
||||
"key": "Clave de API",
|
||||
"sourceDoc": "Documento Fuente",
|
||||
"createNew": "Crear Nuevo",
|
||||
"noData": "No hay chatbots existentes",
|
||||
"deleteConfirmation": "¿Estás seguro de que quieres eliminar la clave API '{{name}}'?",
|
||||
"description": "Aquí puede crear y gestionar sus chatbots. Los chatbots se pueden implementar en sitios web como widgets o utilizarse dentro de sus aplicaciones."
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Analítica",
|
||||
"subtitle": "Realiza un seguimiento del volumen de mensajes, uso de tokens y comentarios de usuarios en tu cuenta",
|
||||
@@ -372,6 +375,17 @@
|
||||
"toolCalls": "Llamadas a Herramientas",
|
||||
"runSuccess": "Éxito de Ejecución",
|
||||
"feedback": "Retroalimentación"
|
||||
},
|
||||
"quota": {
|
||||
"title": "Tu cuota de uso",
|
||||
"resets": "Se restablece el {{resetsAt}}",
|
||||
"tokens": "Tokens",
|
||||
"cost": "Coste",
|
||||
"usedOf": "{{used}} de {{limit}}",
|
||||
"scope": {
|
||||
"direct": "Chat sin agente",
|
||||
"agent": "A través de agentes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -747,6 +761,118 @@
|
||||
"testFailed": "La prueba de conexión falló"
|
||||
}
|
||||
},
|
||||
"accessTokens": {
|
||||
"label": "Tokens de acceso",
|
||||
"subtitle": "Los tokens de acceso personal permiten que scripts, la CLI y pipelines de CI llamen a la API de DocsGPT en tu nombre. Da a cada token solo los permisos que necesite.",
|
||||
"createToken": "Crear token",
|
||||
"disabledNotice": "Los tokens de acceso personal no están disponibles en este servidor. Requieren inicio de sesión con cuenta y deben ser habilitados por tu administrador.",
|
||||
"limitReached_one": "Has alcanzado el límite de {{count}} token activo. Revoca uno para crear otro.",
|
||||
"limitReached_other": "Has alcanzado el límite de {{count}} tokens activos. Revoca uno para crear otro.",
|
||||
"loadError": "No se pudieron cargar los tokens de acceso. Inténtalo de nuevo.",
|
||||
"revokeError": "No se pudo revocar el token. Inténtalo de nuevo.",
|
||||
"empty": "Aún no hay tokens de acceso",
|
||||
"emptyHint": "Crea un token para usar la API de DocsGPT desde scripts y herramientas de línea de comandos.",
|
||||
"name": "Nombre",
|
||||
"scopes": "Permisos",
|
||||
"resources": "Recursos",
|
||||
"createdAt": "Creado",
|
||||
"lastUsed": "Último uso",
|
||||
"expires": "Caduca",
|
||||
"actions": "Acciones",
|
||||
"allResources": "Todos los recursos",
|
||||
"never": "Nunca",
|
||||
"expired": "Caducó el {{date}}",
|
||||
"expiresSoon": "Caduca el {{date}}",
|
||||
"moreScopes": "+{{count}} más",
|
||||
"showLess": "Mostrar menos",
|
||||
"revoke": "Revocar",
|
||||
"revokeAria": "Revocar el token {{name}}",
|
||||
"revokeWarning": "¿Revocar el token \"{{name}}\"? Todo lo que aún lo use dejará de funcionar de inmediato. Esta acción no se puede deshacer.",
|
||||
"restrictionCount": {
|
||||
"agents_one": "{{count}} agente",
|
||||
"agents_other": "{{count}} agentes",
|
||||
"sources_one": "{{count}} fuente",
|
||||
"sources_other": "{{count}} fuentes",
|
||||
"prompts_one": "{{count}} prompt",
|
||||
"prompts_other": "{{count}} prompts",
|
||||
"tools_one": "{{count}} herramienta",
|
||||
"tools_other": "{{count}} herramientas",
|
||||
"workflows_one": "{{count}} flujo de trabajo",
|
||||
"workflows_other": "{{count}} flujos de trabajo"
|
||||
},
|
||||
"relative": {
|
||||
"now": "Ahora mismo",
|
||||
"minutes_one": "Hace {{count}} minuto",
|
||||
"minutes_other": "Hace {{count}} minutos",
|
||||
"hours_one": "Hace {{count}} hora",
|
||||
"hours_other": "Hace {{count}} horas",
|
||||
"days_one": "Hace {{count}} día",
|
||||
"days_other": "Hace {{count}} días"
|
||||
},
|
||||
"families": {
|
||||
"agents": "Agentes",
|
||||
"sources": "Fuentes",
|
||||
"prompts": "Prompts",
|
||||
"tools": "Herramientas",
|
||||
"models": "Modelos",
|
||||
"workflows": "Flujos de trabajo",
|
||||
"schedules": "Programaciones",
|
||||
"conversations": "Conversaciones",
|
||||
"analytics": "Analíticas",
|
||||
"teams": "Equipos",
|
||||
"chat": "Chat"
|
||||
},
|
||||
"create": {
|
||||
"title": "Crear token de acceso",
|
||||
"subtitle": "El token actúa en tu nombre, limitado a los permisos y recursos que elijas aquí.",
|
||||
"name": "Nombre",
|
||||
"namePlaceholder": "p. ej. Pipeline de CI",
|
||||
"expiration": "Caducidad",
|
||||
"expiryDays_one": "{{count}} día",
|
||||
"expiryDays_other": "{{count}} días",
|
||||
"noExpiration": "Sin caducidad",
|
||||
"noExpirationHint": "Los tokens que nunca caducan son más peligrosos si se filtran. Es preferible fijar una fecha de caducidad.",
|
||||
"expiresOn": "Caduca el {{date}}",
|
||||
"scopes": "Permisos",
|
||||
"scopesHint": "Elige qué puede hacer este token. El acceso de escritura incluye el de lectura.",
|
||||
"impliedByWrite": "Incluido con el acceso de escritura",
|
||||
"restrict": "Restringir a recursos específicos",
|
||||
"restrictHint": "Opcional. Limita el token a los elementos que selecciones. Deja una lista vacía para permitirlos todos.",
|
||||
"restrictNoFamilies": "Selecciona primero un permiso de agentes, fuentes, prompts, herramientas o chat para restringir sus recursos.",
|
||||
"allSelected": "Todos (sin restricción)",
|
||||
"noResources": "No se encontró nada.",
|
||||
"searchResources": "Buscar...",
|
||||
"resourcesError": "No se pudo cargar la lista.",
|
||||
"retry": "Reintentar",
|
||||
"cancel": "Cancelar",
|
||||
"submit": "Crear token",
|
||||
"submitting": "Creando",
|
||||
"error": "No se pudo crear el token. Inténtalo de nuevo."
|
||||
},
|
||||
"created": {
|
||||
"title": "Token creado",
|
||||
"subtitle": "Tu nuevo token \"{{name}}\" está listo.",
|
||||
"warning": "Copia el token ahora y guárdalo en un lugar seguro. Por motivos de seguridad no se volverá a mostrar.",
|
||||
"tokenLabel": "Tu token",
|
||||
"usageTitle": "Cómo usarlo",
|
||||
"usageHint": "Expórtalo como variable de entorno y envíalo como token Bearer en la cabecera Authorization.",
|
||||
"done": "Listo"
|
||||
},
|
||||
"regenerate": {
|
||||
"action": "Regenerar",
|
||||
"aria": "Regenerar el token {{name}}",
|
||||
"title": "Regenerar token",
|
||||
"warning": "¿Generar un nuevo secreto para \"{{name}}\"? El secreto actual deja de funcionar de inmediato, así que todo lo que lo use debe actualizarse. El nombre, los permisos y las restricciones no cambian.",
|
||||
"newExpiration": "Nueva caducidad",
|
||||
"submit": "Regenerar",
|
||||
"submitting": "Regenerando…",
|
||||
"error": "No se pudo regenerar el token. Inténtalo de nuevo.",
|
||||
"done": {
|
||||
"title": "Token regenerado",
|
||||
"subtitle": "El nuevo secreto de \"{{name}}\" está listo. El anterior ya no funciona."
|
||||
}
|
||||
}
|
||||
},
|
||||
"scrollTabsLeft": "Desplazar pestañas a la izquierda",
|
||||
"tabsAriaLabel": "Pestañas de configuración",
|
||||
"scrollTabsRight": "Desplazar pestañas a la derecha"
|
||||
@@ -1063,6 +1189,7 @@
|
||||
"share": "Compartir",
|
||||
"delete": "Eliminar",
|
||||
"rename": "Renombrar",
|
||||
"save": "Guardar",
|
||||
"deleteWarning": "¿Está seguro de que desea eliminar esta conversación?"
|
||||
},
|
||||
"pagination": {
|
||||
@@ -1146,6 +1273,10 @@
|
||||
"running": "Ejecutando…",
|
||||
"denied": "Denegado por el usuario",
|
||||
"failed": "falló"
|
||||
},
|
||||
"quotaExceeded": {
|
||||
"tokens": "Has usado {{used}} de tu cuota de {{limit}} tokens. Se restablece el {{resetsAt}}.",
|
||||
"cost": "Has usado {{used}} de tu presupuesto de uso de {{limit}}. Se restablece el {{resetsAt}}."
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
+141
-10
@@ -268,6 +268,19 @@
|
||||
},
|
||||
"exposureHint": "このソースをプロンプトに事前取得するか、エージェントがツールとして必要に応じて検索できるようにします。"
|
||||
},
|
||||
"graphRetrieval": {
|
||||
"title": "グラフ検索",
|
||||
"tag": "再取り込み不要",
|
||||
"seedStrategy": "探索の開始点",
|
||||
"seedStrategyHint": "ほとんどのドキュメントにはエンティティが適しています。リレーションは質問に登場しないエンティティにも到達でき、物事のつながりを説明するコンテンツに向いています。",
|
||||
"seedEntities": "エンティティ(推奨)",
|
||||
"seedRelationships": "リレーション",
|
||||
"passageNodes": "パッセージを探索に含める",
|
||||
"passageNodesHint": "質問に一致するパッセージだけでなく、一致したものとつながるパッセージも見つけられます。このバージョン以降に構築したグラフで最も効果的です。",
|
||||
"blendVector": "ベクトル検索と組み合わせる",
|
||||
"blendVectorHint": "ベクトル検索の結果を加え、グラフが見落としたパッセージも失わないようにします。",
|
||||
"agentToolHint": "このソースの公開方法が「オンデマンド検索ツール」の場合、またはエージェント型エージェントが使用する場合、エージェントはこれらのリレーションを自ら辿ることもできます。"
|
||||
},
|
||||
"prescreen": {
|
||||
"enable": "LLMプリスクリーニングを有効にする",
|
||||
"warning": "より多くの候補を取得し、LLMでフィルタリングします。クエリ時のレイテンシとコストが増加します。",
|
||||
@@ -321,16 +334,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "チャットボット",
|
||||
"name": "名前",
|
||||
"key": "APIキー",
|
||||
"sourceDoc": "ソースドキュメント",
|
||||
"createNew": "新規作成",
|
||||
"noData": "既存のチャットボットはありません",
|
||||
"deleteConfirmation": "APIキー '{{name}}' を削除してもよろしいですか?",
|
||||
"description": "ここでチャットボットを作成・管理できます。チャットボットはウィジェットとしてウェブサイトに導入したり、アプリケーション内で使用したりすることができます。"
|
||||
},
|
||||
"analytics": {
|
||||
"label": "分析",
|
||||
"subtitle": "アカウント全体のメッセージ量、トークン使用量、ユーザーフィードバックを追跡",
|
||||
@@ -372,6 +375,17 @@
|
||||
"toolCalls": "ツール呼び出し",
|
||||
"runSuccess": "実行成功率",
|
||||
"feedback": "フィードバック"
|
||||
},
|
||||
"quota": {
|
||||
"title": "利用クォータ",
|
||||
"resets": "{{resetsAt}} にリセット",
|
||||
"tokens": "トークン",
|
||||
"cost": "コスト",
|
||||
"usedOf": "{{used}} / {{limit}}",
|
||||
"scope": {
|
||||
"direct": "エージェントなしのチャット",
|
||||
"agent": "エージェント経由"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -747,6 +761,118 @@
|
||||
"testFailed": "接続テストに失敗しました"
|
||||
}
|
||||
},
|
||||
"accessTokens": {
|
||||
"label": "アクセストークン",
|
||||
"subtitle": "個人用アクセストークンを使うと、スクリプト、CLI、CIパイプラインがあなたに代わってDocsGPT APIを呼び出せます。各トークンには必要なスコープだけを付与してください。",
|
||||
"createToken": "トークンを作成",
|
||||
"disabledNotice": "このサーバーでは個人用アクセストークンを利用できません。アカウントでのサインインが必要で、管理者が有効にする必要があります。",
|
||||
"limitReached_one": "有効なトークンが上限の{{count}}個に達しました。新しく作成するには、いずれかを取り消してください。",
|
||||
"limitReached_other": "有効なトークンが上限の{{count}}個に達しました。新しく作成するには、いずれかを取り消してください。",
|
||||
"loadError": "アクセストークンを読み込めませんでした。もう一度お試しください。",
|
||||
"revokeError": "トークンを取り消せませんでした。もう一度お試しください。",
|
||||
"empty": "アクセストークンはまだありません",
|
||||
"emptyHint": "トークンを作成すると、スクリプトやコマンドラインツールからDocsGPT APIを利用できます。",
|
||||
"name": "名前",
|
||||
"scopes": "スコープ",
|
||||
"resources": "リソース",
|
||||
"createdAt": "作成日",
|
||||
"lastUsed": "最終使用",
|
||||
"expires": "有効期限",
|
||||
"actions": "操作",
|
||||
"allResources": "すべてのリソース",
|
||||
"never": "なし",
|
||||
"expired": "{{date}}に期限切れ",
|
||||
"expiresSoon": "{{date}}に期限切れ予定",
|
||||
"moreScopes": "他{{count}}件",
|
||||
"showLess": "折りたたむ",
|
||||
"revoke": "取り消し",
|
||||
"revokeAria": "トークン{{name}}を取り消す",
|
||||
"revokeWarning": "トークン「{{name}}」を取り消しますか?このトークンを使用しているものはすぐに動作しなくなります。この操作は元に戻せません。",
|
||||
"restrictionCount": {
|
||||
"agents_one": "エージェント{{count}}件",
|
||||
"agents_other": "エージェント{{count}}件",
|
||||
"sources_one": "ソース{{count}}件",
|
||||
"sources_other": "ソース{{count}}件",
|
||||
"prompts_one": "プロンプト{{count}}件",
|
||||
"prompts_other": "プロンプト{{count}}件",
|
||||
"tools_one": "ツール{{count}}件",
|
||||
"tools_other": "ツール{{count}}件",
|
||||
"workflows_one": "ワークフロー{{count}}件",
|
||||
"workflows_other": "ワークフロー{{count}}件"
|
||||
},
|
||||
"relative": {
|
||||
"now": "たった今",
|
||||
"minutes_one": "{{count}}分前",
|
||||
"minutes_other": "{{count}}分前",
|
||||
"hours_one": "{{count}}時間前",
|
||||
"hours_other": "{{count}}時間前",
|
||||
"days_one": "{{count}}日前",
|
||||
"days_other": "{{count}}日前"
|
||||
},
|
||||
"families": {
|
||||
"agents": "エージェント",
|
||||
"sources": "ソース",
|
||||
"prompts": "プロンプト",
|
||||
"tools": "ツール",
|
||||
"models": "モデル",
|
||||
"workflows": "ワークフロー",
|
||||
"schedules": "スケジュール",
|
||||
"conversations": "会話",
|
||||
"analytics": "分析",
|
||||
"teams": "チーム",
|
||||
"chat": "チャット"
|
||||
},
|
||||
"create": {
|
||||
"title": "アクセストークンを作成",
|
||||
"subtitle": "トークンはあなたとして動作し、ここで選択したスコープとリソースに制限されます。",
|
||||
"name": "名前",
|
||||
"namePlaceholder": "例: CIパイプライン",
|
||||
"expiration": "有効期限",
|
||||
"expiryDays_one": "{{count}}日",
|
||||
"expiryDays_other": "{{count}}日",
|
||||
"noExpiration": "無期限",
|
||||
"noExpirationHint": "無期限のトークンは漏洩した場合のリスクが高くなります。有効期限の設定をおすすめします。",
|
||||
"expiresOn": "{{date}}に期限切れ",
|
||||
"scopes": "スコープ",
|
||||
"scopesHint": "このトークンに許可する操作を選択します。書き込み権限には読み取り権限が含まれます。",
|
||||
"impliedByWrite": "書き込み権限に含まれます",
|
||||
"restrict": "特定のリソースに制限する",
|
||||
"restrictHint": "任意。選択した項目だけにトークンを制限します。リストを空のままにすると、すべて許可されます。",
|
||||
"restrictNoFamilies": "リソースを制限するには、まずエージェント、ソース、プロンプト、ツール、またはチャットのスコープを選択してください。",
|
||||
"allSelected": "すべて(制限なし)",
|
||||
"noResources": "見つかりませんでした。",
|
||||
"searchResources": "検索...",
|
||||
"resourcesError": "リストを読み込めませんでした。",
|
||||
"retry": "再試行",
|
||||
"cancel": "キャンセル",
|
||||
"submit": "トークンを作成",
|
||||
"submitting": "作成中",
|
||||
"error": "トークンを作成できませんでした。もう一度お試しください。"
|
||||
},
|
||||
"created": {
|
||||
"title": "トークンを作成しました",
|
||||
"subtitle": "新しいトークン「{{name}}」の準備ができました。",
|
||||
"warning": "今すぐトークンをコピーして、安全な場所に保管してください。セキュリティ上の理由から、再度表示されることはありません。",
|
||||
"tokenLabel": "あなたのトークン",
|
||||
"usageTitle": "使い方",
|
||||
"usageHint": "環境変数としてエクスポートし、AuthorizationヘッダーのBearerトークンとして送信します。",
|
||||
"done": "完了"
|
||||
},
|
||||
"regenerate": {
|
||||
"action": "再生成",
|
||||
"aria": "トークン {{name}} を再生成",
|
||||
"title": "トークンを再生成",
|
||||
"warning": "「{{name}}」の新しいシークレットを生成しますか?現在のシークレットは直ちに無効になるため、使用している箇所をすべて更新する必要があります。名前、スコープ、制限は変わりません。",
|
||||
"newExpiration": "新しい有効期限",
|
||||
"submit": "再生成",
|
||||
"submitting": "再生成中…",
|
||||
"error": "トークンを再生成できませんでした。もう一度お試しください。",
|
||||
"done": {
|
||||
"title": "トークンを再生成しました",
|
||||
"subtitle": "「{{name}}」の新しいシークレットの準備ができました。以前のものは使用できません。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scrollTabsLeft": "タブを左にスクロール",
|
||||
"tabsAriaLabel": "設定タブ",
|
||||
"scrollTabsRight": "タブを右にスクロール"
|
||||
@@ -1063,6 +1189,7 @@
|
||||
"share": "共有",
|
||||
"delete": "削除",
|
||||
"rename": "名前変更",
|
||||
"save": "保存",
|
||||
"deleteWarning": "この会話を削除してもよろしいですか?"
|
||||
},
|
||||
"pagination": {
|
||||
@@ -1146,6 +1273,10 @@
|
||||
"running": "実行中…",
|
||||
"denied": "ユーザーによって拒否されました",
|
||||
"failed": "失敗"
|
||||
},
|
||||
"quotaExceeded": {
|
||||
"tokens": "トークンクォータ {{limit}} のうち {{used}} を使用しました。{{resetsAt}} にリセットされます。",
|
||||
"cost": "利用予算 {{limit}} のうち {{used}} を使用しました。{{resetsAt}} にリセットされます。"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
+161
-10
@@ -268,6 +268,19 @@
|
||||
},
|
||||
"exposureHint": "Предзагружать этот источник в промпт или позволить агенту искать по нему по мере необходимости как по инструменту."
|
||||
},
|
||||
"graphRetrieval": {
|
||||
"title": "Поиск по графу",
|
||||
"tag": "без повторной загрузки",
|
||||
"seedStrategy": "Начинать обход с",
|
||||
"seedStrategyHint": "Сущности подходят для большинства документов. Связи позволяют дойти до сущности, которая не упоминается в вопросе, — хорошо для контента о том, как всё связано.",
|
||||
"seedEntities": "Сущностей (рекомендуется)",
|
||||
"seedRelationships": "Связей",
|
||||
"passageNodes": "Включать фрагменты в обход",
|
||||
"passageNodesHint": "Фрагмент находится, если он соответствует вопросу или связан с тем, что соответствует. Лучше всего работает на графах, построенных в этой версии.",
|
||||
"blendVector": "Сочетать с векторным поиском",
|
||||
"blendVectorHint": "Добавляет результаты векторного поиска, чтобы не терять фрагменты, пропущенные графом.",
|
||||
"agentToolHint": "Агенты также могут сами проходить по этим связям, если для источника выбран режим «Инструмент поиска по запросу» или его использует агентный агент."
|
||||
},
|
||||
"prescreen": {
|
||||
"enable": "Включить предварительный отбор LLM",
|
||||
"warning": "Извлекается расширенный набор кандидатов, который затем фильтруется с помощью LLM. Это увеличивает задержку и стоимость запроса.",
|
||||
@@ -321,16 +334,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "API ключи",
|
||||
"name": "Название",
|
||||
"key": "API ключ",
|
||||
"sourceDoc": "Источник документа",
|
||||
"createNew": "Создать новый",
|
||||
"noData": "Нет существующих чатботов",
|
||||
"deleteConfirmation": "Вы уверены, что хотите удалить API ключ '{{name}}'?",
|
||||
"description": "Здесь вы можете создавать и управлять чат-ботами. Чат-боты могут быть развернуты на веб-сайтах в виде виджетов или использоваться внутри ваших приложений."
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Аналитика",
|
||||
"subtitle": "Отслеживайте объём сообщений, использование токенов и отзывы пользователей в вашем аккаунте",
|
||||
@@ -372,6 +375,17 @@
|
||||
"toolCalls": "Вызовы инструментов",
|
||||
"runSuccess": "Успешность запусков",
|
||||
"feedback": "Обратная связь"
|
||||
},
|
||||
"quota": {
|
||||
"title": "Ваша квота использования",
|
||||
"resets": "Сброс: {{resetsAt}}",
|
||||
"tokens": "Токены",
|
||||
"cost": "Стоимость",
|
||||
"usedOf": "{{used}} из {{limit}}",
|
||||
"scope": {
|
||||
"direct": "Чат без агента",
|
||||
"agent": "Через агентов"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -747,6 +761,138 @@
|
||||
"testFailed": "Проверка соединения не удалась"
|
||||
}
|
||||
},
|
||||
"accessTokens": {
|
||||
"label": "Токены доступа",
|
||||
"subtitle": "Персональные токены доступа позволяют скриптам, CLI и CI-конвейерам обращаться к API DocsGPT от вашего имени. Выдавайте каждому токену только необходимые права.",
|
||||
"createToken": "Создать токен",
|
||||
"disabledNotice": "Персональные токены доступа недоступны на этом сервере. Для них нужен вход через учётную запись, и их должен включить администратор.",
|
||||
"limitReached_one": "Достигнут лимит активных токенов: {{count}}. Отзовите один, чтобы создать новый.",
|
||||
"limitReached_few": "Достигнут лимит активных токенов: {{count}}. Отзовите один, чтобы создать новый.",
|
||||
"limitReached_many": "Достигнут лимит активных токенов: {{count}}. Отзовите один, чтобы создать новый.",
|
||||
"limitReached_other": "Достигнут лимит активных токенов: {{count}}. Отзовите один, чтобы создать новый.",
|
||||
"loadError": "Не удалось загрузить токены доступа. Попробуйте ещё раз.",
|
||||
"revokeError": "Не удалось отозвать токен. Попробуйте ещё раз.",
|
||||
"empty": "Токенов доступа пока нет",
|
||||
"emptyHint": "Создайте токен, чтобы использовать API DocsGPT из скриптов и инструментов командной строки.",
|
||||
"name": "Название",
|
||||
"scopes": "Права",
|
||||
"resources": "Ресурсы",
|
||||
"createdAt": "Создан",
|
||||
"lastUsed": "Последнее использование",
|
||||
"expires": "Истекает",
|
||||
"actions": "Действия",
|
||||
"allResources": "Все ресурсы",
|
||||
"never": "Никогда",
|
||||
"expired": "Истёк {{date}}",
|
||||
"expiresSoon": "Истекает {{date}}",
|
||||
"moreScopes": "ещё {{count}}",
|
||||
"showLess": "Свернуть",
|
||||
"revoke": "Отозвать",
|
||||
"revokeAria": "Отозвать токен {{name}}",
|
||||
"revokeWarning": "Отозвать токен «{{name}}»? Всё, что его использует, сразу перестанет работать. Это действие нельзя отменить.",
|
||||
"restrictionCount": {
|
||||
"agents_one": "{{count}} агент",
|
||||
"agents_few": "{{count}} агента",
|
||||
"agents_many": "{{count}} агентов",
|
||||
"agents_other": "{{count}} агента",
|
||||
"sources_one": "{{count}} источник",
|
||||
"sources_few": "{{count}} источника",
|
||||
"sources_many": "{{count}} источников",
|
||||
"sources_other": "{{count}} источника",
|
||||
"prompts_one": "{{count}} промпт",
|
||||
"prompts_few": "{{count}} промпта",
|
||||
"prompts_many": "{{count}} промптов",
|
||||
"prompts_other": "{{count}} промпта",
|
||||
"tools_one": "{{count}} инструмент",
|
||||
"tools_few": "{{count}} инструмента",
|
||||
"tools_many": "{{count}} инструментов",
|
||||
"tools_other": "{{count}} инструмента",
|
||||
"workflows_one": "{{count}} рабочий процесс",
|
||||
"workflows_few": "{{count}} рабочих процесса",
|
||||
"workflows_many": "{{count}} рабочих процессов",
|
||||
"workflows_other": "{{count}} рабочего процесса"
|
||||
},
|
||||
"relative": {
|
||||
"now": "Только что",
|
||||
"minutes_one": "{{count}} минуту назад",
|
||||
"minutes_few": "{{count}} минуты назад",
|
||||
"minutes_many": "{{count}} минут назад",
|
||||
"minutes_other": "{{count}} минуты назад",
|
||||
"hours_one": "{{count}} час назад",
|
||||
"hours_few": "{{count}} часа назад",
|
||||
"hours_many": "{{count}} часов назад",
|
||||
"hours_other": "{{count}} часа назад",
|
||||
"days_one": "{{count}} день назад",
|
||||
"days_few": "{{count}} дня назад",
|
||||
"days_many": "{{count}} дней назад",
|
||||
"days_other": "{{count}} дня назад"
|
||||
},
|
||||
"families": {
|
||||
"agents": "Агенты",
|
||||
"sources": "Источники",
|
||||
"prompts": "Промпты",
|
||||
"tools": "Инструменты",
|
||||
"models": "Модели",
|
||||
"workflows": "Рабочие процессы",
|
||||
"schedules": "Расписания",
|
||||
"conversations": "Диалоги",
|
||||
"analytics": "Аналитика",
|
||||
"teams": "Команды",
|
||||
"chat": "Чат"
|
||||
},
|
||||
"create": {
|
||||
"title": "Создать токен доступа",
|
||||
"subtitle": "Токен действует от вашего имени в пределах прав и ресурсов, которые вы выберете здесь.",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "например, CI-конвейер",
|
||||
"expiration": "Срок действия",
|
||||
"expiryDays_one": "{{count}} день",
|
||||
"expiryDays_few": "{{count}} дня",
|
||||
"expiryDays_many": "{{count}} дней",
|
||||
"expiryDays_other": "{{count}} дня",
|
||||
"noExpiration": "Бессрочно",
|
||||
"noExpirationHint": "Бессрочные токены опаснее при утечке. Лучше задать срок действия.",
|
||||
"expiresOn": "Истекает {{date}}",
|
||||
"scopes": "Права",
|
||||
"scopesHint": "Выберите, что разрешено этому токену. Право на запись включает право на чтение.",
|
||||
"impliedByWrite": "Входит в право на запись",
|
||||
"restrict": "Ограничить определёнными ресурсами",
|
||||
"restrictHint": "Необязательно. Ограничьте токен выбранными элементами. Оставьте список пустым, чтобы разрешить все.",
|
||||
"restrictNoFamilies": "Сначала выберите право для агентов, источников, промптов, инструментов или чата, чтобы ограничить их ресурсы.",
|
||||
"allSelected": "Все (без ограничений)",
|
||||
"noResources": "Ничего не найдено.",
|
||||
"searchResources": "Поиск...",
|
||||
"resourcesError": "Не удалось загрузить список.",
|
||||
"retry": "Повторить",
|
||||
"cancel": "Отмена",
|
||||
"submit": "Создать токен",
|
||||
"submitting": "Создание",
|
||||
"error": "Не удалось создать токен. Попробуйте ещё раз."
|
||||
},
|
||||
"created": {
|
||||
"title": "Токен создан",
|
||||
"subtitle": "Ваш новый токен «{{name}}» готов.",
|
||||
"warning": "Скопируйте токен сейчас и сохраните его в надёжном месте. В целях безопасности он больше не будет показан.",
|
||||
"tokenLabel": "Ваш токен",
|
||||
"usageTitle": "Как использовать",
|
||||
"usageHint": "Экспортируйте его как переменную окружения и передавайте как Bearer-токен в заголовке Authorization.",
|
||||
"done": "Готово"
|
||||
},
|
||||
"regenerate": {
|
||||
"action": "Перевыпустить",
|
||||
"aria": "Перевыпустить токен {{name}}",
|
||||
"title": "Перевыпуск токена",
|
||||
"warning": "Создать новый секрет для «{{name}}»? Текущий секрет сразу перестанет работать, поэтому всё, что его использует, нужно обновить. Имя, права и ограничения останутся прежними.",
|
||||
"newExpiration": "Новый срок действия",
|
||||
"submit": "Перевыпустить",
|
||||
"submitting": "Перевыпуск…",
|
||||
"error": "Не удалось перевыпустить токен. Попробуйте ещё раз.",
|
||||
"done": {
|
||||
"title": "Токен перевыпущен",
|
||||
"subtitle": "Новый секрет для «{{name}}» готов. Старый больше не работает."
|
||||
}
|
||||
}
|
||||
},
|
||||
"scrollTabsLeft": "Прокрутить вкладки влево",
|
||||
"tabsAriaLabel": "Вкладки настроек",
|
||||
"scrollTabsRight": "Прокрутить вкладки вправо"
|
||||
@@ -1063,6 +1209,7 @@
|
||||
"share": "Поделиться",
|
||||
"delete": "Удалить",
|
||||
"rename": "Переименовать",
|
||||
"save": "Сохранить",
|
||||
"deleteWarning": "Вы уверены, что хотите удалить этот разговор?"
|
||||
},
|
||||
"pagination": {
|
||||
@@ -1146,6 +1293,10 @@
|
||||
"running": "Выполняется…",
|
||||
"denied": "Отклонено пользователем",
|
||||
"failed": "не удалось"
|
||||
},
|
||||
"quotaExceeded": {
|
||||
"tokens": "Вы использовали {{used}} из квоты в {{limit}} токенов. Квота сбросится {{resetsAt}}.",
|
||||
"cost": "Вы использовали {{used}} из бюджета в {{limit}}. Бюджет сбросится {{resetsAt}}."
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
+141
-10
@@ -268,6 +268,19 @@
|
||||
},
|
||||
"exposureHint": "將此來源預先載入提示中,或讓代理以工具形式隨選搜尋。"
|
||||
},
|
||||
"graphRetrieval": {
|
||||
"title": "圖譜檢索",
|
||||
"tag": "無需重新匯入",
|
||||
"seedStrategy": "走訪起點",
|
||||
"seedStrategyHint": "實體適用於大多數文件。關係可以到達問題中未提及的實體,適合描述事物之間如何關聯的內容。",
|
||||
"seedEntities": "實體(建議)",
|
||||
"seedRelationships": "關係",
|
||||
"passageNodes": "將段落納入走訪",
|
||||
"passageNodesHint": "段落既可因符合問題而被找到,也可因與符合內容相連而被找到。在此版本之後建立的圖譜上效果最佳。",
|
||||
"blendVector": "與向量檢索結合",
|
||||
"blendVectorHint": "加入向量檢索結果,避免遺漏圖譜未找到的段落。",
|
||||
"agentToolHint": "當此來源的公開方式為「隨選搜尋工具」,或由代理型代理使用時,代理也可以自行沿著這些關係查找。"
|
||||
},
|
||||
"prescreen": {
|
||||
"enable": "啟用 LLM 預篩選",
|
||||
"warning": "會擷取較大的候選集合並使用 LLM 篩選。這將增加查詢延遲與成本。",
|
||||
@@ -321,16 +334,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "聊天機器人",
|
||||
"name": "名稱",
|
||||
"key": "API 金鑰",
|
||||
"sourceDoc": "來源文件",
|
||||
"createNew": "建立新的",
|
||||
"noData": "沒有現有的聊天機器人",
|
||||
"deleteConfirmation": "您確定要刪除 API 金鑰 '{{name}}' 嗎?",
|
||||
"description": "在這裡,您可以創建和管理您的聊天機器人。聊天機器人可以作為小部件部署到網站上,或在您的應用程序中使用。"
|
||||
},
|
||||
"analytics": {
|
||||
"label": "分析",
|
||||
"subtitle": "追蹤您賃戶的訊息量、Token 使用量和使用者回饋",
|
||||
@@ -372,6 +375,17 @@
|
||||
"toolCalls": "工具呼叫",
|
||||
"runSuccess": "執行成功率",
|
||||
"feedback": "回饋"
|
||||
},
|
||||
"quota": {
|
||||
"title": "您的用量配額",
|
||||
"resets": "{{resetsAt}} 重設",
|
||||
"tokens": "權杖",
|
||||
"cost": "費用",
|
||||
"usedOf": "{{used}} / {{limit}}",
|
||||
"scope": {
|
||||
"direct": "不使用代理的聊天",
|
||||
"agent": "透過代理"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -747,6 +761,118 @@
|
||||
"testFailed": "連線測試失敗"
|
||||
}
|
||||
},
|
||||
"accessTokens": {
|
||||
"label": "存取權杖",
|
||||
"subtitle": "個人存取權杖可讓指令碼、CLI 和 CI 管線以您的身分呼叫 DocsGPT API。請只授予每個權杖所需的權限範圍。",
|
||||
"createToken": "建立權杖",
|
||||
"disabledNotice": "此伺服器不支援個人存取權杖。此功能需要使用帳戶登入,並須由管理員啟用。",
|
||||
"limitReached_one": "您的有效權杖已達 {{count}} 個的上限。請先撤銷一個再建立新的權杖。",
|
||||
"limitReached_other": "您的有效權杖已達 {{count}} 個的上限。請先撤銷一個再建立新的權杖。",
|
||||
"loadError": "載入存取權杖失敗,請再試一次。",
|
||||
"revokeError": "撤銷權杖失敗,請再試一次。",
|
||||
"empty": "尚無存取權杖",
|
||||
"emptyHint": "建立權杖後,即可在指令碼和命令列工具中使用 DocsGPT API。",
|
||||
"name": "名稱",
|
||||
"scopes": "權限範圍",
|
||||
"resources": "資源",
|
||||
"createdAt": "建立時間",
|
||||
"lastUsed": "上次使用",
|
||||
"expires": "到期時間",
|
||||
"actions": "操作",
|
||||
"allResources": "所有資源",
|
||||
"never": "從不",
|
||||
"expired": "已於 {{date}} 到期",
|
||||
"expiresSoon": "將於 {{date}} 到期",
|
||||
"moreScopes": "另外 {{count}} 項",
|
||||
"showLess": "收合",
|
||||
"revoke": "撤銷",
|
||||
"revokeAria": "撤銷權杖 {{name}}",
|
||||
"revokeWarning": "確定要撤銷權杖「{{name}}」嗎?所有仍在使用它的程式將立即失效,且此操作無法復原。",
|
||||
"restrictionCount": {
|
||||
"agents_one": "{{count}} 個代理",
|
||||
"agents_other": "{{count}} 個代理",
|
||||
"sources_one": "{{count}} 個來源",
|
||||
"sources_other": "{{count}} 個來源",
|
||||
"prompts_one": "{{count}} 個提示詞",
|
||||
"prompts_other": "{{count}} 個提示詞",
|
||||
"tools_one": "{{count}} 個工具",
|
||||
"tools_other": "{{count}} 個工具",
|
||||
"workflows_one": "{{count}} 個工作流程",
|
||||
"workflows_other": "{{count}} 個工作流程"
|
||||
},
|
||||
"relative": {
|
||||
"now": "剛剛",
|
||||
"minutes_one": "{{count}} 分鐘前",
|
||||
"minutes_other": "{{count}} 分鐘前",
|
||||
"hours_one": "{{count}} 小時前",
|
||||
"hours_other": "{{count}} 小時前",
|
||||
"days_one": "{{count}} 天前",
|
||||
"days_other": "{{count}} 天前"
|
||||
},
|
||||
"families": {
|
||||
"agents": "代理",
|
||||
"sources": "來源",
|
||||
"prompts": "提示詞",
|
||||
"tools": "工具",
|
||||
"models": "模型",
|
||||
"workflows": "工作流程",
|
||||
"schedules": "排程",
|
||||
"conversations": "對話",
|
||||
"analytics": "分析",
|
||||
"teams": "團隊",
|
||||
"chat": "聊天"
|
||||
},
|
||||
"create": {
|
||||
"title": "建立存取權杖",
|
||||
"subtitle": "權杖將以您的身分操作,但僅限於您在此選擇的權限範圍和資源。",
|
||||
"name": "名稱",
|
||||
"namePlaceholder": "例如:CI 管線",
|
||||
"expiration": "有效期限",
|
||||
"expiryDays_one": "{{count}} 天",
|
||||
"expiryDays_other": "{{count}} 天",
|
||||
"noExpiration": "永不到期",
|
||||
"noExpirationHint": "永不到期的權杖一旦外洩風險更高,建議設定到期時間。",
|
||||
"expiresOn": "將於 {{date}} 到期",
|
||||
"scopes": "權限範圍",
|
||||
"scopesHint": "選擇此權杖可以執行的操作。寫入權限包含讀取權限。",
|
||||
"impliedByWrite": "已包含在寫入權限中",
|
||||
"restrict": "限制為特定資源",
|
||||
"restrictHint": "選填。將權杖限制為您選擇的項目。清單留空表示允許全部。",
|
||||
"restrictNoFamilies": "請先選擇代理、來源、提示詞、工具或聊天的權限範圍,才能限制其資源。",
|
||||
"allSelected": "全部(不限制)",
|
||||
"noResources": "找不到任何內容。",
|
||||
"searchResources": "搜尋...",
|
||||
"resourcesError": "載入清單失敗。",
|
||||
"retry": "重試",
|
||||
"cancel": "取消",
|
||||
"submit": "建立權杖",
|
||||
"submitting": "建立中",
|
||||
"error": "建立權杖失敗,請再試一次。"
|
||||
},
|
||||
"created": {
|
||||
"title": "權杖已建立",
|
||||
"subtitle": "您的新權杖「{{name}}」已就緒。",
|
||||
"warning": "請立即複製權杖並妥善保存。基於安全考量,它不會再次顯示。",
|
||||
"tokenLabel": "您的權杖",
|
||||
"usageTitle": "使用方式",
|
||||
"usageHint": "將其匯出為環境變數,然後在 Authorization 標頭中作為 Bearer 權杖傳送。",
|
||||
"done": "完成"
|
||||
},
|
||||
"regenerate": {
|
||||
"action": "重新產生",
|
||||
"aria": "重新產生權杖 {{name}}",
|
||||
"title": "重新產生權杖",
|
||||
"warning": "要為「{{name}}」產生新的密鑰嗎?目前的密鑰會立即失效,所有使用它的地方都需要更新。名稱、權限範圍和限制維持不變。",
|
||||
"newExpiration": "新的到期時間",
|
||||
"submit": "重新產生",
|
||||
"submitting": "正在重新產生…",
|
||||
"error": "重新產生權杖失敗,請再試一次。",
|
||||
"done": {
|
||||
"title": "權杖已重新產生",
|
||||
"subtitle": "「{{name}}」的新密鑰已就緒,舊密鑰已失效。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scrollTabsLeft": "向左捲動標籤",
|
||||
"tabsAriaLabel": "設定標籤",
|
||||
"scrollTabsRight": "向右捲動標籤"
|
||||
@@ -1063,6 +1189,7 @@
|
||||
"share": "分享",
|
||||
"delete": "刪除",
|
||||
"rename": "重新命名",
|
||||
"save": "儲存",
|
||||
"deleteWarning": "您確定要刪除這個對話嗎?"
|
||||
},
|
||||
"pagination": {
|
||||
@@ -1146,6 +1273,10 @@
|
||||
"running": "執行中…",
|
||||
"denied": "已被使用者拒絕",
|
||||
"failed": "失敗"
|
||||
},
|
||||
"quotaExceeded": {
|
||||
"tokens": "您已使用 {{limit}} 權杖配額中的 {{used}}。配額將於 {{resetsAt}} 重設。",
|
||||
"cost": "您已使用 {{limit}} 用量預算中的 {{used}}。預算將於 {{resetsAt}} 重設。"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
+141
-10
@@ -268,6 +268,19 @@
|
||||
},
|
||||
"exposureHint": "将此来源预取到提示词中,或让代理按需将其作为工具进行搜索。"
|
||||
},
|
||||
"graphRetrieval": {
|
||||
"title": "图谱检索",
|
||||
"tag": "无需重新导入",
|
||||
"seedStrategy": "遍历起点",
|
||||
"seedStrategyHint": "实体适用于大多数文档。关系可以到达问题中未提及的实体,适合描述事物之间如何关联的内容。",
|
||||
"seedEntities": "实体(推荐)",
|
||||
"seedRelationships": "关系",
|
||||
"passageNodes": "将段落纳入遍历",
|
||||
"passageNodesHint": "段落既可因匹配问题被找到,也可因与匹配内容相连而被找到。在此版本之后构建的图谱上效果最佳。",
|
||||
"blendVector": "与向量检索结合",
|
||||
"blendVectorHint": "加入向量检索结果,避免遗漏图谱未找到的段落。",
|
||||
"agentToolHint": "当此来源的公开方式为“按需搜索工具”,或由智能体型代理使用时,代理也可以自行沿这些关系查找。"
|
||||
},
|
||||
"prescreen": {
|
||||
"enable": "启用 LLM 预筛选",
|
||||
"warning": "会获取更大的候选集并使用 LLM 进行过滤。这会增加查询时的延迟和成本。",
|
||||
@@ -321,16 +334,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "聊天机器人",
|
||||
"name": "名称",
|
||||
"key": "API 密钥",
|
||||
"sourceDoc": "源文档",
|
||||
"createNew": "创建新的",
|
||||
"noData": "没有现有的聊天机器人",
|
||||
"deleteConfirmation": "您确定要删除 API 密钥 '{{name}}' 吗?",
|
||||
"description": "在这里,您可以创建和管理您的聊天机器人。聊天机器人可以作为小部件部署到网站上,或在您的应用程序中使用。"
|
||||
},
|
||||
"analytics": {
|
||||
"label": "分析",
|
||||
"subtitle": "跟踪您账户的消息量、令牌使用情况和用户反馈",
|
||||
@@ -372,6 +375,17 @@
|
||||
"toolCalls": "工具调用",
|
||||
"runSuccess": "运行成功率",
|
||||
"feedback": "反馈"
|
||||
},
|
||||
"quota": {
|
||||
"title": "您的用量配额",
|
||||
"resets": "{{resetsAt}} 重置",
|
||||
"tokens": "令牌",
|
||||
"cost": "费用",
|
||||
"usedOf": "{{used}} / {{limit}}",
|
||||
"scope": {
|
||||
"direct": "不使用代理的聊天",
|
||||
"agent": "通过代理"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -747,6 +761,118 @@
|
||||
"testFailed": "连接测试失败"
|
||||
}
|
||||
},
|
||||
"accessTokens": {
|
||||
"label": "访问令牌",
|
||||
"subtitle": "个人访问令牌可让脚本、CLI 和 CI 流水线以您的身份调用 DocsGPT API。请只为每个令牌授予其所需的权限范围。",
|
||||
"createToken": "创建令牌",
|
||||
"disabledNotice": "此服务器不支持个人访问令牌。该功能需要使用账户登录,并须由管理员启用。",
|
||||
"limitReached_one": "您的有效令牌已达到 {{count}} 个的上限。请先撤销一个再创建新的令牌。",
|
||||
"limitReached_other": "您的有效令牌已达到 {{count}} 个的上限。请先撤销一个再创建新的令牌。",
|
||||
"loadError": "加载访问令牌失败,请重试。",
|
||||
"revokeError": "撤销令牌失败,请重试。",
|
||||
"empty": "暂无访问令牌",
|
||||
"emptyHint": "创建令牌后,即可在脚本和命令行工具中使用 DocsGPT API。",
|
||||
"name": "名称",
|
||||
"scopes": "权限范围",
|
||||
"resources": "资源",
|
||||
"createdAt": "创建时间",
|
||||
"lastUsed": "上次使用",
|
||||
"expires": "过期时间",
|
||||
"actions": "操作",
|
||||
"allResources": "所有资源",
|
||||
"never": "从不",
|
||||
"expired": "已于 {{date}} 过期",
|
||||
"expiresSoon": "将于 {{date}} 过期",
|
||||
"moreScopes": "另外 {{count}} 项",
|
||||
"showLess": "收起",
|
||||
"revoke": "撤销",
|
||||
"revokeAria": "撤销令牌 {{name}}",
|
||||
"revokeWarning": "确定要撤销令牌“{{name}}”吗?所有仍在使用它的程序将立即失效,且此操作无法撤回。",
|
||||
"restrictionCount": {
|
||||
"agents_one": "{{count}} 个代理",
|
||||
"agents_other": "{{count}} 个代理",
|
||||
"sources_one": "{{count}} 个来源",
|
||||
"sources_other": "{{count}} 个来源",
|
||||
"prompts_one": "{{count}} 个提示词",
|
||||
"prompts_other": "{{count}} 个提示词",
|
||||
"tools_one": "{{count}} 个工具",
|
||||
"tools_other": "{{count}} 个工具",
|
||||
"workflows_one": "{{count}} 个工作流",
|
||||
"workflows_other": "{{count}} 个工作流"
|
||||
},
|
||||
"relative": {
|
||||
"now": "刚刚",
|
||||
"minutes_one": "{{count}} 分钟前",
|
||||
"minutes_other": "{{count}} 分钟前",
|
||||
"hours_one": "{{count}} 小时前",
|
||||
"hours_other": "{{count}} 小时前",
|
||||
"days_one": "{{count}} 天前",
|
||||
"days_other": "{{count}} 天前"
|
||||
},
|
||||
"families": {
|
||||
"agents": "代理",
|
||||
"sources": "来源",
|
||||
"prompts": "提示词",
|
||||
"tools": "工具",
|
||||
"models": "模型",
|
||||
"workflows": "工作流",
|
||||
"schedules": "计划任务",
|
||||
"conversations": "对话",
|
||||
"analytics": "分析",
|
||||
"teams": "团队",
|
||||
"chat": "聊天"
|
||||
},
|
||||
"create": {
|
||||
"title": "创建访问令牌",
|
||||
"subtitle": "令牌将以您的身份操作,但仅限于您在此选择的权限范围和资源。",
|
||||
"name": "名称",
|
||||
"namePlaceholder": "例如:CI 流水线",
|
||||
"expiration": "有效期",
|
||||
"expiryDays_one": "{{count}} 天",
|
||||
"expiryDays_other": "{{count}} 天",
|
||||
"noExpiration": "永不过期",
|
||||
"noExpirationHint": "永不过期的令牌一旦泄露风险更高,建议设置过期时间。",
|
||||
"expiresOn": "将于 {{date}} 过期",
|
||||
"scopes": "权限范围",
|
||||
"scopesHint": "选择此令牌可以执行的操作。写入权限包含读取权限。",
|
||||
"impliedByWrite": "已包含在写入权限中",
|
||||
"restrict": "限制为特定资源",
|
||||
"restrictHint": "可选。将令牌限制为您选择的项目。列表留空表示允许全部。",
|
||||
"restrictNoFamilies": "请先选择代理、来源、提示词、工具或聊天的权限范围,才能限制其资源。",
|
||||
"allSelected": "全部(不限制)",
|
||||
"noResources": "未找到任何内容。",
|
||||
"searchResources": "搜索...",
|
||||
"resourcesError": "加载列表失败。",
|
||||
"retry": "重试",
|
||||
"cancel": "取消",
|
||||
"submit": "创建令牌",
|
||||
"submitting": "正在创建",
|
||||
"error": "创建令牌失败,请重试。"
|
||||
},
|
||||
"created": {
|
||||
"title": "令牌已创建",
|
||||
"subtitle": "您的新令牌“{{name}}”已就绪。",
|
||||
"warning": "请立即复制令牌并妥善保存。出于安全考虑,它不会再次显示。",
|
||||
"tokenLabel": "您的令牌",
|
||||
"usageTitle": "使用方法",
|
||||
"usageHint": "将其导出为环境变量,然后在 Authorization 请求头中作为 Bearer 令牌发送。",
|
||||
"done": "完成"
|
||||
},
|
||||
"regenerate": {
|
||||
"action": "重新生成",
|
||||
"aria": "重新生成令牌 {{name}}",
|
||||
"title": "重新生成令牌",
|
||||
"warning": "要为“{{name}}”生成新的密钥吗?当前密钥将立即失效,所有使用它的地方都需要更新。名称、权限范围和限制保持不变。",
|
||||
"newExpiration": "新的过期时间",
|
||||
"submit": "重新生成",
|
||||
"submitting": "正在重新生成…",
|
||||
"error": "重新生成令牌失败,请重试。",
|
||||
"done": {
|
||||
"title": "令牌已重新生成",
|
||||
"subtitle": "“{{name}}”的新密钥已就绪,旧密钥已失效。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scrollTabsLeft": "向左滚动标签",
|
||||
"tabsAriaLabel": "设置标签",
|
||||
"scrollTabsRight": "向右滚动标签"
|
||||
@@ -1063,6 +1189,7 @@
|
||||
"share": "分享",
|
||||
"delete": "删除",
|
||||
"rename": "重命名",
|
||||
"save": "保存",
|
||||
"deleteWarning": "您确定要删除此对话吗?"
|
||||
},
|
||||
"pagination": {
|
||||
@@ -1146,6 +1273,10 @@
|
||||
"running": "正在运行…",
|
||||
"denied": "已被用户拒绝",
|
||||
"failed": "失败"
|
||||
},
|
||||
"quotaExceeded": {
|
||||
"tokens": "您已使用 {{limit}} 令牌配额中的 {{used}}。配额将于 {{resetsAt}} 重置。",
|
||||
"cost": "您已使用 {{limit}} 用量预算中的 {{used}}。预算将于 {{resetsAt}} 重置。"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { baseURL } from '../api/client';
|
||||
import CopyButton from '../components/CopyButton';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import { NO_ESCAPE } from '../settings/accessTokenUtils';
|
||||
|
||||
interface AccessTokenCreatedModalProps {
|
||||
/** Plaintext secret; `null` keeps the modal closed. Held only in the parent's component state. */
|
||||
token: string | null;
|
||||
name: string;
|
||||
/** The secret replaces an existing token's secret rather than belonging to a new token. */
|
||||
regenerated?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AccessTokenCreatedModal({
|
||||
token,
|
||||
name,
|
||||
regenerated = false,
|
||||
onClose,
|
||||
}: AccessTokenCreatedModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const copy = regenerated
|
||||
? 'settings.accessTokens.regenerate.done'
|
||||
: 'settings.accessTokens.created';
|
||||
const exportSnippet = `export DOCSGPT_TOKEN=${token ?? ''}`;
|
||||
const curlSnippet = `curl -H "Authorization: Bearer $DOCSGPT_TOKEN" ${baseURL}/api/get_agents`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={token !== null}
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
hideTitle
|
||||
title={t(`${copy}.title`)}
|
||||
size="lg"
|
||||
mobileVariant="sheet"
|
||||
// The secret cannot be shown again, so a stray click outside must not
|
||||
// dismiss it; closing takes the explicit button (or Esc).
|
||||
isPerformingTask
|
||||
footer={
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-3xl px-6 text-white"
|
||||
>
|
||||
{t('settings.accessTokens.created.done')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5 px-1">
|
||||
<div>
|
||||
<h2 className="text-foreground dark:text-foreground text-xl font-semibold">
|
||||
{t(`${copy}.title`)}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-2 text-sm break-words">
|
||||
{t(`${copy}.subtitle`, {
|
||||
name,
|
||||
...NO_ESCAPE,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-3 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-300"
|
||||
>
|
||||
<TriangleAlert
|
||||
className="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>{t('settings.accessTokens.created.warning')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-foreground dark:text-foreground text-sm font-semibold">
|
||||
{t('settings.accessTokens.created.tokenLabel')}
|
||||
</p>
|
||||
<div className="bg-muted flex items-center gap-2 rounded-xl py-2 pr-2 pl-4">
|
||||
<code
|
||||
data-testid="pat-plaintext"
|
||||
className="text-foreground dark:text-foreground min-w-0 flex-1 font-mono text-xs break-all select-all sm:text-sm"
|
||||
>
|
||||
{token}
|
||||
</code>
|
||||
{token && <CopyButton textToCopy={token} showText />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-foreground dark:text-foreground text-sm font-semibold">
|
||||
{t('settings.accessTokens.created.usageTitle')}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.accessTokens.created.usageHint')}
|
||||
</p>
|
||||
{[exportSnippet, curlSnippet].map((snippet, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-muted flex items-start gap-2 rounded-xl py-2 pr-2 pl-4"
|
||||
>
|
||||
<pre className="text-foreground dark:text-foreground min-w-0 flex-1 font-mono text-xs leading-relaxed break-all whitespace-pre-wrap">
|
||||
{snippet}
|
||||
</pre>
|
||||
<CopyButton textToCopy={snippet} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import patService, {
|
||||
AccessTokenPolicy,
|
||||
AccessTokenScope,
|
||||
CreateAccessTokenResponse,
|
||||
} from '../api/services/patService';
|
||||
import userService from '../api/services/userService';
|
||||
import Spinner from '../components/Spinner';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import { MultiSelect } from '../components/ui/multi-select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../components/ui/select';
|
||||
import { Switch } from '../components/ui/switch';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import {
|
||||
buildResourceFilter,
|
||||
defaultExpiry,
|
||||
eligibleFilterFamilies,
|
||||
expiryOptions,
|
||||
groupScopesByFamily,
|
||||
isScopeImplied,
|
||||
NO_ESCAPE,
|
||||
NO_EXPIRY,
|
||||
PICKER_FAMILIES,
|
||||
ResourceOption,
|
||||
scopesToSubmit,
|
||||
toResourceOptions,
|
||||
} from '../settings/accessTokenUtils';
|
||||
import { formatDateOnly } from '../utils/dateTimeUtils';
|
||||
|
||||
const MAX_NAME_LENGTH = 100;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
type ResourceState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error' }
|
||||
| { status: 'ready'; options: ResourceOption[] };
|
||||
|
||||
const RESOURCE_FETCHERS: Record<
|
||||
string,
|
||||
(token: string | null) => Promise<Response>
|
||||
> = {
|
||||
agents: (token) => userService.getAgents(token),
|
||||
sources: (token) => userService.getDocs(token),
|
||||
prompts: (token) => userService.getPrompts(token),
|
||||
tools: (token) => userService.getUserTools(token),
|
||||
};
|
||||
|
||||
interface CreateAccessTokenModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
scopes: AccessTokenScope[];
|
||||
policy: AccessTokenPolicy;
|
||||
onCreated: (created: CreateAccessTokenResponse) => void;
|
||||
}
|
||||
|
||||
export default function CreateAccessTokenModal({
|
||||
open,
|
||||
onClose,
|
||||
scopes,
|
||||
policy,
|
||||
onCreated,
|
||||
}: CreateAccessTokenModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
|
||||
const [name, setName] = React.useState('');
|
||||
const [selectedScopes, setSelectedScopes] = React.useState<string[]>([]);
|
||||
const [expiry, setExpiry] = React.useState<number>(() =>
|
||||
defaultExpiry(policy),
|
||||
);
|
||||
const [restrict, setRestrict] = React.useState(false);
|
||||
const [resourceSelection, setResourceSelection] = React.useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
const [resources, setResources] = React.useState<
|
||||
Record<string, ResourceState>
|
||||
>({});
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
// Start from a clean form on every open; resource lists are refetched so a
|
||||
// freshly created agent/source shows up without a page reload.
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setName('');
|
||||
setSelectedScopes([]);
|
||||
setExpiry(defaultExpiry(policy));
|
||||
setRestrict(false);
|
||||
setResourceSelection({});
|
||||
setResources({});
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
}, [open, policy]);
|
||||
|
||||
const scopeGroups = React.useMemo(
|
||||
() => groupScopesByFamily(scopes),
|
||||
[scopes],
|
||||
);
|
||||
const expiryChoices = React.useMemo(() => expiryOptions(policy), [policy]);
|
||||
const pickerFamilies = React.useMemo(
|
||||
() =>
|
||||
eligibleFilterFamilies(selectedScopes, policy.filterable_families).filter(
|
||||
(family) => PICKER_FAMILIES.includes(family),
|
||||
),
|
||||
[selectedScopes, policy.filterable_families],
|
||||
);
|
||||
|
||||
const loadResources = React.useCallback(
|
||||
(family: string) => {
|
||||
const fetcher = RESOURCE_FETCHERS[family];
|
||||
if (!fetcher) return;
|
||||
setResources((prev) => ({ ...prev, [family]: { status: 'loading' } }));
|
||||
fetcher(token)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const options = toResourceOptions(family, await response.json());
|
||||
setResources((prev) => ({
|
||||
...prev,
|
||||
[family]: { status: 'ready', options },
|
||||
}));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Failed to load ${family}:`, err);
|
||||
setResources((prev) => ({ ...prev, [family]: { status: 'error' } }));
|
||||
});
|
||||
},
|
||||
[token],
|
||||
);
|
||||
|
||||
// Lazy: a family's list is only fetched once its picker is actually shown.
|
||||
React.useEffect(() => {
|
||||
if (!open || !restrict) return;
|
||||
pickerFamilies.forEach((family) => {
|
||||
if (!resources[family]) loadResources(family);
|
||||
});
|
||||
}, [open, restrict, pickerFamilies, resources, loadResources]);
|
||||
|
||||
const toggleScope = (scope: string) => {
|
||||
setError(null);
|
||||
setSelectedScopes((prev) =>
|
||||
prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope],
|
||||
);
|
||||
};
|
||||
|
||||
const familyLabel = (family: string) =>
|
||||
t(`settings.accessTokens.families.${family}`, { defaultValue: family });
|
||||
|
||||
const trimmedName = name.trim();
|
||||
const canSubmit =
|
||||
trimmedName.length > 0 && selectedScopes.length > 0 && !submitting;
|
||||
|
||||
const handleClose = () => {
|
||||
if (submitting) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await patService.create(
|
||||
{
|
||||
name: trimmedName,
|
||||
scopes: scopesToSubmit(selectedScopes, scopes),
|
||||
resource_filter: restrict
|
||||
? buildResourceFilter(resourceSelection, pickerFamilies)
|
||||
: undefined,
|
||||
expires_in_days: expiry,
|
||||
},
|
||||
token,
|
||||
);
|
||||
onCreated(created);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
setError(message || t('settings.accessTokens.create.error'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const expiryLabel = (days: number) =>
|
||||
days === NO_EXPIRY
|
||||
? t('settings.accessTokens.create.noExpiration')
|
||||
: t('settings.accessTokens.create.expiryDays', { count: days });
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={(o) => !o && handleClose()}
|
||||
hideTitle
|
||||
title={t('settings.accessTokens.create.title')}
|
||||
size="lg"
|
||||
mobileVariant="sheet"
|
||||
isPerformingTask={submitting}
|
||||
contentClassName="max-h-[65vh]"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
className="rounded-3xl px-6"
|
||||
>
|
||||
{t('settings.accessTokens.create.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="rounded-3xl px-6 text-white"
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner size="small" />
|
||||
{t('settings.accessTokens.create.submitting')}
|
||||
</span>
|
||||
) : (
|
||||
t('settings.accessTokens.create.submit')
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
className="flex flex-col gap-6 px-1"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-foreground dark:text-foreground text-xl font-semibold">
|
||||
{t('settings.accessTokens.create.title')}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{t('settings.accessTokens.create.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="pat-name">
|
||||
{t('settings.accessTokens.create.name')}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="pat-name"
|
||||
type="text"
|
||||
value={name}
|
||||
maxLength={MAX_NAME_LENGTH}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t('settings.accessTokens.create.namePlaceholder')}
|
||||
className="rounded-xl"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="pat-expiry">
|
||||
{t('settings.accessTokens.create.expiration')}
|
||||
</Label>
|
||||
<Select
|
||||
value={String(expiry)}
|
||||
onValueChange={(value) => setExpiry(Number(value))}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="pat-expiry"
|
||||
className="w-full rounded-xl px-4 py-2"
|
||||
size="lg"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{expiryChoices.map((days) => (
|
||||
<SelectItem key={days} value={String(days)}>
|
||||
{expiryLabel(days)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p
|
||||
className={`text-xs ${
|
||||
expiry === NO_EXPIRY
|
||||
? 'text-amber-700 dark:text-amber-400'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{expiry === NO_EXPIRY
|
||||
? t('settings.accessTokens.create.noExpirationHint')
|
||||
: t('settings.accessTokens.create.expiresOn', {
|
||||
date: formatDateOnly(
|
||||
new Date(Date.now() + expiry * DAY_MS).toISOString(),
|
||||
),
|
||||
...NO_ESCAPE,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset className="m-0 flex min-w-0 flex-col gap-3 border-0 p-0">
|
||||
<legend className="text-foreground dark:text-foreground text-sm font-semibold">
|
||||
{t('settings.accessTokens.create.scopes')}
|
||||
<span className="text-red-500">*</span>
|
||||
</legend>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.accessTokens.create.scopesHint')}
|
||||
</p>
|
||||
<div className="border-border dark:border-border divide-border flex flex-col divide-y rounded-xl border">
|
||||
{scopeGroups.map((group) => (
|
||||
<div
|
||||
key={group.family}
|
||||
className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:gap-4"
|
||||
>
|
||||
<p className="text-foreground dark:text-foreground w-32 shrink-0 text-sm font-medium capitalize">
|
||||
{familyLabel(group.family)}
|
||||
</p>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
|
||||
{group.scopes.map((scope) => {
|
||||
const implied = isScopeImplied(scope.name, selectedScopes);
|
||||
const checked =
|
||||
implied || selectedScopes.includes(scope.name);
|
||||
const id = `pat-scope-${scope.name}`;
|
||||
return (
|
||||
<label
|
||||
key={scope.name}
|
||||
htmlFor={id}
|
||||
title={
|
||||
implied
|
||||
? t('settings.accessTokens.create.impliedByWrite')
|
||||
: undefined
|
||||
}
|
||||
className={`flex items-start gap-3 ${
|
||||
implied
|
||||
? 'cursor-not-allowed opacity-70'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={implied}
|
||||
onChange={() => toggleScope(scope.name)}
|
||||
className="accent-primary mt-0.5 size-4 shrink-0 rounded-sm border-gray-300 bg-transparent dark:[color-scheme:dark]"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<code className="text-foreground dark:text-foreground font-mono text-xs font-medium">
|
||||
{scope.name}
|
||||
</code>
|
||||
<span className="text-muted-foreground text-xs leading-relaxed">
|
||||
{scope.description}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{policy.filterable_families.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="pat-restrict" className="text-sm font-semibold">
|
||||
{t('settings.accessTokens.create.restrict')}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.accessTokens.create.restrictHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="pat-restrict"
|
||||
checked={restrict}
|
||||
onCheckedChange={setRestrict}
|
||||
/>
|
||||
</div>
|
||||
{restrict && pickerFamilies.length === 0 && (
|
||||
<p className="text-muted-foreground bg-muted rounded-lg px-4 py-2 text-xs">
|
||||
{t('settings.accessTokens.create.restrictNoFamilies')}
|
||||
</p>
|
||||
)}
|
||||
{restrict &&
|
||||
pickerFamilies.map((family) => {
|
||||
const state = resources[family];
|
||||
return (
|
||||
<div key={family} className="flex flex-col gap-1.5">
|
||||
<Label className="capitalize">{familyLabel(family)}</Label>
|
||||
{!state || state.status === 'loading' ? (
|
||||
<div className="flex h-10 items-center">
|
||||
<Spinner size="small" />
|
||||
</div>
|
||||
) : state.status === 'error' ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="text-destructive">
|
||||
{t('settings.accessTokens.create.resourcesError')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto p-0 text-xs"
|
||||
onClick={() => loadResources(family)}
|
||||
>
|
||||
{t('settings.accessTokens.create.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<MultiSelect
|
||||
options={state.options}
|
||||
selected={resourceSelection[family] ?? []}
|
||||
onChange={(ids) =>
|
||||
setResourceSelection((prev) => ({
|
||||
...prev,
|
||||
[family]: ids,
|
||||
}))
|
||||
}
|
||||
placeholder={t(
|
||||
'settings.accessTokens.create.allSelected',
|
||||
)}
|
||||
emptyText={t(
|
||||
'settings.accessTokens.create.noResources',
|
||||
)}
|
||||
searchPlaceholder={t(
|
||||
'settings.accessTokens.create.searchResources',
|
||||
)}
|
||||
className="rounded-xl"
|
||||
modal
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -269,7 +269,7 @@ export default function MoveToFolderModal({
|
||||
<img
|
||||
src={ChevronRight}
|
||||
alt="expand"
|
||||
className={`h-3 w-3 ${selectedFolderId === folder.id ? 'brightness-0 invert' : ''}`}
|
||||
className={`h-3 w-3 ${selectedFolderId === folder.id ? 'brightness-0 invert' : 'dark:invert'}`}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import patService, {
|
||||
AccessTokenApiError,
|
||||
AccessTokenPolicy,
|
||||
CreateAccessTokenResponse,
|
||||
PersonalAccessToken,
|
||||
} from '../api/services/patService';
|
||||
import Spinner from '../components/Spinner';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../components/ui/select';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import {
|
||||
expiryOptions,
|
||||
NO_ESCAPE,
|
||||
NO_EXPIRY,
|
||||
renewalExpiry,
|
||||
} from '../settings/accessTokenUtils';
|
||||
import { formatDateOnly } from '../utils/dateTimeUtils';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface RegenerateAccessTokenModalProps {
|
||||
/** Token to regenerate; `null` keeps the modal closed. */
|
||||
item: PersonalAccessToken | null;
|
||||
policy: AccessTokenPolicy;
|
||||
onClose: () => void;
|
||||
onRegenerated: (response: CreateAccessTokenResponse) => void;
|
||||
}
|
||||
|
||||
export default function RegenerateAccessTokenModal({
|
||||
item,
|
||||
policy,
|
||||
onClose,
|
||||
onRegenerated,
|
||||
}: RegenerateAccessTokenModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
const [expiry, setExpiry] = React.useState<number>(NO_EXPIRY);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const expiryChoices = React.useMemo(() => expiryOptions(policy), [policy]);
|
||||
|
||||
// Preselect the lifetime the token was last issued with.
|
||||
React.useEffect(() => {
|
||||
if (!item) return;
|
||||
setExpiry(renewalExpiry(item, policy));
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
}, [item, policy]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (!submitting) onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
onRegenerated(await patService.regenerate(item.id, expiry, token));
|
||||
} catch (err) {
|
||||
console.error('Failed to regenerate access token:', err);
|
||||
setError(
|
||||
(err instanceof AccessTokenApiError && err.message) ||
|
||||
t('settings.accessTokens.regenerate.error'),
|
||||
);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const expiryLabel = (days: number) =>
|
||||
days === NO_EXPIRY
|
||||
? t('settings.accessTokens.create.noExpiration')
|
||||
: t('settings.accessTokens.create.expiryDays', { count: days });
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={item !== null}
|
||||
onOpenChange={(o) => !o && handleClose()}
|
||||
hideTitle
|
||||
title={t('settings.accessTokens.regenerate.title')}
|
||||
size="md"
|
||||
mobileVariant="sheet"
|
||||
isPerformingTask={submitting}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
className="rounded-3xl px-6"
|
||||
>
|
||||
{t('settings.accessTokens.create.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="rounded-3xl px-6 text-white"
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner size="small" />
|
||||
{t('settings.accessTokens.regenerate.submitting')}
|
||||
</span>
|
||||
) : (
|
||||
t('settings.accessTokens.regenerate.submit')
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5 px-1">
|
||||
<div>
|
||||
<h2 className="text-foreground dark:text-foreground text-xl font-semibold">
|
||||
{t('settings.accessTokens.regenerate.title')}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-2 text-sm break-words">
|
||||
{t('settings.accessTokens.regenerate.warning', {
|
||||
name: item?.name ?? '',
|
||||
...NO_ESCAPE,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="pat-regenerate-expiry">
|
||||
{t('settings.accessTokens.regenerate.newExpiration')}
|
||||
</Label>
|
||||
<Select
|
||||
value={String(expiry)}
|
||||
onValueChange={(value) => setExpiry(Number(value))}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="pat-regenerate-expiry"
|
||||
className="w-full rounded-xl px-4 py-2"
|
||||
size="lg"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{expiryChoices.map((days) => (
|
||||
<SelectItem key={days} value={String(days)}>
|
||||
{expiryLabel(days)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p
|
||||
className={`text-xs ${
|
||||
expiry === NO_EXPIRY
|
||||
? 'text-amber-700 dark:text-amber-400'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{expiry === NO_EXPIRY
|
||||
? t('settings.accessTokens.create.noExpirationHint')
|
||||
: t('settings.accessTokens.create.expiresOn', {
|
||||
date: formatDateOnly(
|
||||
new Date(Date.now() + expiry * DAY_MS).toISOString(),
|
||||
),
|
||||
...NO_ESCAPE,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-lg bg-red-50 px-4 py-2 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,17 @@ export type SourcePrescreenConfig = {
|
||||
max_keep?: number; // default 8, <= candidate_k
|
||||
};
|
||||
|
||||
// Where the graph walk starts: matching entities, or matching relationships
|
||||
// ("A streams_to B"), which can reach an entity the question never names.
|
||||
export type GraphSeedStrategy = 'entities' | 'relationships';
|
||||
|
||||
// Query-time graph retrieval knobs (graphrag only; live, no re-ingest).
|
||||
export type SourceGraphRetrievalConfig = {
|
||||
seed_strategy?: GraphSeedStrategy; // default 'entities'
|
||||
passage_nodes?: boolean; // default true
|
||||
blend_vector?: boolean; // default true
|
||||
};
|
||||
|
||||
// Query-time retrieval knobs (live; no re-ingest needed).
|
||||
export type SourceRetrievalConfig = {
|
||||
retriever?: string; // default 'classic' (only option for now)
|
||||
@@ -40,6 +51,7 @@ export type SourceRetrievalConfig = {
|
||||
score_threshold?: number | null; // default null
|
||||
rephrase_query?: boolean; // default true
|
||||
prescreen?: SourcePrescreenConfig | null; // null = off
|
||||
graph?: SourceGraphRetrievalConfig; // graphrag retriever only
|
||||
};
|
||||
|
||||
// Ingest-time GraphRAG extraction knobs (only used when kind === 'graphrag').
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useDarkTheme, useLoaderState } from '../hooks';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { htmlLegendPlugin } from '../utils/chartUtils';
|
||||
import { formatDate } from '../utils/dateTimeUtils';
|
||||
import UsageQuota from './components/UsageQuota';
|
||||
|
||||
/**
|
||||
* Resolve a CSS custom property on `:root` to a concrete color string.
|
||||
@@ -377,6 +378,7 @@ export default function Analytics({ agentId }: AnalyticsProps) {
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
{agentId ? null : <UsageQuota />}
|
||||
<div className="mb-5 flex flex-row flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-muted-foreground text-sm leading-6">
|
||||
{t('settings.analytics.subtitle')}
|
||||
|
||||
@@ -402,7 +402,7 @@ function Log({
|
||||
<img
|
||||
src={ChevronRight}
|
||||
alt="Expand log entry"
|
||||
className={`mt-[3px] h-3 w-3 transition duration-300 ${isOpen ? 'rotate-90' : ''}`}
|
||||
className={`mt-[3px] h-3 w-3 transition duration-300 dark:invert ${isOpen ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<span className="flex flex-row flex-wrap gap-2">
|
||||
<h2 className="dark:text-foreground text-xs text-black/60">{`${log.timestamp}`}</h2>
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import patService, {
|
||||
AccessTokenApiError,
|
||||
AccessTokenPolicy,
|
||||
AccessTokenScope,
|
||||
CreateAccessTokenResponse,
|
||||
PersonalAccessToken,
|
||||
} from '../api/services/patService';
|
||||
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
|
||||
import NoFilesIcon from '../assets/no-files.svg';
|
||||
import SkeletonLoader from '../components/SkeletonLoader';
|
||||
import { Alert, AlertDescription } from '../components/ui/alert';
|
||||
import { Button } from '../components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '../components/ui/table';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import AccessTokenCreatedModal from '../modals/AccessTokenCreatedModal';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import CreateAccessTokenModal from '../modals/CreateAccessTokenModal';
|
||||
import RegenerateAccessTokenModal from '../modals/RegenerateAccessTokenModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { formatDateOnly, formatDateTime } from '../utils/dateTimeUtils';
|
||||
import {
|
||||
countLiveTokens,
|
||||
expiryStatus,
|
||||
NO_ESCAPE,
|
||||
relativeTime,
|
||||
restrictionCounts,
|
||||
} from './accessTokenUtils';
|
||||
|
||||
const VISIBLE_SCOPES = 3;
|
||||
|
||||
function ScopeChips({ scopes }: { scopes: string[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const visible = expanded ? scopes : scopes.slice(0, VISIBLE_SCOPES);
|
||||
const hidden = scopes.length - visible.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{visible.map((scope) => (
|
||||
<span
|
||||
key={scope}
|
||||
className="bg-muted text-foreground dark:text-foreground rounded-full px-2 py-0.5 font-mono text-[11px] leading-4 whitespace-nowrap"
|
||||
>
|
||||
{scope}
|
||||
</span>
|
||||
))}
|
||||
{scopes.length > VISIBLE_SCOPES && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-expanded={expanded}
|
||||
title={expanded ? undefined : scopes.slice(VISIBLE_SCOPES).join(', ')}
|
||||
className="text-primary hover:bg-primary/10 cursor-pointer rounded-full px-2 py-0.5 text-[11px] leading-4 font-medium whitespace-nowrap"
|
||||
>
|
||||
{expanded
|
||||
? t('settings.accessTokens.showLess')
|
||||
: t('settings.accessTokens.moreScopes', { count: hidden })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PersonalAccessTokens() {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
|
||||
const [tokens, setTokens] = React.useState<PersonalAccessToken[]>([]);
|
||||
const [scopes, setScopes] = React.useState<AccessTokenScope[]>([]);
|
||||
const [policy, setPolicy] = React.useState<AccessTokenPolicy | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [createOpen, setCreateOpen] = React.useState(false);
|
||||
// The plaintext secret lives only here, and only until the modal closes.
|
||||
const [created, setCreated] =
|
||||
React.useState<CreateAccessTokenResponse | null>(null);
|
||||
// True while `created` holds a regenerated (not brand-new) secret.
|
||||
const [createdByRegenerate, setCreatedByRegenerate] = React.useState(false);
|
||||
const [tokenToRegenerate, setTokenToRegenerate] =
|
||||
React.useState<PersonalAccessToken | null>(null);
|
||||
const [revokeState, setRevokeState] = React.useState<ActiveState>('INACTIVE');
|
||||
const [tokenToRevoke, setTokenToRevoke] =
|
||||
React.useState<PersonalAccessToken | null>(null);
|
||||
|
||||
const loadTokens = React.useCallback(
|
||||
async (showLoader: boolean) => {
|
||||
if (showLoader) setLoading(true);
|
||||
try {
|
||||
const data = await patService.list(token);
|
||||
setTokens(data.tokens ?? []);
|
||||
setScopes(data.scopes ?? []);
|
||||
setPolicy(data.policy ?? null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to load access tokens:', err);
|
||||
setError(t('settings.accessTokens.loadError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[token, t],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadTokens(true);
|
||||
}, [loadTokens]);
|
||||
|
||||
const handleRegenerated = (response: CreateAccessTokenResponse) => {
|
||||
const updated = response.personal_access_token;
|
||||
setTokenToRegenerate(null);
|
||||
setCreatedByRegenerate(true);
|
||||
setCreated(response);
|
||||
setTokens((prev) =>
|
||||
prev.map((item) => (item.id === updated.id ? updated : item)),
|
||||
);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleCreated = (response: CreateAccessTokenResponse) => {
|
||||
setCreateOpen(false);
|
||||
setCreatedByRegenerate(false);
|
||||
setCreated(response);
|
||||
setTokens((prev) => [response.personal_access_token, ...prev]);
|
||||
};
|
||||
|
||||
const requestRevoke = (item: PersonalAccessToken) => {
|
||||
setTokenToRevoke(item);
|
||||
setRevokeState('ACTIVE');
|
||||
};
|
||||
|
||||
const confirmRevoke = async () => {
|
||||
if (!tokenToRevoke) return;
|
||||
const target = tokenToRevoke;
|
||||
try {
|
||||
await patService.revoke(target.id, token);
|
||||
setTokens((prev) => prev.filter((item) => item.id !== target.id));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (err instanceof AccessTokenApiError && err.status === 404) {
|
||||
// Already revoked elsewhere (another tab, an admin): it is gone, so
|
||||
// drop the stale row instead of reporting a failure.
|
||||
setTokens((prev) => prev.filter((item) => item.id !== target.id));
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
console.error('Failed to revoke access token:', err);
|
||||
setError(
|
||||
(err instanceof Error && err.message) ||
|
||||
t('settings.accessTokens.revokeError'),
|
||||
);
|
||||
} finally {
|
||||
setTokenToRevoke(null);
|
||||
}
|
||||
};
|
||||
|
||||
const limitReached =
|
||||
!!policy && countLiveTokens(tokens) >= policy.max_per_user;
|
||||
|
||||
const renderRestrictions = (item: PersonalAccessToken) => {
|
||||
const counts = restrictionCounts(item.resource_filter);
|
||||
if (counts.length === 0) return t('settings.accessTokens.allResources');
|
||||
return counts
|
||||
.map(({ family, count }) =>
|
||||
t(`settings.accessTokens.restrictionCount.${family}`, {
|
||||
count,
|
||||
defaultValue: `${count} ${family}`,
|
||||
}),
|
||||
)
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
const renderLastUsed = (item: PersonalAccessToken) => {
|
||||
const relative = relativeTime(item.last_used_at);
|
||||
if (!relative || !item.last_used_at) {
|
||||
return t('settings.accessTokens.never');
|
||||
}
|
||||
const label =
|
||||
relative.unit === 'date'
|
||||
? formatDateOnly(item.last_used_at)
|
||||
: relative.unit === 'now'
|
||||
? t('settings.accessTokens.relative.now')
|
||||
: t(`settings.accessTokens.relative.${relative.unit}`, {
|
||||
count: relative.count,
|
||||
});
|
||||
const details = [formatDateTime(item.last_used_at), item.last_used_ip]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return <span title={details}>{label}</span>;
|
||||
};
|
||||
|
||||
const renderExpiry = (item: PersonalAccessToken) => {
|
||||
const status = expiryStatus(item.expires_at);
|
||||
if (status === 'never' || !item.expires_at) {
|
||||
return t('settings.accessTokens.never');
|
||||
}
|
||||
const date = formatDateOnly(item.expires_at);
|
||||
if (status === 'ok') return date;
|
||||
const expired = status === 'expired';
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${
|
||||
expired
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
}`}
|
||||
title={date}
|
||||
>
|
||||
<TriangleAlert className="size-3 shrink-0" aria-hidden="true" />
|
||||
{expired
|
||||
? t('settings.accessTokens.expired', { date, ...NO_ESCAPE })
|
||||
: t('settings.accessTokens.expiresSoon', { date, ...NO_ESCAPE })}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRegenerateButton = (item: PersonalAccessToken) =>
|
||||
policy?.enabled ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full px-4"
|
||||
onClick={() => setTokenToRegenerate(item)}
|
||||
aria-label={t('settings.accessTokens.regenerate.aria', {
|
||||
name: item.name,
|
||||
...NO_ESCAPE,
|
||||
})}
|
||||
>
|
||||
{t('settings.accessTokens.regenerate.action')}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
const renderRevokeButton = (item: PersonalAccessToken) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive border-destructive/40 hover:bg-destructive/10 rounded-full px-4"
|
||||
onClick={() => requestRevoke(item)}
|
||||
aria-label={t('settings.accessTokens.revokeAria', {
|
||||
name: item.name,
|
||||
...NO_ESCAPE,
|
||||
})}
|
||||
>
|
||||
{t('settings.accessTokens.revoke')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const renderPrefix = (item: PersonalAccessToken) => (
|
||||
<code className="text-muted-foreground font-mono text-xs whitespace-nowrap">
|
||||
{item.token_prefix}…
|
||||
</code>
|
||||
);
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<div className="flex w-full flex-col items-center justify-center py-12">
|
||||
<img
|
||||
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
|
||||
alt=""
|
||||
className="mx-auto mb-6 h-32 w-32"
|
||||
/>
|
||||
<p className="text-center text-lg text-gray-500 dark:text-gray-400">
|
||||
{t('settings.accessTokens.empty')}
|
||||
</p>
|
||||
{policy?.enabled && (
|
||||
<p className="text-muted-foreground mt-2 max-w-md text-center text-sm">
|
||||
{t('settings.accessTokens.emptyHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const mobileField = (label: string, value: React.ReactNode) => (
|
||||
<div className="flex items-start justify-between gap-4 text-sm">
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span className="text-foreground dark:text-foreground min-w-0 text-right">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<div className="relative flex flex-col">
|
||||
<div className="my-3 flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-muted-foreground max-w-2xl text-sm leading-6">
|
||||
{t('settings.accessTokens.subtitle')}
|
||||
</p>
|
||||
{policy?.enabled && (
|
||||
<Button
|
||||
type="button"
|
||||
className="h-11 min-w-[108px] shrink-0 rounded-full whitespace-normal text-white"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={limitReached}
|
||||
title={
|
||||
limitReached
|
||||
? t('settings.accessTokens.limitReached', {
|
||||
count: policy.max_per_user,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t('settings.accessTokens.createToken')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{policy && !policy.enabled && (
|
||||
<Alert className="mt-2">
|
||||
<AlertDescription>
|
||||
{t('settings.accessTokens.disabledNotice')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{policy?.enabled && limitReached && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t('settings.accessTokens.limitReached', {
|
||||
count: policy.max_per_user,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-2">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="border-border dark:border-border mt-5 mb-8 border-b" />
|
||||
|
||||
{loading ? (
|
||||
<SkeletonLoader component="default" />
|
||||
) : tokens.length === 0 ? (
|
||||
!error && renderEmptyState()
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop: table */}
|
||||
<TableContainer className="hidden lg:block">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>{t('settings.accessTokens.name')}</TableHeader>
|
||||
<TableHeader>
|
||||
{t('settings.accessTokens.scopes')}
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
{t('settings.accessTokens.resources')}
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
{t('settings.accessTokens.createdAt')}
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
{t('settings.accessTokens.lastUsed')}
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
{t('settings.accessTokens.expires')}
|
||||
</TableHeader>
|
||||
<TableHeader align="right">
|
||||
<span className="sr-only">
|
||||
{t('settings.accessTokens.actions')}
|
||||
</span>
|
||||
</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tokens.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="max-w-[220px]">
|
||||
<p
|
||||
className="text-foreground dark:text-foreground truncate font-medium"
|
||||
title={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</p>
|
||||
{renderPrefix(item)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[280px]">
|
||||
<ScopeChips scopes={item.scopes} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{renderRestrictions(item)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground whitespace-nowrap">
|
||||
{item.created_at
|
||||
? formatDateOnly(item.created_at)
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground whitespace-nowrap">
|
||||
{renderLastUsed(item)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground whitespace-nowrap">
|
||||
{renderExpiry(item)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{renderRegenerateButton(item)}
|
||||
{renderRevokeButton(item)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Mobile / tablet: cards */}
|
||||
<ul className="flex flex-col gap-4 lg:hidden">
|
||||
{tokens.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="bg-muted/60 flex flex-col gap-3 rounded-2xl p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="text-foreground dark:text-foreground truncate text-sm font-semibold"
|
||||
title={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</p>
|
||||
{renderPrefix(item)}
|
||||
</div>
|
||||
</div>
|
||||
<ScopeChips scopes={item.scopes} />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{mobileField(
|
||||
t('settings.accessTokens.resources'),
|
||||
renderRestrictions(item),
|
||||
)}
|
||||
{mobileField(
|
||||
t('settings.accessTokens.createdAt'),
|
||||
item.created_at ? formatDateOnly(item.created_at) : '-',
|
||||
)}
|
||||
{mobileField(
|
||||
t('settings.accessTokens.lastUsed'),
|
||||
renderLastUsed(item),
|
||||
)}
|
||||
{mobileField(
|
||||
t('settings.accessTokens.expires'),
|
||||
renderExpiry(item),
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{renderRegenerateButton(item)}
|
||||
{renderRevokeButton(item)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{policy && (
|
||||
<CreateAccessTokenModal
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
scopes={scopes}
|
||||
policy={policy}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
)}
|
||||
<AccessTokenCreatedModal
|
||||
token={created?.token ?? null}
|
||||
name={created?.personal_access_token.name ?? ''}
|
||||
regenerated={createdByRegenerate}
|
||||
onClose={() => setCreated(null)}
|
||||
/>
|
||||
{policy && (
|
||||
<RegenerateAccessTokenModal
|
||||
item={tokenToRegenerate}
|
||||
policy={policy}
|
||||
onClose={() => setTokenToRegenerate(null)}
|
||||
onRegenerated={handleRegenerated}
|
||||
/>
|
||||
)}
|
||||
<ConfirmationModal
|
||||
message={t('settings.accessTokens.revokeWarning', {
|
||||
name: tokenToRevoke?.name ?? '',
|
||||
...NO_ESCAPE,
|
||||
})}
|
||||
modalState={revokeState}
|
||||
setModalState={setRevokeState}
|
||||
handleSubmit={confirmRevoke}
|
||||
submitLabel={t('settings.accessTokens.revoke')}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -484,7 +484,7 @@ export default function ToolConfig({
|
||||
<img
|
||||
src={ChevronRight}
|
||||
alt="expand"
|
||||
className={`h-4 w-4 opacity-60 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
||||
className={`h-4 w-4 opacity-60 transition-transform duration-200 dark:invert ${isExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<p className="text-foreground dark:text-foreground font-semibold">
|
||||
{action.name}
|
||||
@@ -919,7 +919,7 @@ function APIToolConfig({
|
||||
<img
|
||||
src={ChevronRight}
|
||||
alt="expand"
|
||||
className={`h-4 w-4 opacity-60 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
||||
className={`h-4 w-4 opacity-60 transition-transform duration-200 dark:invert ${isExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium ${getMethodColorClass(action.method)}`}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildResourceFilter,
|
||||
countLiveTokens,
|
||||
defaultExpiry,
|
||||
eligibleFilterFamilies,
|
||||
expiryOptions,
|
||||
expiryStatus,
|
||||
groupScopesByFamily,
|
||||
isScopeImplied,
|
||||
isUuid,
|
||||
NO_ESCAPE,
|
||||
NO_EXPIRY,
|
||||
relativeTime,
|
||||
renewalExpiry,
|
||||
restrictionCounts,
|
||||
scopesToSubmit,
|
||||
toResourceOptions,
|
||||
} from './accessTokenUtils';
|
||||
|
||||
const CATALOG = [
|
||||
{ name: 'agents:read', description: 'View agents' },
|
||||
{ name: 'agents:write', description: 'Edit agents' },
|
||||
{ name: 'agents:keys', description: 'Agent keys' },
|
||||
{ name: 'sources:read', description: 'View sources' },
|
||||
{ name: 'analytics:read', description: 'View analytics' },
|
||||
{ name: 'chat:run', description: 'Ask agents' },
|
||||
];
|
||||
const FAMILIES = ['agents', 'sources', 'prompts', 'tools', 'workflows'];
|
||||
const POLICY = {
|
||||
default_lifetime_days: 90,
|
||||
max_lifetime_days: 365,
|
||||
allow_non_expiring: false,
|
||||
};
|
||||
const NOW = Date.parse('2026-09-19T12:00:00Z');
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const ID_A = '11111111-1111-4111-8111-111111111111';
|
||||
const ID_B = '22222222-2222-4222-8222-222222222222';
|
||||
|
||||
describe('groupScopesByFamily', () => {
|
||||
it('groups by family in catalog order', () => {
|
||||
const groups = groupScopesByFamily(CATALOG);
|
||||
expect(groups.map((g) => g.family)).toEqual([
|
||||
'agents',
|
||||
'sources',
|
||||
'analytics',
|
||||
'chat',
|
||||
]);
|
||||
expect(groups[0].scopes.map((s) => s.name)).toEqual([
|
||||
'agents:read',
|
||||
'agents:write',
|
||||
'agents:keys',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scope implication', () => {
|
||||
it('write implies read of the same family only', () => {
|
||||
expect(isScopeImplied('agents:read', ['agents:write'])).toBe(true);
|
||||
expect(isScopeImplied('sources:read', ['agents:write'])).toBe(false);
|
||||
expect(isScopeImplied('agents:keys', ['agents:write'])).toBe(false);
|
||||
expect(isScopeImplied('agents:write', ['agents:write'])).toBe(false);
|
||||
expect(isScopeImplied('agents:read', ['agents:read'])).toBe(false);
|
||||
});
|
||||
|
||||
it('scopesToSubmit drops implied reads and keeps catalog order', () => {
|
||||
expect(
|
||||
scopesToSubmit(
|
||||
['chat:run', 'agents:write', 'agents:read', 'sources:read'],
|
||||
CATALOG,
|
||||
),
|
||||
).toEqual(['agents:write', 'sources:read', 'chat:run']);
|
||||
});
|
||||
|
||||
it('scopesToSubmit ignores scopes missing from the catalog', () => {
|
||||
expect(scopesToSubmit(['bogus:read', 'agents:read'], CATALOG)).toEqual([
|
||||
'agents:read',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eligibleFilterFamilies', () => {
|
||||
it('only offers families that have a selected scope', () => {
|
||||
expect(eligibleFilterFamilies(['sources:read'], FAMILIES)).toEqual([
|
||||
'sources',
|
||||
]);
|
||||
expect(eligibleFilterFamilies(['analytics:read'], FAMILIES)).toEqual([]);
|
||||
expect(eligibleFilterFamilies([], FAMILIES)).toEqual([]);
|
||||
});
|
||||
|
||||
it('chat:run additionally permits agents and sources', () => {
|
||||
expect(eligibleFilterFamilies(['chat:run'], FAMILIES)).toEqual([
|
||||
'agents',
|
||||
'sources',
|
||||
]);
|
||||
});
|
||||
|
||||
it('respects the families the server says are filterable', () => {
|
||||
expect(eligibleFilterFamilies(['chat:run'], ['sources'])).toEqual([
|
||||
'sources',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildResourceFilter', () => {
|
||||
it('drops empty and ineligible families', () => {
|
||||
expect(
|
||||
buildResourceFilter({ agents: [ID_A], sources: [], tools: [ID_B] }, [
|
||||
'agents',
|
||||
'sources',
|
||||
]),
|
||||
).toEqual({ agents: [ID_A] });
|
||||
});
|
||||
|
||||
it('returns undefined when nothing is restricted', () => {
|
||||
expect(buildResourceFilter({ agents: [] }, ['agents'])).toBeUndefined();
|
||||
expect(buildResourceFilter({}, [])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('restrictionCounts', () => {
|
||||
it('counts ids per family', () => {
|
||||
expect(
|
||||
restrictionCounts({ agents: [ID_A, ID_B], sources: [ID_A] }),
|
||||
).toEqual([
|
||||
{ family: 'agents', count: 2 },
|
||||
{ family: 'sources', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('is empty ("all resources") for no filter', () => {
|
||||
expect(restrictionCounts({})).toEqual([]);
|
||||
expect(restrictionCounts(null)).toEqual([]);
|
||||
expect(restrictionCounts({ agents: [] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expiryOptions / defaultExpiry', () => {
|
||||
it('offers every preset up to the maximum', () => {
|
||||
expect(expiryOptions(POLICY)).toEqual([7, 30, 60, 90, 180, 365]);
|
||||
expect(defaultExpiry(POLICY)).toBe(90);
|
||||
});
|
||||
|
||||
it('filters presets above max_lifetime_days', () => {
|
||||
expect(expiryOptions({ ...POLICY, max_lifetime_days: 90 })).toEqual([
|
||||
7, 30, 60, 90,
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds a non-preset default in sorted position', () => {
|
||||
const policy = { ...POLICY, default_lifetime_days: 45 };
|
||||
expect(expiryOptions(policy)).toEqual([7, 30, 45, 60, 90, 180, 365]);
|
||||
expect(defaultExpiry(policy)).toBe(45);
|
||||
});
|
||||
|
||||
it('appends "no expiration" only when allowed', () => {
|
||||
const options = expiryOptions({ ...POLICY, allow_non_expiring: true });
|
||||
expect(options[options.length - 1]).toBe(NO_EXPIRY);
|
||||
expect(expiryOptions(POLICY)).not.toContain(NO_EXPIRY);
|
||||
});
|
||||
|
||||
it('falls back to the longest allowed lifetime for an out-of-range default', () => {
|
||||
const policy = {
|
||||
...POLICY,
|
||||
default_lifetime_days: 400,
|
||||
max_lifetime_days: 60,
|
||||
};
|
||||
expect(expiryOptions(policy)).toEqual([7, 30, 60]);
|
||||
expect(defaultExpiry(policy)).toBe(60);
|
||||
});
|
||||
|
||||
it('never returns an empty list when the maximum is below every preset', () => {
|
||||
const policy = {
|
||||
...POLICY,
|
||||
default_lifetime_days: 90,
|
||||
max_lifetime_days: 3,
|
||||
};
|
||||
expect(expiryOptions(policy)).toEqual([3]);
|
||||
expect(defaultExpiry(policy)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expiryStatus', () => {
|
||||
const at = (offset: number) => new Date(NOW + offset).toISOString();
|
||||
|
||||
it('classifies expiry relative to now', () => {
|
||||
expect(expiryStatus(null, NOW)).toBe('never');
|
||||
expect(expiryStatus(at(-1000), NOW)).toBe('expired');
|
||||
expect(expiryStatus(at(3 * DAY), NOW)).toBe('expiringSoon');
|
||||
expect(expiryStatus(at(7 * DAY), NOW)).toBe('expiringSoon');
|
||||
expect(expiryStatus(at(7 * DAY + 60_000), NOW)).toBe('ok');
|
||||
});
|
||||
|
||||
it('does not flag an unparseable date', () => {
|
||||
expect(expiryStatus('not-a-date', NOW)).toBe('ok');
|
||||
});
|
||||
|
||||
it('countLiveTokens skips expired tokens, like the server cap', () => {
|
||||
expect(
|
||||
countLiveTokens(
|
||||
[
|
||||
{ expires_at: null },
|
||||
{ expires_at: at(DAY) },
|
||||
{ expires_at: at(-DAY) },
|
||||
],
|
||||
NOW,
|
||||
),
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('relativeTime', () => {
|
||||
const ago = (ms: number) => new Date(NOW - ms).toISOString();
|
||||
|
||||
it('buckets past timestamps', () => {
|
||||
expect(relativeTime(null, NOW)).toBeNull();
|
||||
expect(relativeTime('garbage', NOW)).toBeNull();
|
||||
expect(relativeTime(ago(20_000), NOW)).toEqual({ unit: 'now' });
|
||||
expect(relativeTime(ago(5 * 60_000), NOW)).toEqual({
|
||||
unit: 'minutes',
|
||||
count: 5,
|
||||
});
|
||||
expect(relativeTime(ago(3 * 3_600_000), NOW)).toEqual({
|
||||
unit: 'hours',
|
||||
count: 3,
|
||||
});
|
||||
expect(relativeTime(ago(2 * DAY), NOW)).toEqual({ unit: 'days', count: 2 });
|
||||
expect(relativeTime(ago(45 * DAY), NOW)).toEqual({ unit: 'date' });
|
||||
});
|
||||
|
||||
it('treats clock skew into the future as "now"', () => {
|
||||
expect(relativeTime(ago(-30_000), NOW)).toEqual({ unit: 'now' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toResourceOptions', () => {
|
||||
it('accepts only UUID ids', () => {
|
||||
expect(isUuid(ID_A)).toBe(true);
|
||||
expect(isUuid('default')).toBe(false);
|
||||
expect(isUuid(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps own items with UUID ids, sorted by label', () => {
|
||||
expect(
|
||||
toResourceOptions('sources', [
|
||||
{ id: ID_B, name: 'Zeta' },
|
||||
{ id: 'default', name: 'Default' },
|
||||
{ id: ID_A, name: 'Alpha', ownership: 'user' },
|
||||
{
|
||||
id: '33333333-3333-4333-8333-333333333333',
|
||||
name: 'Team',
|
||||
ownership: 'team',
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{ value: ID_A, label: 'Alpha' },
|
||||
{ value: ID_B, label: 'Zeta' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips built-in and team prompts', () => {
|
||||
expect(
|
||||
toResourceOptions('prompts', [
|
||||
{ id: 'default', name: 'default', type: 'public' },
|
||||
{ id: ID_A, name: 'Mine', type: 'private' },
|
||||
{ id: ID_B, name: 'Shared', type: 'team' },
|
||||
]),
|
||||
).toEqual([{ value: ID_A, label: 'Mine' }]);
|
||||
});
|
||||
|
||||
it('reads tools from the {tools: []} envelope and prefers the custom name', () => {
|
||||
expect(
|
||||
toResourceOptions('tools', {
|
||||
success: true,
|
||||
tools: [
|
||||
{
|
||||
id: ID_A,
|
||||
name: 'brave',
|
||||
displayName: 'Brave',
|
||||
customName: 'My search',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([{ value: ID_A, label: 'My search' }]);
|
||||
});
|
||||
|
||||
it('returns nothing for an error body', () => {
|
||||
expect(toResourceOptions('agents', { success: false })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NO_ESCAPE', () => {
|
||||
it('turns off i18next HTML escaping so dates and names render as typed', () => {
|
||||
expect(NO_ESCAPE).toEqual({ interpolation: { escapeValue: false } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('eligibleFilterFamilies with chat:run', () => {
|
||||
const all = ['agents', 'sources', 'prompts', 'tools', 'workflows'];
|
||||
it('never offers a tools restriction next to chat:run', () => {
|
||||
expect(eligibleFilterFamilies(['tools:write', 'chat:run'], all)).toEqual([
|
||||
'agents',
|
||||
'sources',
|
||||
]);
|
||||
expect(eligibleFilterFamilies(['tools:write'], all)).toEqual(['tools']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renewalExpiry', () => {
|
||||
const policy = {
|
||||
default_lifetime_days: 90,
|
||||
max_lifetime_days: 365,
|
||||
allow_non_expiring: false,
|
||||
};
|
||||
const issued = '2026-09-01T10:00:00+00:00';
|
||||
const plusDays = (days: number) =>
|
||||
new Date(Date.parse(issued) + days * 86400000).toISOString();
|
||||
|
||||
it('preselects the lifetime the token was issued with', () => {
|
||||
expect(
|
||||
renewalExpiry({ created_at: issued, expires_at: plusDays(30) }, policy),
|
||||
).toBe(30);
|
||||
});
|
||||
|
||||
it('counts from the last regeneration, not from creation', () => {
|
||||
expect(
|
||||
renewalExpiry(
|
||||
{
|
||||
created_at: '2026-01-01T00:00:00+00:00',
|
||||
regenerated_at: issued,
|
||||
expires_at: plusDays(7),
|
||||
},
|
||||
policy,
|
||||
),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it('falls back to the default for a lifetime the policy no longer offers', () => {
|
||||
expect(
|
||||
renewalExpiry({ created_at: issued, expires_at: plusDays(45) }, policy),
|
||||
).toBe(90);
|
||||
expect(
|
||||
renewalExpiry(
|
||||
{ created_at: issued, expires_at: plusDays(365) },
|
||||
{ ...policy, max_lifetime_days: 180 },
|
||||
),
|
||||
).toBe(90);
|
||||
});
|
||||
|
||||
it('keeps a non-expiring token non-expiring only while that is allowed', () => {
|
||||
const item = { created_at: issued, expires_at: null };
|
||||
expect(renewalExpiry(item, policy)).toBe(90);
|
||||
expect(renewalExpiry(item, { ...policy, allow_non_expiring: true })).toBe(
|
||||
NO_EXPIRY,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the default on unparseable dates', () => {
|
||||
expect(
|
||||
renewalExpiry({ created_at: null, expires_at: plusDays(30) }, policy),
|
||||
).toBe(90);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import type {
|
||||
AccessTokenPolicy,
|
||||
AccessTokenScope,
|
||||
} from '../api/services/patService';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Lifetimes (in days) offered in the create form, before policy filtering. */
|
||||
export const EXPIRY_PRESETS = [7, 30, 60, 90, 180, 365];
|
||||
/** `expires_in_days` value the server reads as "never expires". */
|
||||
export const NO_EXPIRY = 0;
|
||||
/** Tokens expiring within this many days get a warning style. */
|
||||
export const EXPIRY_WARNING_DAYS = 7;
|
||||
/** Families `chat:run` acts on, so they may be restricted alongside it. */
|
||||
const CHAT_FAMILIES = ['agents', 'sources'];
|
||||
|
||||
const UUID_REGEX =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** The server only accepts UUIDs in `resource_filter` (built-ins like `default` are not). */
|
||||
export const isUuid = (value: unknown): value is string =>
|
||||
typeof value === 'string' && UUID_REGEX.test(value);
|
||||
|
||||
export const scopeFamily = (scope: string): string => scope.split(':')[0];
|
||||
export const scopeAction = (scope: string): string => scope.split(':')[1] ?? '';
|
||||
|
||||
export interface ScopeGroup {
|
||||
family: string;
|
||||
scopes: AccessTokenScope[];
|
||||
}
|
||||
|
||||
/** Group the server's scope catalog by family, keeping the server's order. */
|
||||
export function groupScopesByFamily(catalog: AccessTokenScope[]): ScopeGroup[] {
|
||||
const groups: ScopeGroup[] = [];
|
||||
const byFamily = new Map<string, ScopeGroup>();
|
||||
catalog.forEach((scope) => {
|
||||
const family = scopeFamily(scope.name);
|
||||
let group = byFamily.get(family);
|
||||
if (!group) {
|
||||
group = { family, scopes: [] };
|
||||
byFamily.set(family, group);
|
||||
groups.push(group);
|
||||
}
|
||||
group.scopes.push(scope);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** `x:read` is implied (granted server-side) whenever `x:write` is selected. */
|
||||
export function isScopeImplied(scope: string, selected: string[]): boolean {
|
||||
return (
|
||||
scopeAction(scope) === 'read' &&
|
||||
selected.includes(`${scopeFamily(scope)}:write`)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes to send: the selection minus reads that a selected write already
|
||||
* implies, in catalog order. The raw selection is kept separately so
|
||||
* unticking write restores whatever read was before.
|
||||
*/
|
||||
export function scopesToSubmit(
|
||||
selected: string[],
|
||||
catalog: AccessTokenScope[],
|
||||
): string[] {
|
||||
const chosen = new Set(selected);
|
||||
return catalog
|
||||
.map((scope) => scope.name)
|
||||
.filter((name) => chosen.has(name) && !isScopeImplied(name, selected));
|
||||
}
|
||||
|
||||
/** Filterable families the selected scopes allow restricting, in policy order. */
|
||||
export function eligibleFilterFamilies(
|
||||
selected: string[],
|
||||
filterableFamilies: string[],
|
||||
): string[] {
|
||||
const families = new Set(selected.map(scopeFamily));
|
||||
const chat = families.has('chat');
|
||||
if (chat) CHAT_FAMILIES.forEach((f) => families.add(f));
|
||||
// Chat executes tools, which cannot be held to an allowlist, so the server
|
||||
// rejects a tools restriction on a token that also has chat:run.
|
||||
if (chat) families.delete('tools');
|
||||
return filterableFamilies.filter((family) => families.has(family));
|
||||
}
|
||||
|
||||
/** Drop empty and ineligible families; `undefined` when nothing is restricted. */
|
||||
export function buildResourceFilter(
|
||||
selection: Record<string, string[]>,
|
||||
eligibleFamilies: string[],
|
||||
): Record<string, string[]> | undefined {
|
||||
const out: Record<string, string[]> = {};
|
||||
eligibleFamilies.forEach((family) => {
|
||||
const ids = selection[family];
|
||||
if (ids && ids.length > 0) out[family] = [...ids];
|
||||
});
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
export interface RestrictionCount {
|
||||
family: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Per-family counts of a token's `resource_filter`; empty means "all resources". */
|
||||
export function restrictionCounts(
|
||||
resourceFilter: Record<string, string[]> | null | undefined,
|
||||
): RestrictionCount[] {
|
||||
return Object.entries(resourceFilter ?? {})
|
||||
.filter(([, ids]) => Array.isArray(ids) && ids.length > 0)
|
||||
.map(([family, ids]) => ({ family, count: ids.length }));
|
||||
}
|
||||
|
||||
type ExpiryPolicy = Pick<
|
||||
AccessTokenPolicy,
|
||||
'default_lifetime_days' | 'max_lifetime_days' | 'allow_non_expiring'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lifetimes (days) to offer: presets within the server maximum, the server
|
||||
* default when it isn't a preset, and `NO_EXPIRY` last when allowed.
|
||||
*/
|
||||
export function expiryOptions(policy: ExpiryPolicy): number[] {
|
||||
const max = policy.max_lifetime_days;
|
||||
const days = new Set(EXPIRY_PRESETS.filter((d) => d <= max));
|
||||
const fallback = policy.default_lifetime_days;
|
||||
if (fallback > 0 && fallback <= max) days.add(fallback);
|
||||
// Never leave the select empty (e.g. max below the smallest preset).
|
||||
if (days.size === 0 && max > 0) days.add(max);
|
||||
const options = Array.from(days).sort((a, b) => a - b);
|
||||
if (policy.allow_non_expiring) options.push(NO_EXPIRY);
|
||||
return options;
|
||||
}
|
||||
|
||||
/** The option preselected in the form: the server default, clamped to what's offered. */
|
||||
export function defaultExpiry(policy: ExpiryPolicy): number {
|
||||
const options = expiryOptions(policy);
|
||||
if (options.includes(policy.default_lifetime_days)) {
|
||||
return policy.default_lifetime_days;
|
||||
}
|
||||
const finite = options.filter((d) => d !== NO_EXPIRY);
|
||||
return finite.length > 0 ? finite[finite.length - 1] : NO_EXPIRY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifetime to preselect when regenerating: what the token was last issued
|
||||
* with, when the policy still offers it; otherwise the policy default.
|
||||
*/
|
||||
export function renewalExpiry(
|
||||
item: {
|
||||
created_at: string | null;
|
||||
regenerated_at?: string | null;
|
||||
expires_at: string | null;
|
||||
},
|
||||
policy: ExpiryPolicy,
|
||||
): number {
|
||||
const options = expiryOptions(policy);
|
||||
if (!item.expires_at) {
|
||||
return options.includes(NO_EXPIRY) ? NO_EXPIRY : defaultExpiry(policy);
|
||||
}
|
||||
const issued = Date.parse(item.regenerated_at || item.created_at || '');
|
||||
const expires = Date.parse(item.expires_at);
|
||||
if (Number.isNaN(issued) || Number.isNaN(expires)) {
|
||||
return defaultExpiry(policy);
|
||||
}
|
||||
const days = Math.round((expires - issued) / DAY_MS);
|
||||
return options.includes(days) ? days : defaultExpiry(policy);
|
||||
}
|
||||
|
||||
export type ExpiryStatus = 'never' | 'expired' | 'expiringSoon' | 'ok';
|
||||
|
||||
export function expiryStatus(
|
||||
expiresAt: string | null | undefined,
|
||||
now: number = Date.now(),
|
||||
): ExpiryStatus {
|
||||
if (!expiresAt) return 'never';
|
||||
const at = Date.parse(expiresAt);
|
||||
if (Number.isNaN(at)) return 'ok';
|
||||
if (at <= now) return 'expired';
|
||||
if (at - now <= EXPIRY_WARNING_DAYS * DAY_MS) return 'expiringSoon';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
/** Tokens that count against `max_per_user`: the server ignores expired ones. */
|
||||
export function countLiveTokens(
|
||||
tokens: { expires_at: string | null }[],
|
||||
now: number = Date.now(),
|
||||
): number {
|
||||
return tokens.filter((tk) => expiryStatus(tk.expires_at, now) !== 'expired')
|
||||
.length;
|
||||
}
|
||||
|
||||
export type RelativeTime =
|
||||
| { unit: 'now' }
|
||||
| { unit: 'minutes' | 'hours' | 'days'; count: number }
|
||||
| { unit: 'date' };
|
||||
|
||||
/** Bucket a past timestamp for display; older than 30 days falls back to a date. */
|
||||
export function relativeTime(
|
||||
value: string | null | undefined,
|
||||
now: number = Date.now(),
|
||||
): RelativeTime | null {
|
||||
if (!value) return null;
|
||||
const at = Date.parse(value);
|
||||
if (Number.isNaN(at)) return null;
|
||||
const minutes = Math.floor(Math.max(0, now - at) / 60_000);
|
||||
if (minutes < 1) return { unit: 'now' };
|
||||
if (minutes < 60) return { unit: 'minutes', count: minutes };
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return { unit: 'hours', count: hours };
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days <= 30) return { unit: 'days', count: days };
|
||||
return { unit: 'date' };
|
||||
}
|
||||
|
||||
export interface ResourceOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Families the create form can offer a picker for (workflows have no list endpoint). */
|
||||
export const PICKER_FAMILIES = ['agents', 'sources', 'prompts', 'tools'];
|
||||
|
||||
/**
|
||||
* Turn a list-endpoint payload into picker options: only the caller's own
|
||||
* items (not team-shared or built-in ones) with ids the server will accept.
|
||||
*/
|
||||
export function toResourceOptions(
|
||||
family: string,
|
||||
payload: unknown,
|
||||
): ResourceOption[] {
|
||||
const envelope = payload as { tools?: unknown } | null;
|
||||
const rows: unknown[] = Array.isArray(payload)
|
||||
? payload
|
||||
: family === 'tools' && Array.isArray(envelope?.tools)
|
||||
? envelope.tools
|
||||
: [];
|
||||
return (rows as (Record<string, unknown> | null)[])
|
||||
.filter(
|
||||
(row): row is Record<string, unknown> =>
|
||||
!!row &&
|
||||
isUuid(row.id) &&
|
||||
row.ownership !== 'team' &&
|
||||
!(
|
||||
family === 'prompts' &&
|
||||
(row.type === 'public' || row.type === 'team')
|
||||
),
|
||||
)
|
||||
.map((row) => ({
|
||||
value: row.id as string,
|
||||
label: String(row.customName || row.displayName || row.name || row.id),
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
/**
|
||||
* i18next HTML-escapes interpolated values by default, which turns a date like
|
||||
* 26/09/2026 into `26/09/2026` and mangles token names containing
|
||||
* `&` or `'`. React already escapes on render, so opt out for those values
|
||||
* (same approach as utils/streamingStatusUtils.ts).
|
||||
*/
|
||||
export const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
|
||||
@@ -146,6 +146,11 @@ describe('round-trip configToOptions(optionsToConfig(x)) == x', () => {
|
||||
batch_size: 5,
|
||||
max_keep: 10,
|
||||
},
|
||||
graph: {
|
||||
seed_strategy: 'relationships',
|
||||
passage_nodes: false,
|
||||
blend_vector: false,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
extraction_model: null,
|
||||
@@ -157,6 +162,44 @@ describe('round-trip configToOptions(optionsToConfig(x)) == x', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('graph retrieval options', () => {
|
||||
it('defaults to the measured-best configuration', () => {
|
||||
expect(DEFAULT_RETRIEVAL_OPTIONS.retrieval.graph).toEqual({
|
||||
seed_strategy: 'entities',
|
||||
passage_nodes: true,
|
||||
blend_vector: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('fills the defaults for a source saved before the options existed', () => {
|
||||
const opts = configToOptions({ retrieval: { retriever: 'graphrag' } });
|
||||
expect(opts.retrieval.graph).toEqual(
|
||||
DEFAULT_RETRIEVAL_OPTIONS.retrieval.graph,
|
||||
);
|
||||
});
|
||||
|
||||
it('honors stored options and fills only the missing ones', () => {
|
||||
const opts = configToOptions({
|
||||
retrieval: { graph: { seed_strategy: 'relationships' } },
|
||||
});
|
||||
expect(opts.retrieval.graph).toEqual({
|
||||
seed_strategy: 'relationships',
|
||||
passage_nodes: true,
|
||||
blend_vector: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes the options into the retrieval block', () => {
|
||||
const v = clone(DEFAULT_RETRIEVAL_OPTIONS);
|
||||
v.retrieval.graph.blend_vector = false;
|
||||
expect(optionsToConfig(v).retrieval?.graph).toEqual({
|
||||
seed_strategy: 'entities',
|
||||
passage_nodes: true,
|
||||
blend_vector: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPrescreenConfigValid', () => {
|
||||
const withPrescreen = (
|
||||
chunks: number,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { Switch } from '../../components/ui/switch';
|
||||
import type {
|
||||
ChunkingStrategy,
|
||||
GraphSeedStrategy,
|
||||
RetrievalExposure,
|
||||
SourceConfig,
|
||||
} from '../../models/misc';
|
||||
@@ -59,6 +60,11 @@ export type RetrievalOptionsValue = {
|
||||
batch_size: number;
|
||||
max_keep: number;
|
||||
};
|
||||
graph: {
|
||||
seed_strategy: GraphSeedStrategy;
|
||||
passage_nodes: boolean;
|
||||
blend_vector: boolean;
|
||||
};
|
||||
};
|
||||
graph: {
|
||||
extraction_model: string | null;
|
||||
@@ -85,6 +91,12 @@ export const DEFAULT_RETRIEVAL_OPTIONS: RetrievalOptionsValue = {
|
||||
enabled: false,
|
||||
...DEFAULT_PRESCREEN,
|
||||
},
|
||||
// The configuration that measured best across the corpora tested.
|
||||
graph: {
|
||||
seed_strategy: 'entities',
|
||||
passage_nodes: true,
|
||||
blend_vector: true,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
extraction_model: null,
|
||||
@@ -204,6 +216,7 @@ export function configToOptions(config?: SourceConfig): RetrievalOptionsValue {
|
||||
const chunking = config?.chunking ?? {};
|
||||
const retrieval = config?.retrieval ?? {};
|
||||
const prescreen = retrieval.prescreen ?? null;
|
||||
const retrievalGraph = retrieval.graph ?? {};
|
||||
const graph = config?.graph ?? {};
|
||||
const d = DEFAULT_RETRIEVAL_OPTIONS;
|
||||
return {
|
||||
@@ -228,6 +241,14 @@ export function configToOptions(config?: SourceConfig): RetrievalOptionsValue {
|
||||
batch_size: prescreen?.batch_size ?? DEFAULT_PRESCREEN.batch_size,
|
||||
max_keep: prescreen?.max_keep ?? DEFAULT_PRESCREEN.max_keep,
|
||||
},
|
||||
graph: {
|
||||
seed_strategy:
|
||||
retrievalGraph.seed_strategy ?? d.retrieval.graph.seed_strategy,
|
||||
passage_nodes:
|
||||
retrievalGraph.passage_nodes ?? d.retrieval.graph.passage_nodes,
|
||||
blend_vector:
|
||||
retrievalGraph.blend_vector ?? d.retrieval.graph.blend_vector,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
extraction_model: graph.extraction_model ?? d.graph.extraction_model,
|
||||
@@ -271,6 +292,11 @@ export function optionsToConfig(value: RetrievalOptionsValue): SourceConfig {
|
||||
max_keep: ps.max_keep,
|
||||
}
|
||||
: null,
|
||||
graph: {
|
||||
seed_strategy: value.retrieval.graph.seed_strategy,
|
||||
passage_nodes: value.retrieval.graph.passage_nodes,
|
||||
blend_vector: value.retrieval.graph.blend_vector,
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
extraction_model: value.graph.extraction_model?.trim()
|
||||
@@ -399,6 +425,12 @@ export default function RetrievalOptions({
|
||||
});
|
||||
};
|
||||
|
||||
const setGraphRetrieval = (
|
||||
patch: Partial<RetrievalOptionsValue['retrieval']['graph']>,
|
||||
) => {
|
||||
setRetrieval({ graph: { ...value.retrieval.graph, ...patch } });
|
||||
};
|
||||
|
||||
const modelOptions = useMemo(() => {
|
||||
const builtin: Model[] = [];
|
||||
const user: Model[] = [];
|
||||
@@ -611,6 +643,84 @@ export default function RetrievalOptions({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Graph retrieval group (graphrag only; live, so shown when testing too) */}
|
||||
{isGraphRAG && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<GroupHeader
|
||||
title={tr('graphRetrieval.title')}
|
||||
tag={tr('graphRetrieval.tag')}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{tr('graphRetrieval.agentToolHint')}
|
||||
</p>
|
||||
|
||||
<div className="divide-border/50 divide-y">
|
||||
<SettingRow
|
||||
label={tr('graphRetrieval.seedStrategy')}
|
||||
htmlFor="graph-seed-strategy"
|
||||
description={tr('graphRetrieval.seedStrategyHint')}
|
||||
alignStart
|
||||
>
|
||||
<Select
|
||||
value={value.retrieval.graph.seed_strategy}
|
||||
disabled={disabled}
|
||||
onValueChange={(v) =>
|
||||
setGraphRetrieval({ seed_strategy: v as GraphSeedStrategy })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="graph-seed-strategy"
|
||||
className="w-52 rounded-md"
|
||||
size="lg"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="entities">
|
||||
{tr('graphRetrieval.seedEntities')}
|
||||
</SelectItem>
|
||||
<SelectItem value="relationships">
|
||||
{tr('graphRetrieval.seedRelationships')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={tr('graphRetrieval.passageNodes')}
|
||||
htmlFor="graph-passage-nodes"
|
||||
description={tr('graphRetrieval.passageNodesHint')}
|
||||
alignStart
|
||||
>
|
||||
<Switch
|
||||
id="graph-passage-nodes"
|
||||
checked={value.retrieval.graph.passage_nodes}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setGraphRetrieval({ passage_nodes: checked })
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={tr('graphRetrieval.blendVector')}
|
||||
htmlFor="graph-blend-vector"
|
||||
description={tr('graphRetrieval.blendVectorHint')}
|
||||
alignStart
|
||||
>
|
||||
<Switch
|
||||
id="graph-blend-vector"
|
||||
checked={value.retrieval.graph.blend_vector}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setGraphRetrieval({ blend_vector: checked })
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Graph extraction group (graphrag only; re-ingest required to apply) */}
|
||||
{isGraphRAG && !queryOnly && (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -792,7 +902,7 @@ export default function RetrievalOptions({
|
||||
<img
|
||||
src={ChevronRight}
|
||||
alt=""
|
||||
className={`h-3 w-3 transform transition-transform ${
|
||||
className={`h-3 w-3 transform transition-transform dark:invert ${
|
||||
expanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { usagePercent } from '../../admin/quotaUtils';
|
||||
import userService from '../../api/services/userService';
|
||||
import { selectToken } from '../../preferences/preferenceSlice';
|
||||
|
||||
type Budget = { limit: number | null; used: number };
|
||||
type Bucket = {
|
||||
bucket: string;
|
||||
tokens: Budget;
|
||||
cost: Budget;
|
||||
resets_at: string;
|
||||
};
|
||||
|
||||
function Meter({
|
||||
label,
|
||||
budget,
|
||||
format,
|
||||
}: {
|
||||
label: string;
|
||||
budget: Budget;
|
||||
format: (value: number) => string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (budget.limit === null) return null;
|
||||
const percent = usagePercent(budget.used, budget.limit);
|
||||
const tone =
|
||||
percent >= 100
|
||||
? 'bg-red-500'
|
||||
: percent >= 80
|
||||
? 'bg-amber-500'
|
||||
: 'bg-[#7D54D1]';
|
||||
return (
|
||||
<div className="min-w-48 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="text-foreground tabular-nums">
|
||||
{t('settings.analytics.quota.usedOf', {
|
||||
used: format(budget.used),
|
||||
limit: format(budget.limit),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="bg-muted mt-1 h-1.5 w-full overflow-hidden rounded-full"
|
||||
role="progressbar"
|
||||
aria-label={label}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(percent)}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full ${tone}`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The caller's usage against the quota an admin set; renders nothing when unlimited. */
|
||||
export default function UsageQuota() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
const [buckets, setBuckets] = useState<Bucket[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
userService
|
||||
.getQuota(token)
|
||||
.then((res: Response) => (res.ok ? res.json() : null))
|
||||
.then((json: { buckets?: Bucket[] } | null) => {
|
||||
if (cancelled) return;
|
||||
setBuckets(json?.buckets ?? []);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
if (buckets.length === 0) return null;
|
||||
|
||||
const number = new Intl.NumberFormat(i18n.language);
|
||||
const usd = new Intl.NumberFormat(i18n.language, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
});
|
||||
const reset = new Date(buckets[0].resets_at);
|
||||
const resetsAt = Number.isNaN(reset.getTime())
|
||||
? ''
|
||||
: new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(reset);
|
||||
|
||||
// A request must fit its own bucket and ``all``, so each limited one is shown.
|
||||
const scopeLabel = (name: string) =>
|
||||
name === 'direct' || name === 'agent'
|
||||
? t(`settings.analytics.quota.scope.${name}`)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="border-border mb-6 rounded-2xl border px-6 py-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<p className="text-foreground font-bold">
|
||||
{t('settings.analytics.quota.title')}
|
||||
</p>
|
||||
{resetsAt ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.analytics.quota.resets', { resetsAt })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{buckets.map((bucket) => (
|
||||
<div key={bucket.bucket} className="mt-3">
|
||||
{scopeLabel(bucket.bucket) ? (
|
||||
<p className="text-muted-foreground mb-1 text-xs">
|
||||
{scopeLabel(bucket.bucket)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<Meter
|
||||
label={t('settings.analytics.quota.tokens')}
|
||||
budget={bucket.tokens}
|
||||
format={(value) => number.format(value)}
|
||||
/>
|
||||
<Meter
|
||||
label={t('settings.analytics.quota.cost')}
|
||||
budget={bucket.cost}
|
||||
format={(value) => usd.format(value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import CustomModels from './CustomModels';
|
||||
import Sources from './Sources';
|
||||
import General from './General';
|
||||
import Logs from './Logs';
|
||||
import PersonalAccessTokens from './PersonalAccessTokens';
|
||||
import Tools from './Tools';
|
||||
|
||||
type HiddenGradientType = 'left' | 'right' | undefined;
|
||||
@@ -47,6 +48,8 @@ export default function Settings() {
|
||||
if (path.includes('/settings/tools')) return t('settings.tools.label');
|
||||
if (path.includes('/settings/custom-models'))
|
||||
return t('settings.customModels.label');
|
||||
if (path.includes('/settings/access-tokens'))
|
||||
return t('settings.accessTokens.label');
|
||||
return t('settings.general.label');
|
||||
};
|
||||
|
||||
@@ -58,6 +61,7 @@ export default function Settings() {
|
||||
t('settings.logs.label'),
|
||||
t('settings.tools.label'),
|
||||
t('settings.customModels.label'),
|
||||
t('settings.accessTokens.label'),
|
||||
];
|
||||
const [hiddenGradient, setHiddenGradient] =
|
||||
useState<HiddenGradientType>('left');
|
||||
@@ -93,6 +97,8 @@ export default function Settings() {
|
||||
else if (tab === t('settings.tools.label')) navigate('/settings/tools');
|
||||
else if (tab === t('settings.customModels.label'))
|
||||
navigate('/settings/custom-models');
|
||||
else if (tab === t('settings.accessTokens.label'))
|
||||
navigate('/settings/access-tokens');
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -198,6 +204,7 @@ export default function Settings() {
|
||||
element={<Navigate to="/settings/tools" replace />}
|
||||
/>
|
||||
<Route path="custom-models" element={<CustomModels />} />
|
||||
<Route path="access-tokens" element={<PersonalAccessTokens />} />
|
||||
<Route path="*" element={<Navigate to="/settings" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -6,21 +6,8 @@ export type ChunkType = {
|
||||
metadata: { [key: string]: string };
|
||||
};
|
||||
|
||||
export type APIKeyData = {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
source: string;
|
||||
prompt_id: string;
|
||||
chunks: string;
|
||||
};
|
||||
|
||||
export type LogEventType =
|
||||
| 'chat'
|
||||
| 'schedule'
|
||||
| 'webhook'
|
||||
| 'workflow'
|
||||
| 'system';
|
||||
'chat' | 'schedule' | 'webhook' | 'workflow' | 'system';
|
||||
|
||||
export type LogData = {
|
||||
id: string;
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Building2,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Plus,
|
||||
Settings as SettingsIcon,
|
||||
User,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -174,7 +174,7 @@ export default function TeamSwitcher({
|
||||
{currentTeam ? (
|
||||
<Users className="size-4" strokeWidth={1.75} />
|
||||
) : (
|
||||
<Building2 className="size-4" strokeWidth={1.75} />
|
||||
<User className="size-4" strokeWidth={1.75} />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
@@ -199,7 +199,7 @@ export default function TeamSwitcher({
|
||||
{/* Personal account entry */}
|
||||
{currentTeam && (
|
||||
<DropdownMenuItem onSelect={() => selectTeam(null)}>
|
||||
<Building2 className="size-4" strokeWidth={1.75} />
|
||||
<User className="size-4" strokeWidth={1.75} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t('teams.switcher.personal')}
|
||||
</span>
|
||||
|
||||
@@ -1086,7 +1086,7 @@ function Upload({
|
||||
<img
|
||||
src={ChevronRight}
|
||||
alt="back"
|
||||
className="h-3 w-3 rotate-180 transform"
|
||||
className="h-3 w-3 rotate-180 transform dark:invert"
|
||||
/>
|
||||
<span>{t('modals.uploadDoc.back')}</span>
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user