every plugin route renders a workspace, and it is not a rule you can forget
an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.
web/panels.ts appRegistryMetas — at least one panel
web/layout.ts defaultLayout — how they are arranged
both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:
probeplug: has a web/ directory but is missing web/layout.ts.
Every plugin route renders a Workspace: contribute panels and a layout,
not a screen.
there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.
the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.
the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+9
-2
@@ -51,7 +51,9 @@ export const ApifyConfig = () => {
|
||||
<div className="grid gap-5">
|
||||
{status && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.configured ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`} />
|
||||
<div
|
||||
className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.configured ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`}
|
||||
/>
|
||||
<span className="text-sm text-duck-dark dark:text-foreground">
|
||||
{status.configured ? 'API token configured' : 'Not configured'}
|
||||
</span>
|
||||
@@ -70,7 +72,12 @@ export const ApifyConfig = () => {
|
||||
/>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Get your token at{' '}
|
||||
<a href="https://console.apify.com/account/integrations" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
<a
|
||||
href="https://console.apify.com/account/integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
console.apify.com/account/integrations
|
||||
</a>
|
||||
</span>
|
||||
|
||||
+14
-14
@@ -69,7 +69,9 @@ export const BrowserRelay = () => {
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
{status?.targetCount ?? 0} tab{(status?.targetCount ?? 0) !== 1 ? 's' : ''} attached
|
||||
{' — '}
|
||||
<a href="/browser" className="text-duck-teal underline">view tabs</a>
|
||||
<a href="/browser" className="text-duck-teal underline">
|
||||
view tabs
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,7 +98,9 @@ export const BrowserRelay = () => {
|
||||
<ol className="list-decimal list-inside space-y-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
<li>
|
||||
Open{' '}
|
||||
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded text-[11px]">chrome://extensions</code>{' '}
|
||||
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded text-[11px]">
|
||||
chrome://extensions
|
||||
</code>{' '}
|
||||
in Chrome
|
||||
</li>
|
||||
<li>
|
||||
@@ -139,12 +143,7 @@ export const BrowserRelay = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => regenerate.mutate()}
|
||||
disabled={regenerate.isPending}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => regenerate.mutate()} disabled={regenerate.isPending}>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Regenerate
|
||||
</Button>
|
||||
@@ -166,8 +165,8 @@ export const BrowserRelay = () => {
|
||||
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-3">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">3. Attach a tab</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan <strong>ON</strong> badge means
|
||||
the tab is connected. Then go to{' '}
|
||||
Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan <strong>ON</strong> badge
|
||||
means the tab is connected. Then go to{' '}
|
||||
<a href="/browser" className="text-duck-teal underline inline-flex items-center gap-0.5">
|
||||
/browser <ExternalLink className="h-3 w-3" />
|
||||
</a>{' '}
|
||||
@@ -189,10 +188,11 @@ type CredentialRowProps = {
|
||||
const CredentialRow = ({ label, value, masked, copied, onCopy }: CredentialRowProps) => (
|
||||
<div className="flex items-center gap-2 rounded-md bg-duck-dark/5 dark:bg-foreground/5 px-3 py-2">
|
||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50 shrink-0 w-24">{label}</span>
|
||||
<code className="flex-1 text-xs truncate">
|
||||
{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value}
|
||||
</code>
|
||||
<button onClick={onCopy} className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer">
|
||||
<code className="flex-1 text-xs truncate">{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value}</code>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5 opacity-50" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+1
-4
@@ -196,10 +196,7 @@ export const EmailAccounts = () => {
|
||||
const progress = accountJob?.steps[accountJob.currentStep]?.progress;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"
|
||||
>
|
||||
<div key={account.id} className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ProviderIcon provider={account.provider} />
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
+78
-26
@@ -32,7 +32,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<strong className="text-duck-dark dark:text-foreground">Create a Google Cloud project</strong>
|
||||
<p className="mt-1">
|
||||
Go to the{' '}
|
||||
<a href="https://console.cloud.google.com/projectcreate" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
<a
|
||||
href="https://console.cloud.google.com/projectcreate"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
New Project
|
||||
</a>{' '}
|
||||
page. Give it a name (e.g. "Officer") and click <strong>Create</strong>.
|
||||
@@ -43,32 +48,56 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<strong className="text-duck-dark dark:text-foreground">Enable the APIs</strong>
|
||||
<p className="mt-1">
|
||||
Go to{' '}
|
||||
<a href="https://console.cloud.google.com/apis/library" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
<a
|
||||
href="https://console.cloud.google.com/apis/library"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
API Library
|
||||
</a>
|
||||
. Search for and enable each of these:
|
||||
</p>
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
<li><strong>Gmail API</strong></li>
|
||||
<li><strong>Google Calendar API</strong></li>
|
||||
<li>
|
||||
<strong>Gmail API</strong>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Google Calendar API</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-1">Click each one, then click <strong>Enable</strong>.</p>
|
||||
<p className="mt-1">
|
||||
Click each one, then click <strong>Enable</strong>.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Configure the OAuth consent screen</strong>
|
||||
<p className="mt-1">
|
||||
Go to{' '}
|
||||
<a href="https://console.cloud.google.com/auth/branding" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
<a
|
||||
href="https://console.cloud.google.com/auth/branding"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
OAuth Branding
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
<li>Set <strong>App name</strong> to your organization name or "Officer"</li>
|
||||
<li>Set <strong>User support email</strong> to your admin email</li>
|
||||
<li>Add your admin email under <strong>Developer contact information</strong></li>
|
||||
<li>Click <strong>Save</strong></li>
|
||||
<li>
|
||||
Set <strong>App name</strong> to your organization name or "Officer"
|
||||
</li>
|
||||
<li>
|
||||
Set <strong>User support email</strong> to your admin email
|
||||
</li>
|
||||
<li>
|
||||
Add your admin email under <strong>Developer contact information</strong>
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Save</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -76,7 +105,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<strong className="text-duck-dark dark:text-foreground">Set the audience</strong>
|
||||
<p className="mt-1">
|
||||
Go to{' '}
|
||||
<a href="https://console.cloud.google.com/auth/audience" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
<a
|
||||
href="https://console.cloud.google.com/auth/audience"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
OAuth Audience
|
||||
</a>
|
||||
.
|
||||
@@ -86,7 +120,8 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
If your team uses Google Workspace, select <strong>Internal</strong> — no verification needed
|
||||
</li>
|
||||
<li>
|
||||
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong> (required while the app is unverified; limit of 100 test users)
|
||||
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong>{' '}
|
||||
(required while the app is unverified; limit of 100 test users)
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -95,7 +130,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<strong className="text-duck-dark dark:text-foreground">Add scopes</strong>
|
||||
<p className="mt-1">
|
||||
In the left sidebar, click{' '}
|
||||
<a href="https://console.cloud.google.com/auth/scopes" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
<a
|
||||
href="https://console.cloud.google.com/auth/scopes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
Data Access
|
||||
</a>
|
||||
, then click <strong>Add or remove scopes</strong>. Search for and add:
|
||||
@@ -103,18 +143,20 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
{SCOPES.map((s) => (
|
||||
<li key={s.scope}>
|
||||
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">
|
||||
{s.scope}
|
||||
</code>{' '}
|
||||
— {s.description}
|
||||
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">{s.scope}</code> —{' '}
|
||||
{s.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-1">Click <strong>Update</strong>, then <strong>Save</strong>.</p>
|
||||
<p className="mt-1">
|
||||
Click <strong>Update</strong>, then <strong>Save</strong>.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code> is classified as <strong>sensitive</strong> and{' '}
|
||||
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as <strong>restricted</strong> by Google.
|
||||
This is fine for Internal apps (Google Workspace) and External apps in testing mode. Publishing to production with restricted scopes requires Google verification.
|
||||
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code>{' '}
|
||||
is classified as <strong>sensitive</strong> and{' '}
|
||||
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as{' '}
|
||||
<strong>restricted</strong> by Google. This is fine for Internal apps (Google Workspace) and External apps
|
||||
in testing mode. Publishing to production with restricted scopes requires Google verification.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
@@ -122,13 +164,20 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
<strong className="text-duck-dark dark:text-foreground">Create OAuth credentials</strong>
|
||||
<p className="mt-1">
|
||||
In the left sidebar, click{' '}
|
||||
<a href="https://console.cloud.google.com/auth/clients" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
|
||||
<a
|
||||
href="https://console.cloud.google.com/auth/clients"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-duck-teal underline"
|
||||
>
|
||||
Clients
|
||||
</a>
|
||||
, then click <strong>Create OAuth client</strong>.
|
||||
</p>
|
||||
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
|
||||
<li>Application type: <strong>Web application</strong></li>
|
||||
<li>
|
||||
Application type: <strong>Web application</strong>
|
||||
</li>
|
||||
<li>Name: anything (e.g. "Officer")</li>
|
||||
<li>
|
||||
Authorized redirect URIs: add{' '}
|
||||
@@ -136,14 +185,17 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
|
||||
{redirectUri}
|
||||
</code>
|
||||
</li>
|
||||
<li>Click <strong>Create</strong></li>
|
||||
<li>
|
||||
Click <strong>Create</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong className="text-duck-dark dark:text-foreground">Copy the credentials</strong>
|
||||
<p className="mt-1">
|
||||
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste them into the fields below.
|
||||
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste
|
||||
them into the fields below.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
@@ -170,7 +222,7 @@ const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVer
|
||||
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}>
|
||||
{status.valid ? 'Credentials valid' : status.error ?? 'Invalid credentials'}
|
||||
{status.valid ? 'Credentials valid' : (status.error ?? 'Invalid credentials')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -87,19 +87,25 @@ export const AIModels = () => {
|
||||
<div className="grid gap-5">
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Chat Model</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat from the home screen</p>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Used when starting a new chat from the home screen
|
||||
</p>
|
||||
{renderModelSelect(chatModel, setChatModel, 'System default')}
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Project Model</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat inside a project dashboard</p>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Used when starting a new chat inside a project dashboard
|
||||
</p>
|
||||
{renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
|
||||
</Label>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Task Model</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when running tasks from the file browser</p>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Used when running tasks from the file browser
|
||||
</p>
|
||||
{renderModelSelect(taskModel, setTaskModel, 'Same as chat default')}
|
||||
</Label>
|
||||
|
||||
|
||||
+38
-11
@@ -1,7 +1,15 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { RefreshCw, Play, Square, Loader2 } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
|
||||
@@ -96,8 +104,16 @@ export const VoicePreference = () => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
audio.onended = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
|
||||
audio.onerror = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
|
||||
audio.onended = () => {
|
||||
audioRef.current = null;
|
||||
setListening('idle');
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
audio.onerror = () => {
|
||||
audioRef.current = null;
|
||||
setListening('idle');
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
await audio.play();
|
||||
setListening('playing');
|
||||
} catch {
|
||||
@@ -120,7 +136,9 @@ export const VoicePreference = () => {
|
||||
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
|
||||
title="Refresh voices"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -129,20 +147,27 @@ export const VoicePreference = () => {
|
||||
<SelectValue placeholder="Server default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600] max-h-[300px]">
|
||||
<SelectItem value={SERVER_DEFAULT}>Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}</SelectItem>
|
||||
<SelectItem value={SERVER_DEFAULT}>
|
||||
Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}
|
||||
</SelectItem>
|
||||
{groups.length > 0
|
||||
? groups.map((g) => (
|
||||
<SelectGroup key={g.label}>
|
||||
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">{g.label}</SelectLabel>
|
||||
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">
|
||||
{g.label}
|
||||
</SelectLabel>
|
||||
{g.voices.map((v) => (
|
||||
<SelectItem key={v} value={v}>{prettify(v)}</SelectItem>
|
||||
<SelectItem key={v} value={v}>
|
||||
{prettify(v)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))
|
||||
: voices.map((v) => (
|
||||
<SelectItem key={v} value={v}>{v}</SelectItem>
|
||||
))
|
||||
}
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<button
|
||||
@@ -161,7 +186,9 @@ export const VoicePreference = () => {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.</span>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
+8
-3
@@ -466,7 +466,9 @@ export const AIHarnessesSection = () => {
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder={getStoredMasked(provider.providerId) || 'Enter API key'}
|
||||
value={keyInputs[provider.providerId] ?? ''}
|
||||
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))}
|
||||
onChange={(ev) =>
|
||||
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
|
||||
}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId);
|
||||
if (ev.key === 'Escape') setEditingProvider(null);
|
||||
@@ -496,7 +498,8 @@ export const AIHarnessesSection = () => {
|
||||
{editingProvider &&
|
||||
(() => {
|
||||
const provider = CHAT_PROVIDERS.find((p) => p.key === editingProvider);
|
||||
if (!provider || connectedProviders.some((cp) => cp.providerId === provider.providerId)) return null;
|
||||
if (!provider || connectedProviders.some((cp) => cp.providerId === provider.providerId))
|
||||
return null;
|
||||
return (
|
||||
<div key={provider.providerId} className="flex items-center gap-2">
|
||||
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">
|
||||
@@ -507,7 +510,9 @@ export const AIHarnessesSection = () => {
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder="Enter API key"
|
||||
value={keyInputs[provider.providerId] ?? ''}
|
||||
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))}
|
||||
onChange={(ev) =>
|
||||
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
|
||||
}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId);
|
||||
if (ev.key === 'Escape') setEditingProvider(null);
|
||||
|
||||
@@ -81,9 +81,7 @@ export const SMTPSection = () => {
|
||||
provider,
|
||||
fromName,
|
||||
fromEmail,
|
||||
...(provider === 'resend'
|
||||
? { apiKey }
|
||||
: { host, port: parseInt(port) || 587, username, password, secure }),
|
||||
...(provider === 'resend' ? { apiKey } : { host, port: parseInt(port) || 587, username, password, secure }),
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -109,7 +107,11 @@ export const SMTPSection = () => {
|
||||
} catch (err: unknown) {
|
||||
const raw = (err as { message?: string })?.message;
|
||||
let msg = 'Connection failed';
|
||||
try { if (raw) msg = JSON.parse(raw).error ?? msg; } catch { /* ignore */ }
|
||||
try {
|
||||
if (raw) msg = JSON.parse(raw).error ?? msg;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setIsTestingConnection(false);
|
||||
@@ -120,7 +122,10 @@ export const SMTPSection = () => {
|
||||
if (isTesting || !testEmail) return;
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const result = await client.post<{ success?: boolean; error?: string }>('/server-settings/smtp/test', { ...buildConfig(), to: testEmail });
|
||||
const result = await client.post<{ success?: boolean; error?: string }>('/server-settings/smtp/test', {
|
||||
...buildConfig(),
|
||||
to: testEmail,
|
||||
});
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
@@ -129,7 +134,11 @@ export const SMTPSection = () => {
|
||||
} catch (err: unknown) {
|
||||
const raw = (err as { message?: string })?.message;
|
||||
let msg = 'Failed to send test email';
|
||||
try { if (raw) msg = JSON.parse(raw).error ?? msg; } catch { /* ignore */ }
|
||||
try {
|
||||
if (raw) msg = JSON.parse(raw).error ?? msg;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
|
||||
@@ -5,7 +5,15 @@ import { RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type Provider = 'openai' | 'elevenlabs';
|
||||
@@ -47,7 +55,11 @@ export const TTSSection = () => {
|
||||
const fetchVoices = async (p: Provider, u: string, key: string, m?: string) => {
|
||||
setVoicesLoading(true);
|
||||
try {
|
||||
const res = await client.post<{ voices?: string[]; groups?: { label: string; voices: string[] }[]; error?: string }>('/server-settings/tts/voices', {
|
||||
const res = await client.post<{
|
||||
voices?: string[];
|
||||
groups?: { label: string; voices: string[] }[];
|
||||
error?: string;
|
||||
}>('/server-settings/tts/voices', {
|
||||
provider: p,
|
||||
url: u,
|
||||
apiKey: key || undefined,
|
||||
@@ -167,7 +179,9 @@ export const TTSSection = () => {
|
||||
)}
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">API Key {provider === 'openai' ? '(optional)' : ''}</span>
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">
|
||||
API Key {provider === 'openai' ? '(optional)' : ''}
|
||||
</span>
|
||||
<Input
|
||||
type="password"
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
|
||||
@@ -197,7 +211,9 @@ export const TTSSection = () => {
|
||||
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
|
||||
title="Refresh voices"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{voices.length > 0 ? (
|
||||
@@ -209,16 +225,21 @@ export const TTSSection = () => {
|
||||
{voiceGroups.length > 0
|
||||
? voiceGroups.map((g) => (
|
||||
<SelectGroup key={g.label}>
|
||||
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">{g.label}</SelectLabel>
|
||||
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">
|
||||
{g.label}
|
||||
</SelectLabel>
|
||||
{g.voices.map((v) => (
|
||||
<SelectItem key={v} value={v}>{v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase())}</SelectItem>
|
||||
<SelectItem key={v} value={v}>
|
||||
{v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase())}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))
|
||||
: voices.map((v) => (
|
||||
<SelectItem key={v} value={v}>{v}</SelectItem>
|
||||
))
|
||||
}
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
|
||||
@@ -37,13 +37,17 @@ const SectionLink = ({ section, to }: { section: SettingsSection; to: string })
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
|
||||
isActive ? 'bg-duck-teal/10 text-duck-dark dark:text-foreground' : 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
|
||||
isActive
|
||||
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
|
||||
: 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<section.icon className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`} />
|
||||
<section.icon
|
||||
className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">{section.title}</div>
|
||||
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{section.description}</div>
|
||||
@@ -53,7 +57,14 @@ const SectionLink = ({ section, to }: { section: SettingsSection; to: string })
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups, hideHeader }: SettingsSidebarProps) => {
|
||||
export const SettingsSidebar = ({
|
||||
basePath,
|
||||
icon: Icon,
|
||||
label,
|
||||
sections,
|
||||
groups,
|
||||
hideHeader,
|
||||
}: SettingsSidebarProps) => {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const query = search.toLowerCase();
|
||||
@@ -71,7 +82,12 @@ export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups,
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 pb-2">
|
||||
<Input placeholder="Search..." value={search} onChange={(ev) => setSearch(ev.target.value)} className="h-8 text-xs" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1">
|
||||
{groups
|
||||
@@ -92,9 +108,9 @@ export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups,
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: sections.filter(matchesSearch).map((s) => (
|
||||
<SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />
|
||||
))}
|
||||
: sections
|
||||
.filter(matchesSearch)
|
||||
.map((s) => <SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -155,10 +171,22 @@ type CreateSettingsPanelParams = {
|
||||
groups?: SettingsSectionGroup[];
|
||||
};
|
||||
|
||||
export const createSettingsPanelComponents = ({ basePath, sidebarIcon, sidebarLabel, sections = [], groups }: CreateSettingsPanelParams) => {
|
||||
export const createSettingsPanelComponents = ({
|
||||
basePath,
|
||||
sidebarIcon,
|
||||
sidebarLabel,
|
||||
sections = [],
|
||||
groups,
|
||||
}: CreateSettingsPanelParams) => {
|
||||
const allSections = groups ? groups.flatMap((g) => g.sections) : sections;
|
||||
const Sidebar: ComponentType = () => (
|
||||
<SettingsSidebar basePath={basePath} icon={sidebarIcon} label={sidebarLabel} sections={allSections} groups={groups} />
|
||||
<SettingsSidebar
|
||||
basePath={basePath}
|
||||
icon={sidebarIcon}
|
||||
label={sidebarLabel}
|
||||
sections={allSections}
|
||||
groups={groups}
|
||||
/>
|
||||
);
|
||||
const Content: ComponentType = () => <SettingsContent sections={allSections} />;
|
||||
return { Sidebar, Content, allSections };
|
||||
|
||||
Reference in New Issue
Block a user