Add TKMind platform extensions, H5/MindSpace stack, and deployment tooling.
Deploy Documentation / deploy (push) Has been cancelled
Canary / Prepare Version (push) Has been cancelled
Canary / build-cli (push) Has been cancelled
Canary / Upload Install Script (push) Has been cancelled
Canary / bundle-desktop (push) Has been cancelled
Canary / bundle-desktop-intel (push) Has been cancelled
Canary / bundle-desktop-linux (push) Has been cancelled
Canary / bundle-desktop-windows (push) Has been cancelled
Canary / bundle-desktop-windows-cuda (push) Has been cancelled
Canary / Release (push) Has been cancelled
Unused Dependencies / machete (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / Check Rust Code Format (push) Has been cancelled
CI / Build and Test Rust Project (push) Has been cancelled
CI / Build Rust Project on Windows (push) Has been cancelled
CI / Check MSRV (push) Has been cancelled
CI / Lint Rust Code (push) Has been cancelled
CI / Check Generated Schemas are Up-to-Date (push) Has been cancelled
CI / Test and Lint Electron Desktop App (push) Has been cancelled
CI / H5 Plaza Tests and Build (push) Has been cancelled
Live Provider Tests / check-fork (push) Has been cancelled
Live Provider Tests / changes (push) Has been cancelled
Live Provider Tests / Build Binary (push) Has been cancelled
Live Provider Tests / Smoke Tests (push) Has been cancelled
Live Provider Tests / Smoke Tests (Code Execution) (push) Has been cancelled
Live Provider Tests / Compaction Tests (push) Has been cancelled
Live Provider Tests / goose server HTTP integration tests (push) Has been cancelled
Publish Ask AI Bot Docker Image / docker (push) Has been cancelled
Publish Docker Image / docker (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Deploy Documentation / deploy (push) Has been cancelled
Canary / Prepare Version (push) Has been cancelled
Canary / build-cli (push) Has been cancelled
Canary / Upload Install Script (push) Has been cancelled
Canary / bundle-desktop (push) Has been cancelled
Canary / bundle-desktop-intel (push) Has been cancelled
Canary / bundle-desktop-linux (push) Has been cancelled
Canary / bundle-desktop-windows (push) Has been cancelled
Canary / bundle-desktop-windows-cuda (push) Has been cancelled
Canary / Release (push) Has been cancelled
Unused Dependencies / machete (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / Check Rust Code Format (push) Has been cancelled
CI / Build and Test Rust Project (push) Has been cancelled
CI / Build Rust Project on Windows (push) Has been cancelled
CI / Check MSRV (push) Has been cancelled
CI / Lint Rust Code (push) Has been cancelled
CI / Check Generated Schemas are Up-to-Date (push) Has been cancelled
CI / Test and Lint Electron Desktop App (push) Has been cancelled
CI / H5 Plaza Tests and Build (push) Has been cancelled
Live Provider Tests / check-fork (push) Has been cancelled
Live Provider Tests / changes (push) Has been cancelled
Live Provider Tests / Build Binary (push) Has been cancelled
Live Provider Tests / Smoke Tests (push) Has been cancelled
Live Provider Tests / Smoke Tests (Code Execution) (push) Has been cancelled
Live Provider Tests / Compaction Tests (push) Has been cancelled
Live Provider Tests / goose server HTTP integration tests (push) Has been cancelled
Publish Ask AI Bot Docker Image / docker (push) Has been cancelled
Publish Docker Image / docker (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Fork goose with custom MCP widgets, platform extensions (aider, git, web, search), MindSpace H5 backend/frontend, Plaza/Ops UIs, and deploy scripts for tkmind.cn. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
VITE_START_EMBEDDED_SERVER=yes
|
||||
GOOSE_PROVIDER__TYPE=openai
|
||||
GOOSE_PROVIDER__HOST=https://api.openai.com
|
||||
GOOSE_PROVIDER__MODEL=gpt-4o
|
||||
@@ -30,7 +30,7 @@ import SettingsView, { SettingsViewOptions } from './components/settings/Setting
|
||||
import SessionsView from './components/sessions/SessionsView';
|
||||
import SharedSessionView from './components/sessions/SharedSessionView';
|
||||
import SchedulesView from './components/schedule/SchedulesView';
|
||||
import ProviderSettings from './components/settings/providers/ProviderSettingsPage';
|
||||
import ProviderSettings from './components/settings/providers/LLMProviderPage';
|
||||
import { AppLayout } from './components/Layout/AppLayout';
|
||||
import { ChatProvider, DEFAULT_CHAT_TITLE } from './contexts/ChatContext';
|
||||
import LauncherView from './components/LauncherView';
|
||||
@@ -260,7 +260,6 @@ const ConfigureProvidersRoute = () => {
|
||||
<div className="w-screen h-screen bg-background-primary">
|
||||
<ProviderSettings
|
||||
onClose={() => navigate('/settings', { state: { section: 'models' } })}
|
||||
isOnboarding={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -42,5 +42,59 @@
|
||||
"enabled": false,
|
||||
"type": "builtin",
|
||||
"env_keys": []
|
||||
},
|
||||
{
|
||||
"id": "tableviewer",
|
||||
"name": "Table Viewer",
|
||||
"description": "Display data as interactive sortable and filterable tables.",
|
||||
"enabled": false,
|
||||
"type": "builtin",
|
||||
"env_keys": [],
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"id": "diffviewer",
|
||||
"name": "Diff Viewer",
|
||||
"description": "Show code or text changes as a side-by-side diff with syntax highlighting.",
|
||||
"enabled": false,
|
||||
"type": "builtin",
|
||||
"env_keys": [],
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"id": "formbuilder",
|
||||
"name": "Form Builder",
|
||||
"description": "Collect structured input from users via dynamically generated forms.",
|
||||
"enabled": false,
|
||||
"type": "builtin",
|
||||
"env_keys": [],
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"id": "kanban",
|
||||
"name": "Kanban Board",
|
||||
"description": "Visualize tasks and progress as a kanban board with priority badges.",
|
||||
"enabled": false,
|
||||
"type": "builtin",
|
||||
"env_keys": [],
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"id": "timeline",
|
||||
"name": "Timeline",
|
||||
"description": "Render events as an interactive vertical timeline with categories.",
|
||||
"enabled": false,
|
||||
"type": "builtin",
|
||||
"env_keys": [],
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"id": "codeplayground",
|
||||
"name": "Code Playground",
|
||||
"description": "Display code snippets with syntax highlighting and run JavaScript in a sandbox.",
|
||||
"enabled": false,
|
||||
"type": "builtin",
|
||||
"env_keys": [],
|
||||
"timeout": 300
|
||||
}
|
||||
]
|
||||
|
||||
@@ -52,5 +52,71 @@
|
||||
"type": "builtin",
|
||||
"env_keys": [],
|
||||
"bundled": true
|
||||
},
|
||||
{
|
||||
"id": "tableviewer",
|
||||
"name": "tableviewer",
|
||||
"display_name": "Table Viewer",
|
||||
"description": "Display data as interactive sortable and filterable tables.",
|
||||
"enabled": false,
|
||||
"type": "builtin",
|
||||
"env_keys": [],
|
||||
"timeout": 300,
|
||||
"bundled": true
|
||||
},
|
||||
{
|
||||
"id": "diffviewer",
|
||||
"name": "diffviewer",
|
||||
"display_name": "Diff Viewer",
|
||||
"description": "Show code or text changes as a side-by-side diff with syntax highlighting.",
|
||||
"enabled": false,
|
||||
"type": "builtin",
|
||||
"env_keys": [],
|
||||
"timeout": 300,
|
||||
"bundled": true
|
||||
},
|
||||
{
|
||||
"id": "formbuilder",
|
||||
"name": "formbuilder",
|
||||
"display_name": "Form Builder",
|
||||
"description": "Collect structured input from users via dynamically generated forms.",
|
||||
"enabled": false,
|
||||
"type": "builtin",
|
||||
"env_keys": [],
|
||||
"timeout": 300,
|
||||
"bundled": true
|
||||
},
|
||||
{
|
||||
"id": "kanban",
|
||||
"name": "kanban",
|
||||
"display_name": "Kanban Board",
|
||||
"description": "Visualize tasks and progress as a kanban board with priority badges.",
|
||||
"enabled": false,
|
||||
"type": "builtin",
|
||||
"env_keys": [],
|
||||
"timeout": 300,
|
||||
"bundled": true
|
||||
},
|
||||
{
|
||||
"id": "timeline",
|
||||
"name": "timeline",
|
||||
"display_name": "Timeline",
|
||||
"description": "Render events as an interactive vertical timeline with categories.",
|
||||
"enabled": false,
|
||||
"type": "builtin",
|
||||
"env_keys": [],
|
||||
"timeout": 300,
|
||||
"bundled": true
|
||||
},
|
||||
{
|
||||
"id": "codeplayground",
|
||||
"name": "codeplayground",
|
||||
"display_name": "Code Playground",
|
||||
"description": "Display code snippets with syntax highlighting and run JavaScript in a sandbox.",
|
||||
"enabled": false,
|
||||
"type": "builtin",
|
||||
"env_keys": [],
|
||||
"timeout": 300,
|
||||
"bundled": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../../../ui/button';
|
||||
import { SwitchModelModal } from './SwitchModelModal';
|
||||
import LLMProviderDialog from '../../providers/LLMProviderDialog';
|
||||
import type { View } from '../../../../utils/navigationUtils';
|
||||
import { shouldShowPredefinedModels } from '../predefinedModelsUtils';
|
||||
import { defineMessages, useIntl } from '../../../../i18n';
|
||||
@@ -23,6 +24,7 @@ interface ConfigureModelButtonsProps {
|
||||
export default function ModelSettingsButtons({ setView }: ConfigureModelButtonsProps) {
|
||||
const intl = useIntl();
|
||||
const [isAddModelModalOpen, setIsAddModelModalOpen] = useState(false);
|
||||
const [isProviderDialogOpen, setIsProviderDialogOpen] = useState(false);
|
||||
const hasPredefinedModels = shouldShowPredefinedModels();
|
||||
|
||||
return (
|
||||
@@ -35,25 +37,28 @@ export default function ModelSettingsButtons({ setView }: ConfigureModelButtonsP
|
||||
>
|
||||
{intl.formatMessage(i18n.switchModels)}
|
||||
</Button>
|
||||
{isAddModelModalOpen ? (
|
||||
{isAddModelModalOpen && (
|
||||
<SwitchModelModal
|
||||
sessionId={null}
|
||||
setView={setView}
|
||||
onClose={() => setIsAddModelModalOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
{!hasPredefinedModels && (
|
||||
<Button
|
||||
className="flex items-center gap-2 justify-center"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setView('ConfigureProviders');
|
||||
}}
|
||||
onClick={() => setIsProviderDialogOpen(true)}
|
||||
>
|
||||
{intl.formatMessage(i18n.configureProviders)}
|
||||
</Button>
|
||||
)}
|
||||
<LLMProviderDialog
|
||||
open={isProviderDialogOpen}
|
||||
onClose={() => setIsProviderDialogOpen(false)}
|
||||
setView={setView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
import { LLMProviderContent } from './LLMProviderPage';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import type { View } from '../../../utils/navigationUtils';
|
||||
|
||||
interface LLMProviderDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onProviderLaunched?: (model?: string) => void;
|
||||
setView?: (view: View) => void;
|
||||
}
|
||||
|
||||
export default function LLMProviderDialog({
|
||||
open,
|
||||
onClose,
|
||||
onProviderLaunched,
|
||||
setView,
|
||||
}: LLMProviderDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-[680px] p-0 gap-0 max-h-[85vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border-primary shrink-0">
|
||||
<DialogTitle className="text-xl font-medium">LLM Provider</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="px-6 py-5">
|
||||
<LLMProviderContent
|
||||
onProviderLaunched={(model) => {
|
||||
onClose();
|
||||
onProviderLaunched?.(model);
|
||||
}}
|
||||
setView={(view) => {
|
||||
onClose();
|
||||
setView?.(view);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import BackButton from '../../ui/BackButton';
|
||||
import { Button } from '../../ui/button';
|
||||
import { useConfig } from '../../ConfigContext';
|
||||
import { useModelAndProvider } from '../../ModelAndProviderContext';
|
||||
import {
|
||||
ProviderDetails,
|
||||
UpdateCustomProviderRequest,
|
||||
DeclarativeProviderConfig,
|
||||
checkProvider,
|
||||
createCustomProvider,
|
||||
updateCustomProvider,
|
||||
removeCustomProvider,
|
||||
getCustomProvider,
|
||||
} from '../../../api';
|
||||
import { createNavigationHandler } from '../../../utils/navigationUtils';
|
||||
import type { View } from '../../../utils/navigationUtils';
|
||||
import {
|
||||
Plus,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Settings2,
|
||||
ChevronRight,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
import CustomProviderForm from './modal/subcomponents/forms/CustomProviderForm';
|
||||
import { SwitchModelModal } from '../models/subcomponents/SwitchModelModal';
|
||||
import ProviderConfigurationModal from './modal/ProviderConfigurationModal';
|
||||
import ExampleParserForm from './subcomponents/ExampleParserForm';
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// ProviderRow
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
type ConnStatus = 'idle' | 'testing' | 'ok' | 'fail';
|
||||
|
||||
function ProviderRow({
|
||||
provider,
|
||||
isActive,
|
||||
onConfigure,
|
||||
onSelect,
|
||||
}: {
|
||||
provider: ProviderDetails;
|
||||
isActive: boolean;
|
||||
onConfigure: () => void;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const [connStatus, setConnStatus] = useState<ConnStatus>('idle');
|
||||
|
||||
const testConnection = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setConnStatus('testing');
|
||||
try {
|
||||
await checkProvider({ body: { provider: provider.name }, throwOnError: true });
|
||||
setConnStatus('ok');
|
||||
} catch {
|
||||
setConnStatus('fail');
|
||||
}
|
||||
setTimeout(() => setConnStatus('idle'), 3000);
|
||||
},
|
||||
[provider.name]
|
||||
);
|
||||
|
||||
const ConnIcon = () => {
|
||||
if (connStatus === 'testing') return <Loader2 className="h-4 w-4 animate-spin text-blue-500" />;
|
||||
if (connStatus === 'ok') return <Wifi className="h-4 w-4 text-green-500" />;
|
||||
if (connStatus === 'fail') return <WifiOff className="h-4 w-4 text-red-500" />;
|
||||
return <Wifi className="h-4 w-4 text-text-secondary" />;
|
||||
};
|
||||
|
||||
const connLabel =
|
||||
connStatus === 'testing' ? 'Testing…' :
|
||||
connStatus === 'ok' ? 'Connected' :
|
||||
connStatus === 'fail' ? 'Failed' : 'Test';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-4 px-4 py-3 rounded-xl border transition-all cursor-pointer group
|
||||
${isActive
|
||||
? 'border-blue-500/40 bg-blue-500/5 hover:bg-blue-500/10'
|
||||
: 'border-border-primary bg-background-secondary hover:bg-background-tertiary'
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{isActive ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-blue-500" />
|
||||
) : provider.is_configured ? (
|
||||
<Circle className="h-5 w-5 text-text-secondary fill-text-secondary/20" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-text-secondary/40" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-text-primary truncate">
|
||||
{provider.metadata.display_name}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="shrink-0 text-xs px-2 py-0.5 rounded-full bg-blue-500/15 text-blue-500 font-medium">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{!provider.is_configured && (
|
||||
<span className="shrink-0 text-xs px-2 py-0.5 rounded-full bg-text-secondary/10 text-text-secondary">
|
||||
Not configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{provider.metadata.description && (
|
||||
<p className="text-xs text-text-secondary mt-0.5 truncate">
|
||||
{provider.metadata.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={testConnection}
|
||||
disabled={connStatus === 'testing' || !provider.is_configured}
|
||||
title="Test connectivity"
|
||||
className="h-8 px-2 gap-1.5 text-xs"
|
||||
>
|
||||
<ConnIcon />
|
||||
<span className="hidden sm:inline">{connLabel}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onConfigure}
|
||||
title="Configure"
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ChevronRight className="h-4 w-4 text-text-secondary/40 shrink-0 group-hover:text-text-secondary transition-colors" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// AddProviderDialog
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
function AddProviderDialog({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: (providerId: string) => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<'example' | 'manual'>('example');
|
||||
const [prefill, setPrefill] = useState<UpdateCustomProviderRequest | null>(null);
|
||||
|
||||
const handleExampleParsed = useCallback((data: UpdateCustomProviderRequest) => {
|
||||
setPrefill(data);
|
||||
setTab('manual');
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (data: UpdateCustomProviderRequest) => {
|
||||
const result = await createCustomProvider({ body: data, throwOnError: true });
|
||||
const id = result.data?.provider_name;
|
||||
onClose();
|
||||
setPrefill(null);
|
||||
setTab('example');
|
||||
if (id) onCreated(id);
|
||||
},
|
||||
[onClose, onCreated]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onClose();
|
||||
setPrefill(null);
|
||||
setTab('example');
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleCancel}>
|
||||
<DialogContent className="sm:max-w-[620px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Provider</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-1 p-1 rounded-lg bg-background-secondary border border-border-primary mb-2">
|
||||
<button
|
||||
onClick={() => setTab('example')}
|
||||
className={`flex-1 text-sm py-1.5 px-3 rounded-md transition-colors ${
|
||||
tab === 'example'
|
||||
? 'bg-background-primary text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5 inline mr-1.5" />
|
||||
Paste example
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('manual')}
|
||||
className={`flex-1 text-sm py-1.5 px-3 rounded-md transition-colors ${
|
||||
tab === 'manual'
|
||||
? 'bg-background-primary text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5 inline mr-1.5" />
|
||||
Manual setup
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'example' ? (
|
||||
<ExampleParserForm onParsed={handleExampleParsed} onCancel={handleCancel} />
|
||||
) : (
|
||||
<CustomProviderForm
|
||||
initialData={prefill}
|
||||
isEditable={true}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// LLMProviderContent ← pure content, no page chrome
|
||||
// Used both by LLMProviderPage (full-page route)
|
||||
// and LLMProviderDialog (dialog embed)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export interface LLMProviderContentProps {
|
||||
/** Called when the user picks a model — lets the parent close itself */
|
||||
onProviderLaunched?: (model?: string) => void;
|
||||
/** Optional override so the Dialog can supply its own setView */
|
||||
setView?: (view: View) => void;
|
||||
}
|
||||
|
||||
export function LLMProviderContent({ onProviderLaunched, setView: setViewProp }: LLMProviderContentProps) {
|
||||
const { getProviders } = useConfig();
|
||||
const navigateHook = useNavigate();
|
||||
const setView = setViewProp ?? createNavigationHandler(navigateHook);
|
||||
const { getCurrentModelAndProvider } = useModelAndProvider();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [providers, setProviders] = useState<ProviderDetails[]>([]);
|
||||
const [activeProvider, setActiveProvider] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [configuringProvider, setConfiguringProvider] = useState<ProviderDetails | null>(null);
|
||||
const [showCustomEdit, setShowCustomEdit] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<{
|
||||
id: string;
|
||||
config: DeclarativeProviderConfig;
|
||||
isEditable: boolean;
|
||||
providerType: string;
|
||||
} | null>(null);
|
||||
const [isActiveProvider, setIsActiveProvider] = useState(false);
|
||||
const [switchModelTarget, setSwitchModelTarget] = useState<string | null>(null);
|
||||
|
||||
const initialLoadDone = useRef(false);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getProviders(!initialLoadDone.current);
|
||||
if (result) {
|
||||
setProviders(result);
|
||||
initialLoadDone.current = true;
|
||||
}
|
||||
const { provider } = await getCurrentModelAndProvider();
|
||||
setActiveProvider(provider);
|
||||
} catch (err) {
|
||||
console.error('Failed to load providers', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getProviders, getCurrentModelAndProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const result = await getProviders(true);
|
||||
if (result) setProviders(result);
|
||||
const { provider } = await getCurrentModelAndProvider();
|
||||
setActiveProvider(provider);
|
||||
}, [getProviders, getCurrentModelAndProvider]);
|
||||
|
||||
const handleConfigureProvider = useCallback(
|
||||
async (provider: ProviderDetails) => {
|
||||
if (provider.provider_type === 'Custom') {
|
||||
const result = await getCustomProvider({ path: { id: provider.name }, throwOnError: true });
|
||||
if (result.data) {
|
||||
setEditingProvider({
|
||||
id: provider.name,
|
||||
config: result.data.config,
|
||||
isEditable: result.data.is_editable,
|
||||
providerType: provider.provider_type,
|
||||
});
|
||||
try {
|
||||
const pm = await getCurrentModelAndProvider();
|
||||
setIsActiveProvider(provider.name === pm.provider);
|
||||
} catch {
|
||||
setIsActiveProvider(false);
|
||||
}
|
||||
setShowCustomEdit(true);
|
||||
}
|
||||
} else {
|
||||
setConfiguringProvider(provider);
|
||||
}
|
||||
},
|
||||
[getCurrentModelAndProvider]
|
||||
);
|
||||
|
||||
const handleUpdateCustomProvider = useCallback(
|
||||
async (data: UpdateCustomProviderRequest) => {
|
||||
if (!editingProvider) return;
|
||||
await updateCustomProvider({
|
||||
path: { id: editingProvider.id },
|
||||
body: data,
|
||||
throwOnError: true,
|
||||
});
|
||||
const id = editingProvider.id;
|
||||
setShowCustomEdit(false);
|
||||
setEditingProvider(null);
|
||||
await refresh();
|
||||
setSwitchModelTarget(id);
|
||||
},
|
||||
[editingProvider, refresh]
|
||||
);
|
||||
|
||||
const handleDeleteCustomProvider = useCallback(async () => {
|
||||
if (!editingProvider) return;
|
||||
await removeCustomProvider({ path: { id: editingProvider.id }, throwOnError: true });
|
||||
setShowCustomEdit(false);
|
||||
setEditingProvider(null);
|
||||
setIsActiveProvider(false);
|
||||
await refresh();
|
||||
}, [editingProvider, refresh]);
|
||||
|
||||
const sortedProviders = [...providers].sort((a, b) => {
|
||||
if (a.name === activeProvider) return -1;
|
||||
if (b.name === activeProvider) return 1;
|
||||
if (a.is_configured !== b.is_configured) return a.is_configured ? -1 : 1;
|
||||
return a.metadata.display_name.localeCompare(b.metadata.display_name);
|
||||
});
|
||||
|
||||
const editTitle = editingProvider
|
||||
? editingProvider.isEditable ? 'Edit Provider' : 'Configure Provider'
|
||||
: 'Add Provider';
|
||||
|
||||
const editInitialData = editingProvider
|
||||
? {
|
||||
engine: editingProvider.config.engine,
|
||||
display_name: editingProvider.config.display_name,
|
||||
api_url: editingProvider.config.base_url,
|
||||
base_path: editingProvider.config.base_path ?? undefined,
|
||||
api_key: '',
|
||||
models: editingProvider.config.models.map((m) => m.name),
|
||||
supports_streaming: editingProvider.config.supports_streaming ?? true,
|
||||
requires_auth: editingProvider.config.requires_auth ?? true,
|
||||
headers: editingProvider.config.headers ?? undefined,
|
||||
catalog_provider_id: editingProvider.config.catalog_provider_id ?? undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header row: title + add button */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<p className="text-sm text-text-secondary">
|
||||
Active provider is used globally. Click a row to switch.
|
||||
</p>
|
||||
<Button onClick={() => setShowAdd(true)} className="gap-1.5 shrink-0" size="sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Provider
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Provider list */}
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-text-secondary">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Loading providers…
|
||||
</div>
|
||||
) : sortedProviders.length === 0 ? (
|
||||
<div className="text-center py-12 text-text-secondary text-sm">
|
||||
No providers found.{' '}
|
||||
<button className="text-blue-500 hover:underline" onClick={() => setShowAdd(true)}>
|
||||
Add one
|
||||
</button>
|
||||
.
|
||||
</div>
|
||||
) : (
|
||||
sortedProviders.map((provider) => (
|
||||
<ProviderRow
|
||||
key={provider.name}
|
||||
provider={provider}
|
||||
isActive={provider.name === activeProvider}
|
||||
onConfigure={() => handleConfigureProvider(provider)}
|
||||
onSelect={() => {
|
||||
if (provider.is_configured) {
|
||||
setSwitchModelTarget(provider.name);
|
||||
} else {
|
||||
handleConfigureProvider(provider);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add provider dialog */}
|
||||
<AddProviderDialog
|
||||
open={showAdd}
|
||||
onClose={() => setShowAdd(false)}
|
||||
onCreated={async (id) => { await refresh(); setSwitchModelTarget(id); }}
|
||||
/>
|
||||
|
||||
{/* Edit custom provider dialog */}
|
||||
<Dialog
|
||||
open={showCustomEdit}
|
||||
onOpenChange={() => { setShowCustomEdit(false); setEditingProvider(null); }}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CustomProviderForm
|
||||
initialData={editInitialData}
|
||||
isEditable={editingProvider?.isEditable ?? true}
|
||||
onSubmit={handleUpdateCustomProvider}
|
||||
onCancel={() => { setShowCustomEdit(false); setEditingProvider(null); }}
|
||||
onDelete={editingProvider?.providerType === 'Custom' ? handleDeleteCustomProvider : undefined}
|
||||
isActiveProvider={isActiveProvider}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Configure builtin provider */}
|
||||
{configuringProvider && (
|
||||
<ProviderConfigurationModal
|
||||
provider={configuringProvider}
|
||||
onClose={() => { setConfiguringProvider(null); refresh(); }}
|
||||
onConfigured={async (provider) => {
|
||||
setConfiguringProvider(null);
|
||||
await refresh();
|
||||
setSwitchModelTarget(provider.name);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Switch model after provider selection */}
|
||||
{switchModelTarget && (
|
||||
<SwitchModelModal
|
||||
sessionId={null}
|
||||
onClose={() => setSwitchModelTarget(null)}
|
||||
setView={(view) => { setSwitchModelTarget(null); setView(view); }}
|
||||
onModelSelected={onProviderLaunched}
|
||||
initialProvider={switchModelTarget}
|
||||
titleOverride="Choose Model"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// LLMProviderPage ← full-page route wrapper
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export default function LLMProviderPage({
|
||||
onClose,
|
||||
onProviderLaunched,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onProviderLaunched?: (model?: string) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const setView = useCallback(createNavigationHandler(navigate), [navigate]);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col bg-background-primary text-text-primary">
|
||||
<ScrollArea className="flex-1 w-full">
|
||||
<div className="w-full max-w-3xl mx-auto px-6 pt-12 pb-10">
|
||||
<div className="flex items-center mb-1 no-drag">
|
||||
<BackButton onClick={onClose} />
|
||||
</div>
|
||||
<h1 className="text-3xl font-light pt-5 mb-6">LLM Provider</h1>
|
||||
<LLMProviderContent onProviderLaunched={onProviderLaunched} setView={setView} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Button } from '../../../ui/button';
|
||||
import { UpdateCustomProviderRequest } from '../../../../api';
|
||||
import { Sparkles, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface ParsedResult {
|
||||
engine: string;
|
||||
display_name: string;
|
||||
api_url: string;
|
||||
api_key: string;
|
||||
models: string[];
|
||||
requires_auth: boolean;
|
||||
supports_streaming: boolean;
|
||||
}
|
||||
|
||||
function parseExample(raw: string): ParsedResult | null {
|
||||
const text = raw.trim();
|
||||
if (!text) return null;
|
||||
|
||||
const result: ParsedResult = {
|
||||
engine: 'openai_compatible',
|
||||
display_name: '',
|
||||
api_url: '',
|
||||
api_key: '',
|
||||
models: [],
|
||||
requires_auth: true,
|
||||
supports_streaming: true,
|
||||
};
|
||||
|
||||
// ── Extract URL ──
|
||||
// curl URL (first positional arg after 'curl')
|
||||
const curlUrlMatch = text.match(/curl\s+(?:-[^\s]+\s+\S+\s+)*['"](https?:\/\/[^'"]+)['"]/);
|
||||
const bareUrlMatch = text.match(/curl\s+['"]?(https?:\/\/[^\s'"]+)['"]?/);
|
||||
const pythonBaseUrl = text.match(/base_url\s*=\s*['"]([^'"]+)['"]/);
|
||||
const jsBaseUrl = text.match(/baseURL\s*[:=]\s*['"`]([^'"`]+)['"`]/);
|
||||
const genericUrl = text.match(/https?:\/\/[^\s'"`,)]+/);
|
||||
|
||||
const rawUrl =
|
||||
(curlUrlMatch?.[1] || bareUrlMatch?.[1] || pythonBaseUrl?.[1] || jsBaseUrl?.[1] || genericUrl?.[0]) ?? '';
|
||||
|
||||
if (rawUrl) {
|
||||
try {
|
||||
const u = new URL(rawUrl);
|
||||
// Strip known chat-completion paths, keep base
|
||||
const cleanPath = u.pathname
|
||||
.replace(/\/(v\d+\/)?(chat\/completions|completions|messages)\/?$/, '')
|
||||
.replace(/\/$/, '');
|
||||
result.api_url = `${u.protocol}//${u.host}${cleanPath}`;
|
||||
|
||||
// Derive display name from hostname
|
||||
const hostParts = u.hostname.replace(/^api\./, '').split('.');
|
||||
result.display_name = hostParts[0].charAt(0).toUpperCase() + hostParts[0].slice(1);
|
||||
} catch {
|
||||
result.api_url = rawUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extract API key ──
|
||||
const bearerMatch = text.match(/Bearer\s+([A-Za-z0-9\-_.~+/]+=*)/);
|
||||
const pythonApiKey = text.match(/api_key\s*=\s*['"]([^'"]+)['"]/);
|
||||
const jsApiKey = text.match(/apiKey\s*[:=]\s*['"`]([^'"`]+)['"`]/);
|
||||
const envKey = text.match(/process\.env\.([A-Z_]+)/);
|
||||
|
||||
if (bearerMatch) result.api_key = bearerMatch[1];
|
||||
else if (pythonApiKey) result.api_key = pythonApiKey[1];
|
||||
else if (jsApiKey) result.api_key = jsApiKey[1];
|
||||
else if (envKey) result.api_key = `$${envKey[1]}`;
|
||||
|
||||
result.requires_auth = !!result.api_key || text.includes('api_key') || text.includes('Bearer');
|
||||
|
||||
// ── Extract model(s) ──
|
||||
const modelInJson = text.match(/"model"\s*:\s*"([^"]+)"/);
|
||||
const modelPython = text.match(/model\s*=\s*['"]([^'"]+)['"]/g);
|
||||
const modelJs = text.match(/model\s*:\s*['"`]([^'"`]+)['"`]/);
|
||||
|
||||
if (modelInJson) {
|
||||
result.models = [modelInJson[1]];
|
||||
} else if (modelPython) {
|
||||
// deduplicate
|
||||
const names = modelPython
|
||||
.map((m) => m.match(/['"]([^'"]+)['"]/)?.[1])
|
||||
.filter((m): m is string => !!m && !m.includes(' '));
|
||||
result.models = [...new Set(names)];
|
||||
} else if (modelJs) {
|
||||
result.models = [modelJs[1]];
|
||||
}
|
||||
|
||||
// ── Detect engine ──
|
||||
const lower = text.toLowerCase();
|
||||
if (lower.includes('anthropic') || lower.includes('claude')) {
|
||||
result.engine = 'anthropic_compatible';
|
||||
if (!result.display_name) result.display_name = 'Anthropic';
|
||||
} else if (lower.includes('ollama')) {
|
||||
result.engine = 'ollama_compatible';
|
||||
result.requires_auth = false;
|
||||
result.api_key = '';
|
||||
if (!result.display_name) result.display_name = 'Ollama';
|
||||
} else {
|
||||
result.engine = 'openai_compatible';
|
||||
}
|
||||
|
||||
// ── Streaming hint ──
|
||||
if (lower.includes('stream') && (lower.includes('false') || lower.includes('false'))) {
|
||||
result.supports_streaming = false;
|
||||
}
|
||||
|
||||
if (!result.api_url && result.models.length === 0) return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
interface ExampleParserFormProps {
|
||||
onParsed: (data: UpdateCustomProviderRequest) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const PLACEHOLDERS = [
|
||||
`curl https://api.openai.com/v1/chat/completions \\
|
||||
-H "Authorization: Bearer sk-proj-…" \\
|
||||
-d '{"model": "gpt-4o", "messages": [...]}'`,
|
||||
`from openai import OpenAI
|
||||
client = OpenAI(base_url="https://api.deepseek.com", api_key="sk-…")
|
||||
client.chat.completions.create(model="deepseek-chat", messages=[...])`,
|
||||
];
|
||||
|
||||
export default function ExampleParserForm({ onParsed, onCancel }: ExampleParserFormProps) {
|
||||
const [text, setText] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [parsed, setParsed] = useState<ParsedResult | null>(null);
|
||||
const [placeholder] = useState(() => PLACEHOLDERS[Math.floor(Math.random() * PLACEHOLDERS.length)]);
|
||||
|
||||
const handleParse = useCallback(() => {
|
||||
setError('');
|
||||
const result = parseExample(text);
|
||||
if (!result) {
|
||||
setError("Couldn't detect provider details. Check the example format or use Manual setup.");
|
||||
return;
|
||||
}
|
||||
setParsed(result);
|
||||
}, [text]);
|
||||
|
||||
const handleUse = useCallback(() => {
|
||||
if (!parsed) return;
|
||||
onParsed({
|
||||
engine: parsed.engine,
|
||||
display_name: parsed.display_name,
|
||||
api_url: parsed.api_url,
|
||||
api_key: parsed.api_key,
|
||||
models: parsed.models,
|
||||
requires_auth: parsed.requires_auth,
|
||||
supports_streaming: parsed.supports_streaming,
|
||||
headers: {},
|
||||
});
|
||||
}, [parsed, onParsed]);
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-4">
|
||||
<p className="text-sm text-text-secondary">
|
||||
Paste a curl command, Python snippet, or JS code that calls your provider. We'll extract the
|
||||
URL, API key, and model automatically.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => { setText(e.target.value); setParsed(null); setError(''); }}
|
||||
rows={8}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-lg border border-border-primary bg-background-secondary text-text-primary text-sm font-mono px-3 py-2.5 resize-none focus:outline-none focus:ring-2 focus:ring-blue-500/30 placeholder:text-text-secondary/40"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 text-sm text-red-500 bg-red-500/10 px-3 py-2 rounded-lg">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsed && (
|
||||
<div className="rounded-lg border border-green-500/30 bg-green-500/5 px-4 py-3 space-y-1.5">
|
||||
<p className="text-xs font-semibold text-green-600 uppercase tracking-wide mb-2">Detected</p>
|
||||
<Row label="Provider" value={parsed.display_name || '—'} />
|
||||
<Row label="API URL" value={parsed.api_url || '—'} />
|
||||
<Row label="Engine" value={parsed.engine.replace('_compatible', '')} />
|
||||
<Row
|
||||
label="API Key"
|
||||
value={
|
||||
!parsed.requires_auth
|
||||
? 'None (no auth)'
|
||||
: parsed.api_key
|
||||
? parsed.api_key.length > 20
|
||||
? parsed.api_key.slice(0, 8) + '…' + parsed.api_key.slice(-4)
|
||||
: parsed.api_key
|
||||
: 'Not detected (fill in form)'
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="Models"
|
||||
value={parsed.models.length > 0 ? parsed.models.join(', ') : 'Not detected (fill in form)'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
{parsed ? (
|
||||
<Button type="button" onClick={handleUse}>
|
||||
Continue with these settings →
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" onClick={handleParse} disabled={!text.trim()} className="gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Parse
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex gap-2 text-sm">
|
||||
<span className="w-20 text-text-secondary shrink-0">{label}</span>
|
||||
<span className="text-text-primary font-mono text-xs break-all">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user