mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat: enhance MCP server management and system settings
- Added functionality to save MCP server configurations as templates in the MCP Manager. - Implemented new hooks for managing system settings including Chinese response, Windows platform, and Codex CLI enhancements. - Updated API calls to support fetching and toggling system settings. - Introduced UI components for displaying and managing response language settings and system status. - Enhanced error handling and notifications for server deletion and template saving actions. - Updated localization files for new settings and descriptions in English and Chinese.
This commit is contained in:
@@ -56,6 +56,7 @@ interface NavGroupDef {
|
||||
icon: React.ElementType;
|
||||
badge?: number | string;
|
||||
badgeVariant?: 'default' | 'success' | 'warning' | 'info';
|
||||
end?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -110,7 +111,7 @@ const navGroupDefinitions: NavGroupDef[] = [
|
||||
items: [
|
||||
{ path: '/settings/codexlens', labelKey: 'navigation.main.codexlens', icon: Sparkles },
|
||||
{ path: '/api-settings', labelKey: 'navigation.main.apiSettings', icon: Server },
|
||||
{ path: '/settings', labelKey: 'navigation.main.settings', icon: Settings },
|
||||
{ path: '/settings', labelKey: 'navigation.main.settings', icon: Settings, end: true },
|
||||
{ path: '/help', labelKey: 'navigation.main.help', icon: HelpCircle },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -115,6 +115,7 @@ export function CcwToolsMcpCard({
|
||||
mutationFn: installCcwMcp,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
|
||||
onInstall();
|
||||
},
|
||||
});
|
||||
@@ -123,8 +124,12 @@ export function CcwToolsMcpCard({
|
||||
mutationFn: uninstallCcwMcp,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
|
||||
onInstall();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to uninstall CCW MCP:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
createMcpServer,
|
||||
updateMcpServer,
|
||||
fetchMcpServers,
|
||||
saveMcpTemplate,
|
||||
type McpServer,
|
||||
type McpProjectConfigType,
|
||||
} from '@/lib/api';
|
||||
@@ -95,6 +96,7 @@ export function McpServerDialog({
|
||||
const [argsInput, setArgsInput] = useState('');
|
||||
const [envInput, setEnvInput] = useState('');
|
||||
const [configType, setConfigType] = useState<McpConfigType>('mcp-json');
|
||||
const [saveAsTemplate, setSaveAsTemplate] = useState(false);
|
||||
const projectConfigType: McpProjectConfigType = configType === 'claude-json' ? 'claude' : 'mcp';
|
||||
|
||||
// Initialize form from server prop (edit mode)
|
||||
@@ -128,6 +130,7 @@ export function McpServerDialog({
|
||||
setEnvInput('');
|
||||
}
|
||||
setSelectedTemplate('');
|
||||
setSaveAsTemplate(false);
|
||||
setErrors({});
|
||||
}, [server, mode, open]);
|
||||
|
||||
@@ -261,6 +264,23 @@ export function McpServerDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Save as template if checked
|
||||
if (saveAsTemplate) {
|
||||
try {
|
||||
await saveMcpTemplate({
|
||||
name: formData.name,
|
||||
category: 'custom',
|
||||
serverConfig: {
|
||||
command: formData.command,
|
||||
args: formData.args.length > 0 ? formData.args : undefined,
|
||||
env: Object.keys(formData.env).length > 0 ? formData.env : undefined,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Template save failure should not block server creation
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'add') {
|
||||
createMutation.mutate({
|
||||
server: {
|
||||
@@ -501,6 +521,20 @@ export function McpServerDialog({
|
||||
{formatMessage({ id: 'mcp.dialog.form.enabled' })}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Save as Template */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="save-as-template"
|
||||
checked={saveAsTemplate}
|
||||
onChange={(e) => setSaveAsTemplate(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="save-as-template" className="text-sm font-medium text-foreground cursor-pointer">
|
||||
{formatMessage({ id: 'mcp.templates.actions.saveAsTemplate' })}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -58,17 +58,18 @@ import type { McpTemplate } from '@/types/store';
|
||||
export interface McpTemplatesSectionProps {
|
||||
/** Callback when template is installed (opens McpServerDialog) */
|
||||
onInstallTemplate?: (template: McpTemplate) => void;
|
||||
/** Callback when current server should be saved as template */
|
||||
onSaveAsTemplate?: (serverName: string, config: { command: string; args: string[]; env?: Record<string, string> }) => void;
|
||||
/** Callback when saving a new template */
|
||||
onSaveAsTemplate?: (name: string, category: string, description: string, serverConfig: { command: string; args: string[]; env: Record<string, string> }) => void;
|
||||
}
|
||||
|
||||
interface TemplateSaveDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (name: string, category: string, description: string) => void;
|
||||
onSave: (name: string, category: string, description: string, serverConfig: { command: string; args: string[]; env: Record<string, string> }) => void;
|
||||
defaultName?: string;
|
||||
defaultCommand?: string;
|
||||
defaultArgs?: string[];
|
||||
defaultEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface TemplateCardProps {
|
||||
@@ -181,18 +182,26 @@ function TemplateCard({ template, onInstall, onDelete, isInstalling, isDeleting
|
||||
/**
|
||||
* Template Save Dialog - Save current server configuration as template
|
||||
*/
|
||||
function TemplateSaveDialog({
|
||||
export function TemplateSaveDialog({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
defaultName = '',
|
||||
defaultCommand = '',
|
||||
defaultArgs = [],
|
||||
defaultEnv = {},
|
||||
}: TemplateSaveDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const [name, setName] = useState(defaultName);
|
||||
const [category, setCategory] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [errors, setErrors] = useState<{ name?: string }>({});
|
||||
const [command, setCommand] = useState(defaultCommand);
|
||||
const [argsInput, setArgsInput] = useState(defaultArgs.join(', '));
|
||||
const [envInput, setEnvInput] = useState(
|
||||
Object.entries(defaultEnv).map(([k, v]) => `${k}=${v}`).join('\n')
|
||||
);
|
||||
const [errors, setErrors] = useState<{ name?: string; command?: string }>({});
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
@@ -200,25 +209,52 @@ function TemplateSaveDialog({
|
||||
setName(defaultName || '');
|
||||
setCategory('');
|
||||
setDescription('');
|
||||
setCommand(defaultCommand || '');
|
||||
setArgsInput((defaultArgs || []).join(', '));
|
||||
setEnvInput(
|
||||
Object.entries(defaultEnv || {}).map(([k, v]) => `${k}=${v}`).join('\n')
|
||||
);
|
||||
setErrors({});
|
||||
}
|
||||
}, [open, defaultName]);
|
||||
}, [open, defaultName, defaultCommand, defaultArgs, defaultEnv]);
|
||||
|
||||
const handleSave = () => {
|
||||
const newErrors: { name?: string; command?: string } = {};
|
||||
if (!name.trim()) {
|
||||
setErrors({ name: formatMessage({ id: 'mcp.templates.saveDialog.validation.nameRequired' }) });
|
||||
newErrors.name = formatMessage({ id: 'mcp.templates.saveDialog.validation.nameRequired' });
|
||||
}
|
||||
if (!command.trim()) {
|
||||
newErrors.command = formatMessage({ id: 'mcp.dialog.validation.commandRequired' });
|
||||
}
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
onSave(name.trim(), category.trim(), description.trim());
|
||||
setName('');
|
||||
setCategory('');
|
||||
setDescription('');
|
||||
setErrors({});
|
||||
|
||||
const args = argsInput
|
||||
.split(',')
|
||||
.map((a) => a.trim())
|
||||
.filter((a) => a.length > 0);
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of envInput.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && trimmed.includes('=')) {
|
||||
const [key, ...valParts] = trimmed.split('=');
|
||||
if (key) env[key.trim()] = valParts.join('=').trim();
|
||||
}
|
||||
}
|
||||
|
||||
onSave(name.trim(), category.trim(), description.trim(), {
|
||||
command: command.trim(),
|
||||
args,
|
||||
env,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent className="max-w-md max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{formatMessage({ id: 'mcp.templates.saveDialog.title' })}
|
||||
@@ -235,7 +271,7 @@ function TemplateSaveDialog({
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
if (errors.name) setErrors({});
|
||||
if (errors.name) setErrors((prev) => ({ ...prev, name: undefined }));
|
||||
}}
|
||||
placeholder={formatMessage({ id: 'mcp.templates.saveDialog.namePlaceholder' })}
|
||||
error={!!errors.name}
|
||||
@@ -245,6 +281,54 @@ function TemplateSaveDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.command' })}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => {
|
||||
setCommand(e.target.value);
|
||||
if (errors.command) setErrors((prev) => ({ ...prev, command: undefined }));
|
||||
}}
|
||||
placeholder={formatMessage({ id: 'mcp.dialog.form.commandPlaceholder' })}
|
||||
error={!!errors.command}
|
||||
/>
|
||||
{errors.command && (
|
||||
<p className="text-sm text-destructive">{errors.command}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Args */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.args' })}
|
||||
</label>
|
||||
<Input
|
||||
value={argsInput}
|
||||
onChange={(e) => setArgsInput(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.dialog.form.argsPlaceholder' })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.argsHint' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.env' })}
|
||||
</label>
|
||||
<textarea
|
||||
value={envInput}
|
||||
onChange={(e) => setEnvInput(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.dialog.form.envPlaceholder' })}
|
||||
className="flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
@@ -273,7 +357,7 @@ function TemplateSaveDialog({
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.templates.saveDialog.descriptionPlaceholder' })}
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,8 +458,8 @@ export function McpTemplatesSection({ onInstallTemplate, onSaveAsTemplate }: Mcp
|
||||
}
|
||||
}, [templateToDelete, deleteMutation]);
|
||||
|
||||
const handleSaveTemplate = useCallback((_name: string, _category: string, _description: string) => {
|
||||
onSaveAsTemplate?.(_name, { command: '', args: [] });
|
||||
const handleSaveTemplate = useCallback((_name: string, _category: string, _description: string, _serverConfig: { command: string; args: string[]; env: Record<string, string> }) => {
|
||||
onSaveAsTemplate?.(_name, _category, _description, _serverConfig);
|
||||
setSaveDialogOpen(false);
|
||||
}, [onSaveAsTemplate]);
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface NavItem {
|
||||
icon: React.ElementType;
|
||||
badge?: number | string;
|
||||
badgeVariant?: 'default' | 'success' | 'warning' | 'info';
|
||||
/** When true, only exact path match activates this item (no prefix matching) */
|
||||
end?: boolean;
|
||||
}
|
||||
|
||||
export interface NavGroupProps {
|
||||
@@ -54,10 +56,10 @@ export function NavGroup({
|
||||
{items.map((item) => {
|
||||
const ItemIcon = item.icon;
|
||||
const [basePath] = item.path.split('?');
|
||||
// More precise matching: exact match or basePath followed by '/' to avoid parent/child conflicts
|
||||
const isActive =
|
||||
location.pathname === basePath ||
|
||||
(basePath !== '/' && location.pathname.startsWith(basePath + '/'));
|
||||
const isActive = item.end
|
||||
? location.pathname === basePath
|
||||
: location.pathname === basePath ||
|
||||
(basePath !== '/' && location.pathname.startsWith(basePath + '/'));
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
@@ -94,10 +96,10 @@ export function NavGroup({
|
||||
{items.map((item) => {
|
||||
const ItemIcon = item.icon;
|
||||
const [basePath, searchParams] = item.path.split('?');
|
||||
// More precise matching: exact match or basePath followed by '/' to avoid parent/child conflicts
|
||||
const isActive =
|
||||
location.pathname === basePath ||
|
||||
(basePath !== '/' && location.pathname.startsWith(basePath + '/'));
|
||||
const isActive = item.end
|
||||
? location.pathname === basePath
|
||||
: location.pathname === basePath ||
|
||||
(basePath !== '/' && location.pathname.startsWith(basePath + '/'));
|
||||
const isQueryParamActive =
|
||||
searchParams && location.search.includes(searchParams);
|
||||
|
||||
|
||||
406
ccw/frontend/src/components/shared/SkillCreateDialog.tsx
Normal file
406
ccw/frontend/src/components/shared/SkillCreateDialog.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
// ========================================
|
||||
// Skill Create Dialog Component
|
||||
// ========================================
|
||||
// Modal dialog for creating/importing skills with two modes:
|
||||
// - Import: import existing skill folder
|
||||
// - CLI Generate: AI-generated skill from description
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Folder,
|
||||
User,
|
||||
FolderInput,
|
||||
Sparkles,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { validateSkillImport, createSkill } from '@/lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SkillCreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
type CreateMode = 'import' | 'cli-generate';
|
||||
type SkillLocation = 'project' | 'user';
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors?: string[];
|
||||
skillInfo?: { name: string; description: string; version?: string; supportingFiles?: string[] };
|
||||
}
|
||||
|
||||
export function SkillCreateDialog({ open, onOpenChange, onCreated }: SkillCreateDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const [mode, setMode] = useState<CreateMode>('import');
|
||||
const [location, setLocation] = useState<SkillLocation>('project');
|
||||
|
||||
// Import mode state
|
||||
const [sourcePath, setSourcePath] = useState('');
|
||||
const [customName, setCustomName] = useState('');
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
// CLI Generate mode state
|
||||
const [skillName, setSkillName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setMode('import');
|
||||
setLocation('project');
|
||||
setSourcePath('');
|
||||
setCustomName('');
|
||||
setValidationResult(null);
|
||||
setIsValidating(false);
|
||||
setSkillName('');
|
||||
setDescription('');
|
||||
setIsCreating(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
resetState();
|
||||
}
|
||||
onOpenChange(open);
|
||||
}, [onOpenChange, resetState]);
|
||||
|
||||
const handleValidate = useCallback(async () => {
|
||||
if (!sourcePath.trim()) return;
|
||||
|
||||
setIsValidating(true);
|
||||
setValidationResult(null);
|
||||
|
||||
try {
|
||||
const result = await validateSkillImport(sourcePath.trim());
|
||||
setValidationResult(result);
|
||||
} catch (err) {
|
||||
setValidationResult({
|
||||
valid: false,
|
||||
errors: [err instanceof Error ? err.message : String(err)],
|
||||
});
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [sourcePath]);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (mode === 'import') {
|
||||
if (!sourcePath.trim()) return;
|
||||
if (!validationResult?.valid) return;
|
||||
} else {
|
||||
if (!skillName.trim()) return;
|
||||
if (!description.trim()) return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
|
||||
try {
|
||||
await createSkill({
|
||||
mode,
|
||||
location,
|
||||
sourcePath: mode === 'import' ? sourcePath.trim() : undefined,
|
||||
skillName: mode === 'import' ? (customName.trim() || undefined) : skillName.trim(),
|
||||
description: mode === 'cli-generate' ? description.trim() : undefined,
|
||||
generationType: mode === 'cli-generate' ? 'description' : undefined,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
handleOpenChange(false);
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
console.error('Failed to create skill:', err);
|
||||
if (mode === 'import') {
|
||||
setValidationResult({
|
||||
valid: false,
|
||||
errors: [err instanceof Error ? err.message : formatMessage({ id: 'skills.create.createError' })],
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [mode, location, sourcePath, customName, skillName, description, validationResult, projectPath, handleOpenChange, onCreated, formatMessage]);
|
||||
|
||||
const canCreate = mode === 'import'
|
||||
? sourcePath.trim() && validationResult?.valid && !isCreating
|
||||
: skillName.trim() && description.trim() && !isCreating;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formatMessage({ id: 'skills.create.title' })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatMessage({ id: 'skills.description' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
{/* Location Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'skills.create.location' })}</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'px-4 py-3 text-left border-2 rounded-lg transition-all',
|
||||
location === 'project'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
onClick={() => setLocation('project')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="w-5 h-5" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{formatMessage({ id: 'skills.create.locationProject' })}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'skills.create.locationProjectHint' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'px-4 py-3 text-left border-2 rounded-lg transition-all',
|
||||
location === 'user'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
onClick={() => setLocation('user')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-5 h-5" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{formatMessage({ id: 'skills.create.locationUser' })}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'skills.create.locationUserHint' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'skills.create.mode' })}</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'px-4 py-3 text-left border-2 rounded-lg transition-all',
|
||||
mode === 'import'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
onClick={() => setMode('import')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderInput className="w-5 h-5" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{formatMessage({ id: 'skills.create.modeImport' })}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'skills.create.modeImportHint' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'px-4 py-3 text-left border-2 rounded-lg transition-all',
|
||||
mode === 'cli-generate'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
onClick={() => setMode('cli-generate')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{formatMessage({ id: 'skills.create.modeGenerate' })}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'skills.create.modeGenerateHint' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Mode Content */}
|
||||
{mode === 'import' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sourcePath">{formatMessage({ id: 'skills.create.sourcePath' })}</Label>
|
||||
<Input
|
||||
id="sourcePath"
|
||||
value={sourcePath}
|
||||
onChange={(e) => {
|
||||
setSourcePath(e.target.value);
|
||||
setValidationResult(null);
|
||||
}}
|
||||
placeholder={formatMessage({ id: 'skills.create.sourcePathPlaceholder' })}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'skills.create.sourcePathHint' })}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="customName">
|
||||
{formatMessage({ id: 'skills.create.customName' })}
|
||||
<span className="text-muted-foreground ml-1">({formatMessage({ id: 'skills.create.customNameHint' })})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="customName"
|
||||
value={customName}
|
||||
onChange={(e) => setCustomName(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'skills.create.customNamePlaceholder' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Validation Result */}
|
||||
{isValidating && (
|
||||
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">{formatMessage({ id: 'skills.create.validating' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{validationResult && !isValidating && (
|
||||
validationResult.valid ? (
|
||||
<div className="p-4 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-600 mb-2">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<span className="font-medium">{formatMessage({ id: 'skills.create.validSkill' })}</span>
|
||||
</div>
|
||||
{validationResult.skillInfo && (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'skills.card.description' })}: </span>
|
||||
<span>{validationResult.skillInfo.name}</span>
|
||||
</div>
|
||||
{validationResult.skillInfo.description && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'skills.card.description' })}: </span>
|
||||
<span>{validationResult.skillInfo.description}</span>
|
||||
</div>
|
||||
)}
|
||||
{validationResult.skillInfo.version && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'skills.card.version' })}: </span>
|
||||
<span>{validationResult.skillInfo.version}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-destructive mb-2">
|
||||
<XCircle className="w-5 h-5" />
|
||||
<span className="font-medium">{formatMessage({ id: 'skills.create.invalidSkill' })}</span>
|
||||
</div>
|
||||
{validationResult.errors && (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationResult.errors.map((error, i) => (
|
||||
<li key={i} className="text-destructive">{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CLI Generate Mode Content */}
|
||||
{mode === 'cli-generate' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skillName">
|
||||
{formatMessage({ id: 'skills.create.skillName' })} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="skillName"
|
||||
value={skillName}
|
||||
onChange={(e) => setSkillName(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'skills.create.skillNamePlaceholder' })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'skills.create.skillNameHint' })}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">
|
||||
{formatMessage({ id: 'skills.create.descriptionLabel' })} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'skills.create.descriptionPlaceholder' })}
|
||||
rows={6}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'skills.create.descriptionHint' })}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="w-4 h-4 text-blue-600 mt-0.5" />
|
||||
<div className="text-sm text-blue-600">
|
||||
<p className="font-medium">{formatMessage({ id: 'skills.create.generateInfo' })}</p>
|
||||
<p className="text-xs mt-1">{formatMessage({ id: 'skills.create.generateTimeHint' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isCreating}>
|
||||
{formatMessage({ id: 'skills.actions.cancel' })}
|
||||
</Button>
|
||||
{mode === 'import' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleValidate}
|
||||
disabled={!sourcePath.trim() || isValidating || isCreating}
|
||||
>
|
||||
{isValidating && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{formatMessage({ id: 'skills.create.validate' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!canCreate}
|
||||
>
|
||||
{isCreating && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{isCreating
|
||||
? formatMessage({ id: 'skills.create.creating' })
|
||||
: mode === 'import'
|
||||
? formatMessage({ id: 'skills.create.import' })
|
||||
: formatMessage({ id: 'skills.create.generate' })
|
||||
}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillCreateDialog;
|
||||
@@ -19,6 +19,9 @@ export type { SkillCardProps } from './SkillCard';
|
||||
export { SkillDetailPanel } from './SkillDetailPanel';
|
||||
export type { SkillDetailPanelProps } from './SkillDetailPanel';
|
||||
|
||||
export { SkillCreateDialog } from './SkillCreateDialog';
|
||||
export type { SkillCreateDialogProps } from './SkillCreateDialog';
|
||||
|
||||
export { StatCard, StatCardSkeleton } from './StatCard';
|
||||
export type { StatCardProps } from './StatCard';
|
||||
|
||||
|
||||
@@ -196,6 +196,27 @@ export type {
|
||||
UseRulesReturn,
|
||||
} from './useCli';
|
||||
|
||||
// ========== System Settings ==========
|
||||
export {
|
||||
useChineseResponseStatus,
|
||||
useToggleChineseResponse,
|
||||
useWindowsPlatformStatus,
|
||||
useToggleWindowsPlatform,
|
||||
useCodexCliEnhancementStatus,
|
||||
useToggleCodexCliEnhancement,
|
||||
useRefreshCodexCliEnhancement,
|
||||
useCcwInstallStatus,
|
||||
useCliToolStatus,
|
||||
systemSettingsKeys,
|
||||
} from './useSystemSettings';
|
||||
export type {
|
||||
UseChineseResponseStatusReturn,
|
||||
UseWindowsPlatformStatusReturn,
|
||||
UseCodexCliEnhancementStatusReturn,
|
||||
UseCcwInstallStatusReturn,
|
||||
UseCliToolStatusReturn,
|
||||
} from './useSystemSettings';
|
||||
|
||||
// ========== CLI Execution ==========
|
||||
export {
|
||||
useCliExecutionDetail,
|
||||
|
||||
@@ -189,6 +189,7 @@ export function useDeleteMcpServer(): UseDeleteMcpServerReturn {
|
||||
deleteMcpServer(serverName, scope, { projectPath: projectPath ?? undefined }),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
238
ccw/frontend/src/hooks/useSystemSettings.ts
Normal file
238
ccw/frontend/src/hooks/useSystemSettings.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
// ========================================
|
||||
// useSystemSettings Hook
|
||||
// ========================================
|
||||
// TanStack Query hooks for system settings (language, install status, tool status)
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchChineseResponseStatus,
|
||||
toggleChineseResponse,
|
||||
fetchWindowsPlatformStatus,
|
||||
toggleWindowsPlatform,
|
||||
fetchCodexCliEnhancementStatus,
|
||||
toggleCodexCliEnhancement,
|
||||
refreshCodexCliEnhancement,
|
||||
fetchAggregatedStatus,
|
||||
fetchCliToolStatus,
|
||||
type ChineseResponseStatus,
|
||||
type WindowsPlatformStatus,
|
||||
type CodexCliEnhancementStatus,
|
||||
type CcwInstallStatus,
|
||||
} from '../lib/api';
|
||||
|
||||
// Query key factory
|
||||
export const systemSettingsKeys = {
|
||||
all: ['systemSettings'] as const,
|
||||
chineseResponse: () => [...systemSettingsKeys.all, 'chineseResponse'] as const,
|
||||
windowsPlatform: () => [...systemSettingsKeys.all, 'windowsPlatform'] as const,
|
||||
codexCliEnhancement: () => [...systemSettingsKeys.all, 'codexCliEnhancement'] as const,
|
||||
aggregatedStatus: () => [...systemSettingsKeys.all, 'aggregatedStatus'] as const,
|
||||
cliToolStatus: () => [...systemSettingsKeys.all, 'cliToolStatus'] as const,
|
||||
};
|
||||
|
||||
const STALE_TIME = 60 * 1000; // 1 minute
|
||||
|
||||
// ========================================
|
||||
// Chinese Response Hooks
|
||||
// ========================================
|
||||
|
||||
export interface UseChineseResponseStatusReturn {
|
||||
data: ChineseResponseStatus | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useChineseResponseStatus(): UseChineseResponseStatusReturn {
|
||||
const query = useQuery({
|
||||
queryKey: systemSettingsKeys.chineseResponse(),
|
||||
queryFn: fetchChineseResponseStatus,
|
||||
staleTime: STALE_TIME,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
return {
|
||||
data: query.data,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: () => { query.refetch(); },
|
||||
};
|
||||
}
|
||||
|
||||
export function useToggleChineseResponse() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ enabled, target }: { enabled: boolean; target: 'claude' | 'codex' }) =>
|
||||
toggleChineseResponse(enabled, target),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: systemSettingsKeys.chineseResponse() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
toggle: (enabled: boolean, target: 'claude' | 'codex') =>
|
||||
mutation.mutateAsync({ enabled, target }),
|
||||
isPending: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Windows Platform Hooks
|
||||
// ========================================
|
||||
|
||||
export interface UseWindowsPlatformStatusReturn {
|
||||
data: WindowsPlatformStatus | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useWindowsPlatformStatus(): UseWindowsPlatformStatusReturn {
|
||||
const query = useQuery({
|
||||
queryKey: systemSettingsKeys.windowsPlatform(),
|
||||
queryFn: fetchWindowsPlatformStatus,
|
||||
staleTime: STALE_TIME,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
return {
|
||||
data: query.data,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: () => { query.refetch(); },
|
||||
};
|
||||
}
|
||||
|
||||
export function useToggleWindowsPlatform() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => toggleWindowsPlatform(enabled),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: systemSettingsKeys.windowsPlatform() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
toggle: mutation.mutateAsync,
|
||||
isPending: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Codex CLI Enhancement Hooks
|
||||
// ========================================
|
||||
|
||||
export interface UseCodexCliEnhancementStatusReturn {
|
||||
data: CodexCliEnhancementStatus | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useCodexCliEnhancementStatus(): UseCodexCliEnhancementStatusReturn {
|
||||
const query = useQuery({
|
||||
queryKey: systemSettingsKeys.codexCliEnhancement(),
|
||||
queryFn: fetchCodexCliEnhancementStatus,
|
||||
staleTime: STALE_TIME,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
return {
|
||||
data: query.data,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: () => { query.refetch(); },
|
||||
};
|
||||
}
|
||||
|
||||
export function useToggleCodexCliEnhancement() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => toggleCodexCliEnhancement(enabled),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: systemSettingsKeys.codexCliEnhancement() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
toggle: mutation.mutateAsync,
|
||||
isPending: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useRefreshCodexCliEnhancement() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => refreshCodexCliEnhancement(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: systemSettingsKeys.codexCliEnhancement() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
refresh: mutation.mutateAsync,
|
||||
isPending: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Aggregated Status / CCW Install Hooks
|
||||
// ========================================
|
||||
|
||||
export interface UseCcwInstallStatusReturn {
|
||||
data: CcwInstallStatus | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useCcwInstallStatus(): UseCcwInstallStatusReturn {
|
||||
const query = useQuery({
|
||||
queryKey: systemSettingsKeys.aggregatedStatus(),
|
||||
queryFn: fetchAggregatedStatus,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
return {
|
||||
data: query.data?.ccwInstall,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: () => { query.refetch(); },
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// CLI Tool Status Hooks
|
||||
// ========================================
|
||||
|
||||
export interface UseCliToolStatusReturn {
|
||||
data: Record<string, { available: boolean; path?: string; version?: string }> | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useCliToolStatus(): UseCliToolStatusReturn {
|
||||
const query = useQuery({
|
||||
queryKey: systemSettingsKeys.cliToolStatus(),
|
||||
queryFn: fetchCliToolStatus,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
return {
|
||||
data: query.data,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: () => { query.refetch(); },
|
||||
};
|
||||
}
|
||||
@@ -993,22 +993,36 @@ export interface SkillsResponse {
|
||||
* @param projectPath - Optional project path to filter data by workspace
|
||||
*/
|
||||
export async function fetchSkills(projectPath?: string): Promise<SkillsResponse> {
|
||||
// Response type from backend when includeDisabled=true
|
||||
interface ExtendedSkillsResponse {
|
||||
skills?: Skill[];
|
||||
projectSkills?: Skill[];
|
||||
userSkills?: Skill[];
|
||||
disabledProjectSkills?: Skill[];
|
||||
disabledUserSkills?: Skill[];
|
||||
}
|
||||
|
||||
// Helper to add location and enabled status to skills
|
||||
// Backend only returns enabled skills (with SKILL.md), so we set enabled: true
|
||||
const addMetadata = (skills: Skill[], location: 'project' | 'user'): Skill[] =>
|
||||
const addEnabledMetadata = (skills: Skill[], location: 'project' | 'user'): Skill[] =>
|
||||
skills.map(skill => ({ ...skill, location, enabled: true }));
|
||||
|
||||
const addDisabledMetadata = (skills: Skill[], location: 'project' | 'user'): Skill[] =>
|
||||
skills.map(skill => ({ ...skill, location, enabled: false }));
|
||||
|
||||
const buildSkillsList = (data: ExtendedSkillsResponse): Skill[] => {
|
||||
const projectSkillsEnabled = addEnabledMetadata(data.projectSkills ?? [], 'project');
|
||||
const userSkillsEnabled = addEnabledMetadata(data.userSkills ?? [], 'user');
|
||||
const projectSkillsDisabled = addDisabledMetadata(data.disabledProjectSkills ?? [], 'project');
|
||||
const userSkillsDisabled = addDisabledMetadata(data.disabledUserSkills ?? [], 'user');
|
||||
return [...projectSkillsEnabled, ...userSkillsEnabled, ...projectSkillsDisabled, ...userSkillsDisabled];
|
||||
};
|
||||
|
||||
// Try with project path first, fall back to global on 403/404
|
||||
if (projectPath) {
|
||||
try {
|
||||
const url = `/api/skills?path=${encodeURIComponent(projectPath)}`;
|
||||
const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>(url);
|
||||
const projectSkillsWithMetadata = addMetadata(data.projectSkills ?? [], 'project');
|
||||
const userSkillsWithMetadata = addMetadata(data.userSkills ?? [], 'user');
|
||||
const allSkills = [...projectSkillsWithMetadata, ...userSkillsWithMetadata];
|
||||
return {
|
||||
skills: data.skills ?? allSkills,
|
||||
};
|
||||
const url = `/api/skills?path=${encodeURIComponent(projectPath)}&includeDisabled=true`;
|
||||
const data = await fetchApi<ExtendedSkillsResponse>(url);
|
||||
return { skills: buildSkillsList(data) };
|
||||
} catch (error: unknown) {
|
||||
const apiError = error as ApiError;
|
||||
if (apiError.status === 403 || apiError.status === 404) {
|
||||
@@ -1020,13 +1034,8 @@ export async function fetchSkills(projectPath?: string): Promise<SkillsResponse>
|
||||
}
|
||||
}
|
||||
// Fallback: fetch global skills
|
||||
const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>('/api/skills');
|
||||
const projectSkillsWithMetadata = addMetadata(data.projectSkills ?? [], 'project');
|
||||
const userSkillsWithMetadata = addMetadata(data.userSkills ?? [], 'user');
|
||||
const allSkills = [...projectSkillsWithMetadata, ...userSkillsWithMetadata];
|
||||
return {
|
||||
skills: data.skills ?? allSkills,
|
||||
};
|
||||
const data = await fetchApi<ExtendedSkillsResponse>('/api/skills?includeDisabled=true');
|
||||
return { skills: buildSkillsList(data) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1074,6 +1083,38 @@ export async function fetchSkillDetail(
|
||||
return fetchApi<{ skill: Skill }>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a skill folder for import
|
||||
*/
|
||||
export async function validateSkillImport(sourcePath: string): Promise<{
|
||||
valid: boolean;
|
||||
errors?: string[];
|
||||
skillInfo?: { name: string; description: string; version?: string; supportingFiles?: string[] };
|
||||
}> {
|
||||
return fetchApi('/api/skills/validate-import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sourcePath }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/import a skill
|
||||
*/
|
||||
export async function createSkill(params: {
|
||||
mode: 'import' | 'cli-generate';
|
||||
location: 'project' | 'user';
|
||||
sourcePath?: string;
|
||||
skillName?: string;
|
||||
description?: string;
|
||||
generationType?: 'description' | 'template';
|
||||
projectPath?: string;
|
||||
}): Promise<{ skillName: string; path: string }> {
|
||||
return fetchApi('/api/skills/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Commands API ==========
|
||||
|
||||
export interface Command {
|
||||
@@ -5062,3 +5103,142 @@ export async function fetchExecutionLogs(
|
||||
const queryString = params.toString();
|
||||
return fetchApi(`/api/orchestrator/executions/${encodeURIComponent(execId)}/logs${queryString ? `?${queryString}` : ''}`);
|
||||
}
|
||||
|
||||
// ========== System Settings API ==========
|
||||
|
||||
/**
|
||||
* Chinese response setting status
|
||||
*/
|
||||
export interface ChineseResponseStatus {
|
||||
enabled: boolean;
|
||||
claudeEnabled: boolean;
|
||||
codexEnabled: boolean;
|
||||
codexNeedsMigration: boolean;
|
||||
guidelinesPath: string;
|
||||
guidelinesExists: boolean;
|
||||
userClaudeMdExists: boolean;
|
||||
userCodexAgentsExists: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Chinese response setting status
|
||||
*/
|
||||
export async function fetchChineseResponseStatus(): Promise<ChineseResponseStatus> {
|
||||
return fetchApi('/api/language/chinese-response');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle Chinese response setting
|
||||
*/
|
||||
export async function toggleChineseResponse(
|
||||
enabled: boolean,
|
||||
target: 'claude' | 'codex' = 'claude'
|
||||
): Promise<{ success: boolean; enabled: boolean; target: string }> {
|
||||
return fetchApi('/api/language/chinese-response', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enabled, target }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows platform setting status
|
||||
*/
|
||||
export interface WindowsPlatformStatus {
|
||||
enabled: boolean;
|
||||
guidelinesPath: string;
|
||||
guidelinesExists: boolean;
|
||||
userClaudeMdExists: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Windows platform setting status
|
||||
*/
|
||||
export async function fetchWindowsPlatformStatus(): Promise<WindowsPlatformStatus> {
|
||||
return fetchApi('/api/language/windows-platform');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle Windows platform setting
|
||||
*/
|
||||
export async function toggleWindowsPlatform(
|
||||
enabled: boolean
|
||||
): Promise<{ success: boolean; enabled: boolean }> {
|
||||
return fetchApi('/api/language/windows-platform', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex CLI Enhancement setting status
|
||||
*/
|
||||
export interface CodexCliEnhancementStatus {
|
||||
enabled: boolean;
|
||||
guidelinesPath: string;
|
||||
guidelinesExists: boolean;
|
||||
userCodexAgentsExists: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Codex CLI Enhancement setting status
|
||||
*/
|
||||
export async function fetchCodexCliEnhancementStatus(): Promise<CodexCliEnhancementStatus> {
|
||||
return fetchApi('/api/language/codex-cli-enhancement');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle Codex CLI Enhancement setting
|
||||
*/
|
||||
export async function toggleCodexCliEnhancement(
|
||||
enabled: boolean
|
||||
): Promise<{ success: boolean; enabled: boolean }> {
|
||||
return fetchApi('/api/language/codex-cli-enhancement', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Codex CLI Enhancement content
|
||||
*/
|
||||
export async function refreshCodexCliEnhancement(): Promise<{ success: boolean; refreshed: boolean }> {
|
||||
return fetchApi('/api/language/codex-cli-enhancement', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'refresh' }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* CCW Install status
|
||||
*/
|
||||
export interface CcwInstallStatus {
|
||||
installed: boolean;
|
||||
workflowsInstalled: boolean;
|
||||
missingFiles: string[];
|
||||
installPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated status response
|
||||
*/
|
||||
export interface AggregatedStatus {
|
||||
cli: Record<string, { available: boolean; path?: string; version?: string }>;
|
||||
codexLens: { ready: boolean };
|
||||
semantic: { available: boolean; backend: string | null };
|
||||
ccwInstall: CcwInstallStatus;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch aggregated system status (includes CCW install status)
|
||||
*/
|
||||
export async function fetchAggregatedStatus(): Promise<AggregatedStatus> {
|
||||
return fetchApi('/api/status/all');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CLI tool availability status
|
||||
*/
|
||||
export async function fetchCliToolStatus(): Promise<Record<string, { available: boolean; path?: string; version?: string }>> {
|
||||
return fetchApi('/api/cli/status');
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"cliTools": "CLI Tools",
|
||||
"display": "Display Settings",
|
||||
"language": "Language",
|
||||
"responseLanguage": "Response Language",
|
||||
"systemStatus": "System Status",
|
||||
"hooks": "Git Hooks",
|
||||
"rules": "Rules",
|
||||
"about": "About"
|
||||
@@ -48,6 +50,35 @@
|
||||
"displayLanguage": "Display Language",
|
||||
"chooseLanguage": "Choose your preferred language for the interface"
|
||||
},
|
||||
"responseLanguage": {
|
||||
"title": "Response Language Settings",
|
||||
"chineseClaude": "Chinese Response",
|
||||
"chineseClaudeDesc": "Enable Chinese response guidelines in ~/.claude/CLAUDE.md",
|
||||
"chineseCodex": "Chinese Response",
|
||||
"chineseCodexDesc": "Enable Chinese response guidelines in ~/.codex/AGENTS.md",
|
||||
"windowsPlatform": "Windows Platform",
|
||||
"windowsPlatformDesc": "Enable Windows path format conventions in global CLAUDE.md",
|
||||
"cliEnhancement": "CLI Enhancement",
|
||||
"cliEnhancementDesc": "Enable multi-CLI tool invocation for Codex",
|
||||
"cliEnhancementHint": "After config changes, click refresh to update content",
|
||||
"refreshConfig": "Refresh Config",
|
||||
"migrationWarning": "Old format detected, please disable and re-enable to migrate",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"systemStatus": {
|
||||
"title": "System Status",
|
||||
"ccwInstall": "CCW Installation",
|
||||
"installed": "Installed",
|
||||
"incomplete": "Incomplete",
|
||||
"notInstalled": "Not Installed",
|
||||
"missingFiles": "Missing Files",
|
||||
"runToFix": "Run to fix",
|
||||
"toolStatus": "Tool Availability",
|
||||
"available": "Available",
|
||||
"unavailable": "Unavailable",
|
||||
"checking": "Checking..."
|
||||
},
|
||||
"dataRefresh": {
|
||||
"title": "Data Refresh",
|
||||
"autoRefresh": "Auto Refresh",
|
||||
|
||||
@@ -65,5 +65,45 @@
|
||||
"emptyState": {
|
||||
"title": "No Skills Found",
|
||||
"message": "No skills match your current filter."
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Skill",
|
||||
"location": "Location",
|
||||
"locationProject": "Project Skills",
|
||||
"locationProjectHint": ".claude/skills/",
|
||||
"locationUser": "Global Skills",
|
||||
"locationUserHint": "~/.claude/skills/",
|
||||
"mode": "Creation Mode",
|
||||
"modeImport": "Import Folder",
|
||||
"modeImportHint": "Import skill from existing folder",
|
||||
"modeGenerate": "AI Generate",
|
||||
"modeGenerateHint": "Generate skill using AI",
|
||||
"sourcePath": "Source Folder Path",
|
||||
"sourcePathPlaceholder": "Enter absolute path to skill folder",
|
||||
"sourcePathHint": "Folder must contain a SKILL.md file",
|
||||
"customName": "Custom Name",
|
||||
"customNamePlaceholder": "Leave empty to use original name",
|
||||
"customNameHint": "Optional, overrides default skill name",
|
||||
"skillName": "Skill Name",
|
||||
"skillNamePlaceholder": "Enter skill name",
|
||||
"skillNameHint": "Used as the skill folder name",
|
||||
"descriptionLabel": "Skill Description",
|
||||
"descriptionPlaceholder": "Describe what this skill should do...",
|
||||
"descriptionHint": "AI will generate skill content based on this description",
|
||||
"generateInfo": "AI will use CLI tools to generate the skill",
|
||||
"generateTimeHint": "Generation may take some time",
|
||||
"validate": "Validate",
|
||||
"import": "Import",
|
||||
"generate": "Generate",
|
||||
"validating": "Validating...",
|
||||
"validSkill": "Validation passed",
|
||||
"invalidSkill": "Validation failed",
|
||||
"creating": "Creating...",
|
||||
"created": "Skill \"{name}\" created successfully",
|
||||
"createError": "Failed to create skill",
|
||||
"sourcePathRequired": "Please enter source folder path",
|
||||
"skillNameRequired": "Please enter skill name",
|
||||
"descriptionRequired": "Please enter skill description",
|
||||
"validateFirst": "Please validate the skill folder first"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"cliTools": "CLI 工具",
|
||||
"display": "显示设置",
|
||||
"language": "语言",
|
||||
"responseLanguage": "回复语言设置",
|
||||
"systemStatus": "系统状态",
|
||||
"hooks": "Git 钩子",
|
||||
"rules": "规则",
|
||||
"about": "关于"
|
||||
@@ -48,6 +50,35 @@
|
||||
"displayLanguage": "显示语言",
|
||||
"chooseLanguage": "选择界面的首选语言"
|
||||
},
|
||||
"responseLanguage": {
|
||||
"title": "回复语言设置",
|
||||
"chineseClaude": "中文回复",
|
||||
"chineseClaudeDesc": "在 ~/.claude/CLAUDE.md 中启用中文回复准则",
|
||||
"chineseCodex": "中文回复",
|
||||
"chineseCodexDesc": "在 ~/.codex/AGENTS.md 中启用中文回复准则",
|
||||
"windowsPlatform": "Windows 平台规范",
|
||||
"windowsPlatformDesc": "在全局 CLAUDE.md 中启用 Windows 路径格式规范",
|
||||
"cliEnhancement": "CLI 调用增强",
|
||||
"cliEnhancementDesc": "为 Codex 启用多 CLI 工具调用功能",
|
||||
"cliEnhancementHint": "配置文件变更后,点击刷新按钮更新内容",
|
||||
"refreshConfig": "刷新配置",
|
||||
"migrationWarning": "检测到旧格式,请关闭后重新启用以迁移",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用"
|
||||
},
|
||||
"systemStatus": {
|
||||
"title": "系统状态",
|
||||
"ccwInstall": "CCW 安装状态",
|
||||
"installed": "已安装",
|
||||
"incomplete": "不完整",
|
||||
"notInstalled": "未安装",
|
||||
"missingFiles": "缺失文件",
|
||||
"runToFix": "运行以下命令修复",
|
||||
"toolStatus": "工具可用性",
|
||||
"available": "可用",
|
||||
"unavailable": "不可用",
|
||||
"checking": "检测中..."
|
||||
},
|
||||
"dataRefresh": {
|
||||
"title": "数据刷新",
|
||||
"autoRefresh": "自动刷新",
|
||||
|
||||
@@ -65,5 +65,45 @@
|
||||
"emptyState": {
|
||||
"title": "未找到技能",
|
||||
"message": "没有符合当前筛选条件的技能。"
|
||||
},
|
||||
"create": {
|
||||
"title": "创建技能",
|
||||
"location": "存储位置",
|
||||
"locationProject": "项目技能",
|
||||
"locationProjectHint": ".claude/skills/",
|
||||
"locationUser": "全局技能",
|
||||
"locationUserHint": "~/.claude/skills/",
|
||||
"mode": "创建方式",
|
||||
"modeImport": "导入文件夹",
|
||||
"modeImportHint": "从现有文件夹导入技能",
|
||||
"modeGenerate": "AI 生成",
|
||||
"modeGenerateHint": "使用 AI 生成技能",
|
||||
"sourcePath": "源文件夹路径",
|
||||
"sourcePathPlaceholder": "输入技能文件夹的绝对路径",
|
||||
"sourcePathHint": "文件夹中需要包含 SKILL.md 文件",
|
||||
"customName": "自定义名称",
|
||||
"customNamePlaceholder": "留空则使用原始名称",
|
||||
"customNameHint": "可选,用于覆盖默认技能名称",
|
||||
"skillName": "技能名称",
|
||||
"skillNamePlaceholder": "输入技能名称",
|
||||
"skillNameHint": "用作技能文件夹名称",
|
||||
"descriptionLabel": "技能描述",
|
||||
"descriptionPlaceholder": "描述这个技能应该做什么...",
|
||||
"descriptionHint": "AI 将根据描述生成技能内容",
|
||||
"generateInfo": "AI 将使用 CLI 工具生成技能",
|
||||
"generateTimeHint": "生成过程可能需要一些时间",
|
||||
"validate": "验证",
|
||||
"import": "导入",
|
||||
"generate": "生成",
|
||||
"validating": "验证中...",
|
||||
"validSkill": "验证通过",
|
||||
"invalidSkill": "验证失败",
|
||||
"creating": "创建中...",
|
||||
"created": "技能 \"{name}\" 创建成功",
|
||||
"createError": "创建技能失败",
|
||||
"sourcePathRequired": "请输入源文件夹路径",
|
||||
"skillNameRequired": "请输入技能名称",
|
||||
"descriptionRequired": "请输入技能描述",
|
||||
"validateFirst": "请先验证技能文件夹"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
BookmarkPlus,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -29,20 +30,21 @@ import { McpServerDialog } from '@/components/mcp/McpServerDialog';
|
||||
import { CliModeToggle, type CliMode } from '@/components/mcp/CliModeToggle';
|
||||
import { CodexMcpEditableCard } from '@/components/mcp/CodexMcpEditableCard';
|
||||
import { CcwToolsMcpCard } from '@/components/mcp/CcwToolsMcpCard';
|
||||
import { McpTemplatesSection } from '@/components/mcp/McpTemplatesSection';
|
||||
import { McpTemplatesSection, TemplateSaveDialog } from '@/components/mcp/McpTemplatesSection';
|
||||
import { RecommendedMcpSection } from '@/components/mcp/RecommendedMcpSection';
|
||||
import { WindowsCompatibilityWarning } from '@/components/mcp/WindowsCompatibilityWarning';
|
||||
import { CrossCliCopyButton } from '@/components/mcp/CrossCliCopyButton';
|
||||
import { AllProjectsTable } from '@/components/mcp/AllProjectsTable';
|
||||
import { OtherProjectsSection } from '@/components/mcp/OtherProjectsSection';
|
||||
import { TabsNavigation } from '@/components/ui/TabsNavigation';
|
||||
import { useMcpServers, useMcpServerMutations } from '@/hooks';
|
||||
import { useMcpServers, useMcpServerMutations, useNotifications } from '@/hooks';
|
||||
import {
|
||||
fetchCodexMcpServers,
|
||||
fetchCcwMcpConfig,
|
||||
updateCcwConfig,
|
||||
codexRemoveServer,
|
||||
codexToggleServer,
|
||||
saveMcpTemplate,
|
||||
type McpServer,
|
||||
type CcwMcpConfig,
|
||||
} from '@/lib/api';
|
||||
@@ -57,9 +59,10 @@ interface McpServerCardProps {
|
||||
onToggle: (serverName: string, enabled: boolean) => void;
|
||||
onEdit: (server: McpServer) => void;
|
||||
onDelete: (server: McpServer) => void;
|
||||
onSaveAsTemplate: (server: McpServer) => void;
|
||||
}
|
||||
|
||||
function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, onDelete }: McpServerCardProps) {
|
||||
function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, onDelete, onSaveAsTemplate }: McpServerCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
@@ -115,6 +118,18 @@ function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, o
|
||||
>
|
||||
{server.enabled ? <Power className="w-4 h-4 text-green-600" /> : <PowerOff className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSaveAsTemplate(server);
|
||||
}}
|
||||
title={formatMessage({ id: 'mcp.templates.actions.saveAsTemplate' })}
|
||||
>
|
||||
<BookmarkPlus className="w-4 h-4 text-primary" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -206,6 +221,10 @@ export function McpManagerPage() {
|
||||
const [editingServer, setEditingServer] = useState<McpServer | undefined>(undefined);
|
||||
const [cliMode, setCliMode] = useState<CliMode>('claude');
|
||||
const [codexExpandedServers, setCodexExpandedServers] = useState<Set<string>>(new Set());
|
||||
const [saveTemplateDialogOpen, setSaveTemplateDialogOpen] = useState(false);
|
||||
const [serverToSaveAsTemplate, setServerToSaveAsTemplate] = useState<McpServer | undefined>(undefined);
|
||||
|
||||
const notifications = useNotifications();
|
||||
|
||||
const {
|
||||
servers,
|
||||
@@ -269,9 +288,22 @@ export function McpManagerPage() {
|
||||
toggleServer(serverName, enabled);
|
||||
};
|
||||
|
||||
const handleDelete = (server: McpServer) => {
|
||||
const handleDelete = async (server: McpServer) => {
|
||||
if (confirm(formatMessage({ id: 'mcp.deleteConfirm' }, { name: server.name }))) {
|
||||
deleteServer(server.name, server.scope);
|
||||
try {
|
||||
await deleteServer(server.name, server.scope);
|
||||
notifications.success(
|
||||
formatMessage({ id: 'mcp.actions.delete' }),
|
||||
server.name
|
||||
);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete MCP server:', error);
|
||||
notifications.error(
|
||||
formatMessage({ id: 'mcp.actions.delete' }),
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -339,19 +371,64 @@ export function McpManagerPage() {
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveAsTemplate = (serverName: string, config: { command: string; args: string[] }) => {
|
||||
// This would open a dialog to save current server as template
|
||||
// For now, just log it
|
||||
console.log('Save as template:', serverName, config);
|
||||
const handleSaveServerAsTemplate = (server: McpServer) => {
|
||||
setServerToSaveAsTemplate(server);
|
||||
setSaveTemplateDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveAsTemplate = async (
|
||||
name: string,
|
||||
category: string,
|
||||
description: string,
|
||||
serverConfig: { command: string; args: string[]; env: Record<string, string> },
|
||||
) => {
|
||||
try {
|
||||
const result = await saveMcpTemplate({
|
||||
name,
|
||||
description: description || undefined,
|
||||
category: category || 'custom',
|
||||
serverConfig: {
|
||||
command: serverConfig.command,
|
||||
args: serverConfig.args.length > 0 ? serverConfig.args : undefined,
|
||||
env: Object.keys(serverConfig.env).length > 0 ? serverConfig.env : undefined,
|
||||
},
|
||||
});
|
||||
if (result.success) {
|
||||
notifications.success(
|
||||
formatMessage({ id: 'mcp.templates.feedback.saveSuccess' }),
|
||||
name
|
||||
);
|
||||
setSaveTemplateDialogOpen(false);
|
||||
setServerToSaveAsTemplate(undefined);
|
||||
} else {
|
||||
notifications.error(
|
||||
formatMessage({ id: 'mcp.templates.feedback.saveError' }),
|
||||
result.error || ''
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.error(
|
||||
formatMessage({ id: 'mcp.templates.feedback.saveError' }),
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Codex MCP handlers
|
||||
const handleCodexRemove = async (serverName: string) => {
|
||||
try {
|
||||
await codexRemoveServer(serverName);
|
||||
notifications.success(
|
||||
formatMessage({ id: 'mcp.actions.delete' }),
|
||||
serverName
|
||||
);
|
||||
codexQuery.refetch();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove Codex MCP server:', error);
|
||||
notifications.error(
|
||||
formatMessage({ id: 'mcp.actions.delete' }),
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -592,6 +669,7 @@ export function McpManagerPage() {
|
||||
onToggle={handleToggle}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onSaveAsTemplate={handleSaveServerAsTemplate}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
@@ -646,6 +724,20 @@ export function McpManagerPage() {
|
||||
onSave={handleDialogSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Save as Template Dialog */}
|
||||
<TemplateSaveDialog
|
||||
open={saveTemplateDialogOpen}
|
||||
onClose={() => {
|
||||
setSaveTemplateDialogOpen(false);
|
||||
setServerToSaveAsTemplate(undefined);
|
||||
}}
|
||||
onSave={handleSaveAsTemplate}
|
||||
defaultName={serverToSaveAsTemplate?.name}
|
||||
defaultCommand={serverToSaveAsTemplate?.command}
|
||||
defaultArgs={serverToSaveAsTemplate?.args}
|
||||
defaultEnv={serverToSaveAsTemplate?.env as Record<string, string>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
ChevronUp,
|
||||
Languages,
|
||||
Plus,
|
||||
MessageSquareText,
|
||||
Monitor,
|
||||
Terminal,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -29,6 +33,17 @@ import { useConfigStore, selectCliTools, selectDefaultCliTool, selectUserPrefere
|
||||
import type { CliToolConfig, UserPreferences } from '@/types/store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LanguageSwitcher } from '@/components/layout/LanguageSwitcher';
|
||||
import {
|
||||
useChineseResponseStatus,
|
||||
useToggleChineseResponse,
|
||||
useWindowsPlatformStatus,
|
||||
useToggleWindowsPlatform,
|
||||
useCodexCliEnhancementStatus,
|
||||
useToggleCodexCliEnhancement,
|
||||
useRefreshCodexCliEnhancement,
|
||||
useCcwInstallStatus,
|
||||
useCliToolStatus,
|
||||
} from '@/hooks/useSystemSettings';
|
||||
|
||||
// ========== CLI Tool Card Component ==========
|
||||
|
||||
@@ -37,6 +52,7 @@ interface CliToolCardProps {
|
||||
config: CliToolConfig;
|
||||
isDefault: boolean;
|
||||
isExpanded: boolean;
|
||||
toolAvailable?: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onToggleEnabled: () => void;
|
||||
onSetDefault: () => void;
|
||||
@@ -51,6 +67,7 @@ function CliToolCard({
|
||||
config,
|
||||
isDefault,
|
||||
isExpanded,
|
||||
toolAvailable,
|
||||
onToggleExpand,
|
||||
onToggleEnabled,
|
||||
onSetDefault,
|
||||
@@ -125,6 +142,12 @@ function CliToolCard({
|
||||
<Badge variant="default" className="text-xs">{formatMessage({ id: 'settings.cliTools.default' })}</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">{config.type}</Badge>
|
||||
{toolAvailable !== undefined && (
|
||||
<span className={cn(
|
||||
'inline-block w-2 h-2 rounded-full',
|
||||
toolAvailable ? 'bg-green-500' : 'bg-red-400'
|
||||
)} title={toolAvailable ? 'Available' : 'Unavailable'} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{config.primaryModel}
|
||||
@@ -345,6 +368,266 @@ function CliToolCard({
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Response Language Section ==========
|
||||
|
||||
function ResponseLanguageSection() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { data: chineseStatus, isLoading: chineseLoading } = useChineseResponseStatus();
|
||||
const { toggle: toggleChinese, isPending: chineseToggling } = useToggleChineseResponse();
|
||||
const { data: windowsStatus, isLoading: windowsLoading } = useWindowsPlatformStatus();
|
||||
const { toggle: toggleWindows, isPending: windowsToggling } = useToggleWindowsPlatform();
|
||||
const { data: cliEnhStatus, isLoading: cliEnhLoading } = useCodexCliEnhancementStatus();
|
||||
const { toggle: toggleCliEnh, isPending: cliEnhToggling } = useToggleCodexCliEnhancement();
|
||||
const { refresh: refreshCliEnh, isPending: refreshing } = useRefreshCodexCliEnhancement();
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||
<MessageSquareText className="w-5 h-5" />
|
||||
{formatMessage({ id: 'settings.sections.responseLanguage' })}
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Chinese Response - Claude */}
|
||||
<div className="rounded-lg border border-border p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{formatMessage({ id: 'settings.responseLanguage.chineseClaude' })}</span>
|
||||
<Badge variant="default" className="text-xs">Claude</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant={chineseStatus?.claudeEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7"
|
||||
disabled={chineseLoading || chineseToggling}
|
||||
onClick={() => toggleChinese(!chineseStatus?.claudeEnabled, 'claude')}
|
||||
>
|
||||
{chineseStatus?.claudeEnabled
|
||||
? formatMessage({ id: 'settings.responseLanguage.enabled' })
|
||||
: formatMessage({ id: 'settings.responseLanguage.disabled' })}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'settings.responseLanguage.chineseClaudeDesc' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Chinese Response - Codex */}
|
||||
<div className="rounded-lg border border-border p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{formatMessage({ id: 'settings.responseLanguage.chineseCodex' })}</span>
|
||||
<Badge variant="secondary" className="text-xs">Codex</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant={chineseStatus?.codexEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7"
|
||||
disabled={chineseLoading || chineseToggling}
|
||||
onClick={() => toggleChinese(!chineseStatus?.codexEnabled, 'codex')}
|
||||
>
|
||||
{chineseStatus?.codexEnabled
|
||||
? formatMessage({ id: 'settings.responseLanguage.enabled' })
|
||||
: formatMessage({ id: 'settings.responseLanguage.disabled' })}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'settings.responseLanguage.chineseCodexDesc' })}
|
||||
</p>
|
||||
{chineseStatus?.codexNeedsMigration && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
<AlertTriangle className="w-3 h-3 inline mr-1" />
|
||||
{formatMessage({ id: 'settings.responseLanguage.migrationWarning' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Windows Platform */}
|
||||
<div className="rounded-lg border border-border p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{formatMessage({ id: 'settings.responseLanguage.windowsPlatform' })}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant={windowsStatus?.enabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7"
|
||||
disabled={windowsLoading || windowsToggling}
|
||||
onClick={() => toggleWindows(!windowsStatus?.enabled)}
|
||||
>
|
||||
{windowsStatus?.enabled
|
||||
? formatMessage({ id: 'settings.responseLanguage.enabled' })
|
||||
: formatMessage({ id: 'settings.responseLanguage.disabled' })}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'settings.responseLanguage.windowsPlatformDesc' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CLI Enhancement - Codex */}
|
||||
<div className="rounded-lg border border-border p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{formatMessage({ id: 'settings.responseLanguage.cliEnhancement' })}</span>
|
||||
<Badge variant="secondary" className="text-xs">Codex</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{cliEnhStatus?.enabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={cliEnhLoading || refreshing}
|
||||
onClick={() => refreshCliEnh()}
|
||||
title={formatMessage({ id: 'settings.responseLanguage.refreshConfig' })}
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={cliEnhStatus?.enabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7"
|
||||
disabled={cliEnhLoading || cliEnhToggling}
|
||||
onClick={() => toggleCliEnh(!cliEnhStatus?.enabled)}
|
||||
>
|
||||
{cliEnhStatus?.enabled
|
||||
? formatMessage({ id: 'settings.responseLanguage.enabled' })
|
||||
: formatMessage({ id: 'settings.responseLanguage.disabled' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'settings.responseLanguage.cliEnhancementDesc' })}
|
||||
</p>
|
||||
{cliEnhStatus?.enabled && (
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{formatMessage({ id: 'settings.responseLanguage.cliEnhancementHint' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== System Status Section ==========
|
||||
|
||||
function SystemStatusSection() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { data: ccwInstall, isLoading: installLoading } = useCcwInstallStatus();
|
||||
|
||||
// Don't show if installed or still loading
|
||||
if (installLoading || ccwInstall?.installed) return null;
|
||||
|
||||
return (
|
||||
<Card className="p-6 border-yellow-500/50">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-500" />
|
||||
{formatMessage({ id: 'settings.systemStatus.title' })}
|
||||
</h2>
|
||||
|
||||
{/* CCW Install Status */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{formatMessage({ id: 'settings.systemStatus.ccwInstall' })}</span>
|
||||
<Badge variant="outline" className="text-yellow-500 border-yellow-500/50">
|
||||
{formatMessage({ id: 'settings.systemStatus.incomplete' })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{ccwInstall && ccwInstall.missingFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'settings.systemStatus.missingFiles' })}:
|
||||
</p>
|
||||
<ul className="text-xs text-muted-foreground/70 list-disc list-inside">
|
||||
{ccwInstall.missingFiles.slice(0, 5).map((file) => (
|
||||
<li key={file}>{file}</li>
|
||||
))}
|
||||
{ccwInstall.missingFiles.length > 5 && (
|
||||
<li>+{ccwInstall.missingFiles.length - 5} more...</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className="bg-muted/50 rounded-md p-3 mt-2">
|
||||
<p className="text-xs font-medium mb-1">
|
||||
{formatMessage({ id: 'settings.systemStatus.runToFix' })}:
|
||||
</p>
|
||||
<code className="text-xs bg-background px-2 py-1 rounded block font-mono">
|
||||
ccw install
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== CLI Tools with Status Enhancement ==========
|
||||
|
||||
interface CliToolsWithStatusProps {
|
||||
cliTools: Record<string, CliToolConfig>;
|
||||
defaultCliTool: string;
|
||||
expandedTools: Set<string>;
|
||||
onToggleExpand: (toolId: string) => void;
|
||||
onToggleEnabled: (toolId: string) => void;
|
||||
onSetDefault: (toolId: string) => void;
|
||||
onUpdateModel: (toolId: string, field: 'primaryModel' | 'secondaryModel', value: string) => void;
|
||||
onUpdateTags: (toolId: string, tags: string[]) => void;
|
||||
onUpdateAvailableModels: (toolId: string, models: string[]) => void;
|
||||
onUpdateSettingsFile: (toolId: string, settingsFile: string | undefined) => void;
|
||||
formatMessage: ReturnType<typeof useIntl>['formatMessage'];
|
||||
}
|
||||
|
||||
function CliToolsWithStatus({
|
||||
cliTools,
|
||||
defaultCliTool,
|
||||
expandedTools,
|
||||
onToggleExpand,
|
||||
onToggleEnabled,
|
||||
onSetDefault,
|
||||
onUpdateModel,
|
||||
onUpdateTags,
|
||||
onUpdateAvailableModels,
|
||||
onUpdateSettingsFile,
|
||||
formatMessage,
|
||||
}: CliToolsWithStatusProps) {
|
||||
const { data: toolStatus } = useCliToolStatus();
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{formatMessage({ id: 'settings.cliTools.description' })} <strong className="text-foreground">{defaultCliTool}</strong>
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(cliTools).map(([toolId, config]) => {
|
||||
const status = toolStatus?.[toolId];
|
||||
return (
|
||||
<CliToolCard
|
||||
key={toolId}
|
||||
toolId={toolId}
|
||||
config={config}
|
||||
isDefault={toolId === defaultCliTool}
|
||||
isExpanded={expandedTools.has(toolId)}
|
||||
toolAvailable={status?.available}
|
||||
onToggleExpand={() => onToggleExpand(toolId)}
|
||||
onToggleEnabled={() => onToggleEnabled(toolId)}
|
||||
onSetDefault={() => onSetDefault(toolId)}
|
||||
onUpdateModel={(field, value) => onUpdateModel(toolId, field, value)}
|
||||
onUpdateTags={(tags) => onUpdateTags(toolId, tags)}
|
||||
onUpdateAvailableModels={(models) => onUpdateAvailableModels(toolId, models)}
|
||||
onUpdateSettingsFile={(settingsFile) => onUpdateSettingsFile(toolId, settingsFile)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Page Component ==========
|
||||
|
||||
export function SettingsPage() {
|
||||
@@ -466,33 +749,31 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Response Language Settings */}
|
||||
<ResponseLanguageSection />
|
||||
|
||||
{/* System Status */}
|
||||
<SystemStatusSection />
|
||||
|
||||
{/* CLI Tools Configuration */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||
<Cpu className="w-5 h-5" />
|
||||
{formatMessage({ id: 'settings.sections.cliTools' })}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{formatMessage({ id: 'settings.cliTools.description' })} <strong className="text-foreground">{defaultCliTool}</strong>
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(cliTools).map(([toolId, config]) => (
|
||||
<CliToolCard
|
||||
key={toolId}
|
||||
toolId={toolId}
|
||||
config={config}
|
||||
isDefault={toolId === defaultCliTool}
|
||||
isExpanded={expandedTools.has(toolId)}
|
||||
onToggleExpand={() => toggleToolExpand(toolId)}
|
||||
onToggleEnabled={() => handleToggleToolEnabled(toolId)}
|
||||
onSetDefault={() => handleSetDefaultTool(toolId)}
|
||||
onUpdateModel={(field, value) => handleUpdateModel(toolId, field, value)}
|
||||
onUpdateTags={(tags) => handleUpdateTags(toolId, tags)}
|
||||
onUpdateAvailableModels={(models) => handleUpdateAvailableModels(toolId, models)}
|
||||
onUpdateSettingsFile={(settingsFile) => handleUpdateSettingsFile(toolId, settingsFile)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<CliToolsWithStatus
|
||||
cliTools={cliTools}
|
||||
defaultCliTool={defaultCliTool}
|
||||
expandedTools={expandedTools}
|
||||
onToggleExpand={toggleToolExpand}
|
||||
onToggleEnabled={handleToggleToolEnabled}
|
||||
onSetDefault={handleSetDefaultTool}
|
||||
onUpdateModel={handleUpdateModel}
|
||||
onUpdateTags={handleUpdateTags}
|
||||
onUpdateAvailableModels={handleUpdateAvailableModels}
|
||||
onUpdateSettingsFile={handleUpdateSettingsFile}
|
||||
formatMessage={formatMessage}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Data Refresh Settings */}
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
} from '@/components/ui';
|
||||
import { SkillCard, SkillDetailPanel } from '@/components/shared';
|
||||
import { SkillCard, SkillDetailPanel, SkillCreateDialog } from '@/components/shared';
|
||||
import { useSkills, useSkillMutations } from '@/hooks';
|
||||
import { fetchSkillDetail } from '@/lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
@@ -118,6 +118,9 @@ export function SkillsManagerPage() {
|
||||
const [confirmDisable, setConfirmDisable] = useState<{ skill: Skill; enable: boolean } | null>(null);
|
||||
const [locationFilter, setLocationFilter] = useState<'project' | 'user'>('project');
|
||||
|
||||
// Skill create dialog state
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
|
||||
// Skill detail panel state
|
||||
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false);
|
||||
@@ -243,7 +246,7 @@ export function SkillsManagerPage() {
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
<Button>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'skills.actions.install' })}
|
||||
</Button>
|
||||
@@ -470,6 +473,13 @@ export function SkillsManagerPage() {
|
||||
onClose={handleCloseDetailPanel}
|
||||
isLoading={isDetailLoading}
|
||||
/>
|
||||
|
||||
{/* Skill Create Dialog */}
|
||||
<SkillCreateDialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
onCreated={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user