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:
Alex committed 2026-09-21 17:52:53 +01:00
commit a25bec6821
300 files changed
+27029 -1745

No files matched your search

+2
View File
@@ -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 {
+261
View File
@@ -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>
);
}
+347
View File
@@ -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&apos;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>
);
}
+122
View File
@@ -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>
);
}
+16 -1
View File
@@ -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) => {
+3
View File
@@ -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>
+128
View File
@@ -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',
);
});
});
+136
View File
@@ -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}`
+11 -3
View File
@@ -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',
+25
View File
@@ -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',
);
});
});
+130
View File
@@ -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;
+2 -6
View File
@@ -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> =>
+2 -6
View File
@@ -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')}
+11 -1
View File
@@ -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"
+14 -9
View File
@@ -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);
});
});
+47
View File
@@ -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 },
);
}
+59
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+1 -1
View File
@@ -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>
);
}
+12
View File
@@ -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').
+2
View File
@@ -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')}
+1 -1
View File
@@ -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>
);
}
+2 -2
View File
@@ -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);
});
});
+261
View File
@@ -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&#x2F;09&#x2F;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>
);
}
+7
View File
@@ -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>
+1 -14
View File
@@ -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 -3
View File
@@ -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>
+1 -1
View File
@@ -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>