mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
Add comprehensive tests for ast-grep and tree-sitter relationship extraction
- Introduced test suite for AstGrepPythonProcessor covering pattern definitions, parsing, and relationship extraction. - Added comparison tests between tree-sitter and ast-grep for consistency in relationship extraction. - Implemented tests for ast-grep binding module to verify functionality and availability. - Ensured tests cover various scenarios including inheritance, function calls, and imports.
This commit is contained in:
@@ -704,7 +704,7 @@ function WorkflowTaskWidgetComponent({ className }: WorkflowTaskWidgetProps) {
|
||||
const isLastOdd = currentSession.tasks!.length % 2 === 1 && index === currentSession.tasks!.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={task.task_id}
|
||||
key={`${currentSession.session_id}-${task.task_id}`}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-2 rounded hover:bg-background/50 transition-colors',
|
||||
isLastOdd && 'col-span-2'
|
||||
|
||||
396
ccw/frontend/src/components/settings/PlatformConfigCards.tsx
Normal file
396
ccw/frontend/src/components/settings/PlatformConfigCards.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
// ========================================
|
||||
// Platform Configuration Cards
|
||||
// ========================================
|
||||
// Individual configuration cards for each notification platform
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
MessageCircle,
|
||||
Send,
|
||||
Link,
|
||||
Check,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
TestTube,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
RemoteNotificationConfig,
|
||||
NotificationPlatform,
|
||||
DiscordConfig,
|
||||
TelegramConfig,
|
||||
WebhookConfig,
|
||||
} from '@/types/remote-notification';
|
||||
import { PLATFORM_INFO } from '@/types/remote-notification';
|
||||
|
||||
interface PlatformConfigCardsProps {
|
||||
config: RemoteNotificationConfig;
|
||||
expandedPlatform: NotificationPlatform | null;
|
||||
testing: NotificationPlatform | null;
|
||||
onToggleExpand: (platform: NotificationPlatform | null) => void;
|
||||
onUpdateConfig: (
|
||||
platform: NotificationPlatform,
|
||||
updates: Partial<DiscordConfig | TelegramConfig | WebhookConfig>
|
||||
) => void;
|
||||
onTest: (
|
||||
platform: NotificationPlatform,
|
||||
config: DiscordConfig | TelegramConfig | WebhookConfig
|
||||
) => void;
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
}
|
||||
|
||||
export function PlatformConfigCards({
|
||||
config,
|
||||
expandedPlatform,
|
||||
testing,
|
||||
onToggleExpand,
|
||||
onUpdateConfig,
|
||||
onTest,
|
||||
onSave,
|
||||
saving,
|
||||
}: PlatformConfigCardsProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const platforms: NotificationPlatform[] = ['discord', 'telegram', 'webhook'];
|
||||
|
||||
const getPlatformIcon = (platform: NotificationPlatform) => {
|
||||
switch (platform) {
|
||||
case 'discord':
|
||||
return <MessageCircle className="w-4 h-4" />;
|
||||
case 'telegram':
|
||||
return <Send className="w-4 h-4" />;
|
||||
case 'webhook':
|
||||
return <Link className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformConfig = (
|
||||
platform: NotificationPlatform
|
||||
): DiscordConfig | TelegramConfig | WebhookConfig => {
|
||||
switch (platform) {
|
||||
case 'discord':
|
||||
return config.platforms.discord || { enabled: false, webhookUrl: '' };
|
||||
case 'telegram':
|
||||
return config.platforms.telegram || { enabled: false, botToken: '', chatId: '' };
|
||||
case 'webhook':
|
||||
return config.platforms.webhook || { enabled: false, url: '', method: 'POST' };
|
||||
}
|
||||
};
|
||||
|
||||
const isConfigured = (platform: NotificationPlatform): boolean => {
|
||||
const platformConfig = getPlatformConfig(platform);
|
||||
switch (platform) {
|
||||
case 'discord':
|
||||
return !!(platformConfig as DiscordConfig).webhookUrl;
|
||||
case 'telegram':
|
||||
return !!(platformConfig as TelegramConfig).botToken && !!(platformConfig as TelegramConfig).chatId;
|
||||
case 'webhook':
|
||||
return !!(platformConfig as WebhookConfig).url;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{platforms.map((platform) => {
|
||||
const info = PLATFORM_INFO[platform];
|
||||
const platformConfig = getPlatformConfig(platform);
|
||||
const configured = isConfigured(platform);
|
||||
const expanded = expandedPlatform === platform;
|
||||
|
||||
return (
|
||||
<Card key={platform} className="overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => onToggleExpand(expanded ? null : platform)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg',
|
||||
platformConfig.enabled && configured
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}>
|
||||
{getPlatformIcon(platform)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{info.name}</span>
|
||||
{configured && (
|
||||
<Badge variant="outline" className="text-xs text-green-600 border-green-500/30">
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'settings.remoteNotifications.configured' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{info.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={platformConfig.enabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdateConfig(platform, { enabled: !platformConfig.enabled });
|
||||
}}
|
||||
>
|
||||
{platformConfig.enabled ? (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<X className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{expanded && (
|
||||
<div className="border-t border-border p-4 space-y-4 bg-muted/30">
|
||||
{platform === 'discord' && (
|
||||
<DiscordConfigForm
|
||||
config={platformConfig as DiscordConfig}
|
||||
onUpdate={(updates) => onUpdateConfig('discord', updates)}
|
||||
/>
|
||||
)}
|
||||
{platform === 'telegram' && (
|
||||
<TelegramConfigForm
|
||||
config={platformConfig as TelegramConfig}
|
||||
onUpdate={(updates) => onUpdateConfig('telegram', updates)}
|
||||
/>
|
||||
)}
|
||||
{platform === 'webhook' && (
|
||||
<WebhookConfigForm
|
||||
config={platformConfig as WebhookConfig}
|
||||
onUpdate={(updates) => onUpdateConfig('webhook', updates)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onTest(platform, platformConfig)}
|
||||
disabled={testing === platform || !configured}
|
||||
>
|
||||
<TestTube className={cn('w-3.5 h-3.5 mr-1', testing === platform && 'animate-pulse')} />
|
||||
{formatMessage({ id: 'settings.remoteNotifications.testConnection' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{formatMessage({ id: 'settings.remoteNotifications.save' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Discord Config Form ==========
|
||||
|
||||
function DiscordConfigForm({
|
||||
config,
|
||||
onUpdate,
|
||||
}: {
|
||||
config: DiscordConfig;
|
||||
onUpdate: (updates: Partial<DiscordConfig>) => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [showUrl, setShowUrl] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'settings.remoteNotifications.discord.webhookUrl' })}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
type={showUrl ? 'text' : 'password'}
|
||||
value={config.webhookUrl || ''}
|
||||
onChange={(e) => onUpdate({ webhookUrl: e.target.value })}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => setShowUrl(!showUrl)}
|
||||
>
|
||||
{showUrl ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'settings.remoteNotifications.discord.webhookUrlHint' })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'settings.remoteNotifications.discord.username' })}
|
||||
</label>
|
||||
<Input
|
||||
value={config.username || ''}
|
||||
onChange={(e) => onUpdate({ username: e.target.value })}
|
||||
placeholder="CCW Notification"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Telegram Config Form ==========
|
||||
|
||||
function TelegramConfigForm({
|
||||
config,
|
||||
onUpdate,
|
||||
}: {
|
||||
config: TelegramConfig;
|
||||
onUpdate: (updates: Partial<TelegramConfig>) => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'settings.remoteNotifications.telegram.botToken' })}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={config.botToken || ''}
|
||||
onChange={(e) => onUpdate({ botToken: e.target.value })}
|
||||
placeholder="1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
>
|
||||
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'settings.remoteNotifications.telegram.botTokenHint' })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'settings.remoteNotifications.telegram.chatId' })}
|
||||
</label>
|
||||
<Input
|
||||
value={config.chatId || ''}
|
||||
onChange={(e) => onUpdate({ chatId: e.target.value })}
|
||||
placeholder="-1001234567890"
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'settings.remoteNotifications.telegram.chatIdHint' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Webhook Config Form ==========
|
||||
|
||||
function WebhookConfigForm({
|
||||
config,
|
||||
onUpdate,
|
||||
}: {
|
||||
config: WebhookConfig;
|
||||
onUpdate: (updates: Partial<WebhookConfig>) => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'settings.remoteNotifications.webhook.url' })}
|
||||
</label>
|
||||
<Input
|
||||
value={config.url || ''}
|
||||
onChange={(e) => onUpdate({ url: e.target.value })}
|
||||
placeholder="https://your-server.com/webhook"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'settings.remoteNotifications.webhook.method' })}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Button
|
||||
variant={config.method === 'POST' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onUpdate({ method: 'POST' })}
|
||||
>
|
||||
POST
|
||||
</Button>
|
||||
<Button
|
||||
variant={config.method === 'PUT' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onUpdate({ method: 'PUT' })}
|
||||
>
|
||||
PUT
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'settings.remoteNotifications.webhook.headers' })}
|
||||
</label>
|
||||
<Input
|
||||
value={config.headers ? JSON.stringify(config.headers) : ''}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const headers = e.target.value ? JSON.parse(e.target.value) : undefined;
|
||||
onUpdate({ headers });
|
||||
} catch {
|
||||
// Invalid JSON, ignore
|
||||
}
|
||||
}}
|
||||
placeholder='{"Authorization": "Bearer token"}'
|
||||
className="mt-1 font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'settings.remoteNotifications.webhook.headersHint' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlatformConfigCards;
|
||||
@@ -0,0 +1,347 @@
|
||||
// ========================================
|
||||
// Remote Notification Settings Section
|
||||
// ========================================
|
||||
// Configuration UI for remote notification platforms
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
RefreshCw,
|
||||
Check,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
TestTube,
|
||||
Save,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import type {
|
||||
RemoteNotificationConfig,
|
||||
NotificationPlatform,
|
||||
EventConfig,
|
||||
DiscordConfig,
|
||||
TelegramConfig,
|
||||
WebhookConfig,
|
||||
} from '@/types/remote-notification';
|
||||
import { PLATFORM_INFO, EVENT_INFO, getDefaultConfig } from '@/types/remote-notification';
|
||||
import { PlatformConfigCards } from './PlatformConfigCards';
|
||||
|
||||
interface RemoteNotificationSectionProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RemoteNotificationSection({ className }: RemoteNotificationSectionProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [config, setConfig] = useState<RemoteNotificationConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState<NotificationPlatform | null>(null);
|
||||
const [expandedPlatform, setExpandedPlatform] = useState<NotificationPlatform | null>(null);
|
||||
|
||||
// Load configuration
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/notifications/remote/config');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setConfig(data);
|
||||
} else {
|
||||
// Use default config if not found
|
||||
setConfig(getDefaultConfig());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load remote notification config:', error);
|
||||
setConfig(getDefaultConfig());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
// Save configuration
|
||||
const saveConfig = useCallback(async (newConfig: RemoteNotificationConfig) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/notifications/remote/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newConfig),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setConfig(data.config);
|
||||
toast.success(formatMessage({ id: 'settings.remoteNotifications.saved' }));
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(formatMessage({ id: 'settings.remoteNotifications.saveError' }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [formatMessage]);
|
||||
|
||||
// Test platform
|
||||
const testPlatform = useCallback(async (
|
||||
platform: NotificationPlatform,
|
||||
platformConfig: DiscordConfig | TelegramConfig | WebhookConfig
|
||||
) => {
|
||||
setTesting(platform);
|
||||
try {
|
||||
const response = await fetch('/api/notifications/remote/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ platform, config: platformConfig }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
formatMessage({ id: 'settings.remoteNotifications.testSuccess' }),
|
||||
{ description: `${result.responseTime}ms` }
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
formatMessage({ id: 'settings.remoteNotifications.testFailed' }),
|
||||
{ description: result.error }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(formatMessage({ id: 'settings.remoteNotifications.testError' }));
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
}, [formatMessage]);
|
||||
|
||||
// Toggle master switch
|
||||
const toggleEnabled = () => {
|
||||
if (!config) return;
|
||||
saveConfig({ ...config, enabled: !config.enabled });
|
||||
};
|
||||
|
||||
// Update platform config
|
||||
const updatePlatformConfig = (
|
||||
platform: NotificationPlatform,
|
||||
updates: Partial<DiscordConfig | TelegramConfig | WebhookConfig>
|
||||
) => {
|
||||
if (!config) return;
|
||||
const newConfig = {
|
||||
...config,
|
||||
platforms: {
|
||||
...config.platforms,
|
||||
[platform]: {
|
||||
...config.platforms[platform as keyof typeof config.platforms],
|
||||
...updates,
|
||||
},
|
||||
},
|
||||
};
|
||||
setConfig(newConfig);
|
||||
};
|
||||
|
||||
// Update event config
|
||||
const updateEventConfig = (eventIndex: number, updates: Partial<EventConfig>) => {
|
||||
if (!config) return;
|
||||
const newEvents = [...config.events];
|
||||
newEvents[eventIndex] = { ...newEvents[eventIndex], ...updates };
|
||||
setConfig({ ...config, events: newEvents });
|
||||
};
|
||||
|
||||
// Reset to defaults
|
||||
const resetConfig = async () => {
|
||||
if (!confirm(formatMessage({ id: 'settings.remoteNotifications.resetConfirm' }))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/notifications/remote/reset', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setConfig(data.config);
|
||||
toast.success(formatMessage({ id: 'settings.remoteNotifications.resetSuccess' }));
|
||||
}
|
||||
} catch {
|
||||
toast.error(formatMessage({ id: 'settings.remoteNotifications.resetError' }));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className={cn('p-6', className)}>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn('p-6', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
{config.enabled ? (
|
||||
<Bell className="w-5 h-5 text-primary" />
|
||||
) : (
|
||||
<BellOff className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
{formatMessage({ id: 'settings.remoteNotifications.title' })}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => loadConfig()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button
|
||||
variant={config.enabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={toggleEnabled}
|
||||
>
|
||||
{config.enabled ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'settings.remoteNotifications.enabled' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'settings.remoteNotifications.disabled' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
{formatMessage({ id: 'settings.remoteNotifications.description' })}
|
||||
</p>
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
{/* Platform Configuration */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'settings.remoteNotifications.platforms' })}
|
||||
</h3>
|
||||
<PlatformConfigCards
|
||||
config={config}
|
||||
expandedPlatform={expandedPlatform}
|
||||
testing={testing}
|
||||
onToggleExpand={setExpandedPlatform}
|
||||
onUpdateConfig={updatePlatformConfig}
|
||||
onTest={testPlatform}
|
||||
onSave={() => saveConfig(config)}
|
||||
saving={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event Configuration */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'settings.remoteNotifications.events' })}
|
||||
</h3>
|
||||
<div className="grid gap-3">
|
||||
{config.events.map((eventConfig, index) => {
|
||||
const info = EVENT_INFO[eventConfig.event];
|
||||
return (
|
||||
<div
|
||||
key={eventConfig.event}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-border bg-muted/30"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg',
|
||||
eventConfig.enabled ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
|
||||
)}>
|
||||
<span className="text-sm">{info.icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{info.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{info.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Platform badges */}
|
||||
<div className="flex gap-1">
|
||||
{eventConfig.platforms.map((platform) => (
|
||||
<Badge key={platform} variant="secondary" className="text-xs">
|
||||
{PLATFORM_INFO[platform].name}
|
||||
</Badge>
|
||||
))}
|
||||
{eventConfig.platforms.length === 0 && (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'settings.remoteNotifications.noPlatforms' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Toggle */}
|
||||
<Button
|
||||
variant={eventConfig.enabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={() => updateEventConfig(index, { enabled: !eventConfig.enabled })}
|
||||
>
|
||||
{eventConfig.enabled ? (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<X className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetConfig}
|
||||
>
|
||||
{formatMessage({ id: 'settings.remoteNotifications.reset' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => saveConfig(config)}
|
||||
disabled={saving}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
{saving
|
||||
? formatMessage({ id: 'settings.remoteNotifications.saving' })
|
||||
: formatMessage({ id: 'settings.remoteNotifications.save' })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default RemoteNotificationSection;
|
||||
@@ -0,0 +1,270 @@
|
||||
// ========================================
|
||||
// CliConfigModal Component
|
||||
// ========================================
|
||||
// Config modal for creating a new CLI session in Terminal Dashboard.
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
|
||||
|
||||
export type CliTool = 'claude' | 'gemini' | 'qwen' | 'codex' | 'opencode';
|
||||
export type LaunchMode = 'default' | 'yolo';
|
||||
export type ShellKind = 'bash' | 'pwsh';
|
||||
|
||||
export interface CliSessionConfig {
|
||||
tool: CliTool;
|
||||
model?: string;
|
||||
launchMode: LaunchMode;
|
||||
preferredShell: ShellKind;
|
||||
workingDir: string;
|
||||
}
|
||||
|
||||
export interface CliConfigModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
defaultWorkingDir?: string | null;
|
||||
onCreateSession: (config: CliSessionConfig) => Promise<void>;
|
||||
}
|
||||
|
||||
const CLI_TOOLS: CliTool[] = ['claude', 'gemini', 'qwen', 'codex', 'opencode'];
|
||||
|
||||
const MODEL_OPTIONS: Record<CliTool, string[]> = {
|
||||
claude: ['sonnet', 'haiku'],
|
||||
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash'],
|
||||
qwen: ['coder-model'],
|
||||
codex: ['gpt-5.2'],
|
||||
opencode: ['opencode/glm-4.7-free'],
|
||||
};
|
||||
|
||||
const AUTO_MODEL_VALUE = '__auto__';
|
||||
|
||||
export function CliConfigModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
defaultWorkingDir,
|
||||
onCreateSession,
|
||||
}: CliConfigModalProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const [tool, setTool] = React.useState<CliTool>('gemini');
|
||||
const [model, setModel] = React.useState<string | undefined>(MODEL_OPTIONS.gemini[0]);
|
||||
const [launchMode, setLaunchMode] = React.useState<LaunchMode>('yolo');
|
||||
const [preferredShell, setPreferredShell] = React.useState<ShellKind>('bash');
|
||||
const [workingDir, setWorkingDir] = React.useState<string>(defaultWorkingDir ?? '');
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const modelOptions = React.useMemo(() => MODEL_OPTIONS[tool] ?? [], [tool]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
// Reset to a safe default each time the modal is opened.
|
||||
const nextWorkingDir = defaultWorkingDir ?? '';
|
||||
setWorkingDir(nextWorkingDir);
|
||||
setError(null);
|
||||
}, [isOpen, defaultWorkingDir]);
|
||||
|
||||
const handleToolChange = (nextTool: string) => {
|
||||
const next = nextTool as CliTool;
|
||||
setTool(next);
|
||||
const nextModels = MODEL_OPTIONS[next] ?? [];
|
||||
if (!model || !nextModels.includes(model)) {
|
||||
setModel(nextModels[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowse = () => {
|
||||
// Reserved for future file-picker integration
|
||||
console.log('[CliConfigModal] browse working directory - not implemented');
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const dir = workingDir.trim();
|
||||
if (!dir) {
|
||||
setError(formatMessage({ id: 'terminalDashboard.cliConfig.errors.workingDirRequired' }));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onCreateSession({
|
||||
tool,
|
||||
model,
|
||||
launchMode,
|
||||
preferredShell,
|
||||
workingDir: dir,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('[CliConfigModal] create session failed:', err);
|
||||
setError(formatMessage({ id: 'terminalDashboard.cliConfig.errors.createFailed' }));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[720px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formatMessage({ id: 'terminalDashboard.cliConfig.title' })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatMessage({ id: 'terminalDashboard.cliConfig.description' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Tool */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cli-config-tool">
|
||||
{formatMessage({ id: 'terminalDashboard.cliConfig.tool' })}
|
||||
</Label>
|
||||
<Select value={tool} onValueChange={handleToolChange} disabled={isSubmitting}>
|
||||
<SelectTrigger id="cli-config-tool">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CLI_TOOLS.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cli-config-model">
|
||||
{formatMessage({ id: 'terminalDashboard.cliConfig.model' })}
|
||||
</Label>
|
||||
<Select
|
||||
value={model ?? AUTO_MODEL_VALUE}
|
||||
onValueChange={(v) => setModel(v === AUTO_MODEL_VALUE ? undefined : v)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger id="cli-config-model">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={AUTO_MODEL_VALUE}>
|
||||
{formatMessage({ id: 'terminalDashboard.cliConfig.modelAuto' })}
|
||||
</SelectItem>
|
||||
{modelOptions.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode */}
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'terminalDashboard.cliConfig.mode' })}</Label>
|
||||
<RadioGroup
|
||||
value={launchMode}
|
||||
onValueChange={(v) => setLaunchMode(v as LaunchMode)}
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<RadioGroupItem value="default" />
|
||||
{formatMessage({ id: 'terminalDashboard.cliConfig.modeDefault' })}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<RadioGroupItem value="yolo" />
|
||||
{formatMessage({ id: 'terminalDashboard.cliConfig.modeYolo' })}
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Shell */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cli-config-shell">
|
||||
{formatMessage({ id: 'terminalDashboard.cliConfig.shell' })}
|
||||
</Label>
|
||||
<Select
|
||||
value={preferredShell}
|
||||
onValueChange={(v) => setPreferredShell(v as ShellKind)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger id="cli-config-shell">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bash">bash</SelectItem>
|
||||
<SelectItem value="pwsh">pwsh</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Working Directory */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cli-config-workingDir">
|
||||
{formatMessage({ id: 'terminalDashboard.cliConfig.workingDir' })}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="cli-config-workingDir"
|
||||
value={workingDir}
|
||||
onChange={(e) => {
|
||||
setWorkingDir(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
placeholder={formatMessage({ id: 'terminalDashboard.cliConfig.workingDirPlaceholder' })}
|
||||
disabled={isSubmitting}
|
||||
className={cn(error && 'border-destructive')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleBrowse}
|
||||
disabled={isSubmitting}
|
||||
title={formatMessage({ id: 'terminalDashboard.cliConfig.browse' })}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isSubmitting}>
|
||||
{formatMessage({ id: 'common.actions.create' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CliConfigModal;
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/Dropdown';
|
||||
@@ -37,6 +42,8 @@ import {
|
||||
import { useIssues, useIssueQueue } from '@/hooks/useIssues';
|
||||
import { useTerminalGridStore, selectTerminalGridFocusedPaneId } from '@/stores/terminalGridStore';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { sendCliSessionText } from '@/lib/api';
|
||||
import { CliConfigModal, type CliSessionConfig } from './CliConfigModal';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -56,6 +63,19 @@ const LAYOUT_PRESETS = [
|
||||
{ id: 'grid-2x2' as const, icon: LayoutGrid, labelId: 'terminalDashboard.toolbar.layoutGrid' },
|
||||
];
|
||||
|
||||
type LaunchMode = 'default' | 'yolo';
|
||||
|
||||
const CLI_TOOLS = ['claude', 'gemini', 'qwen', 'codex', 'opencode'] as const;
|
||||
type CliTool = (typeof CLI_TOOLS)[number];
|
||||
|
||||
const LAUNCH_COMMANDS: Record<CliTool, Record<LaunchMode, string>> = {
|
||||
claude: { default: 'claude', yolo: 'claude --permission-mode bypassPermissions' },
|
||||
gemini: { default: 'gemini', yolo: 'gemini --approval-mode yolo' },
|
||||
qwen: { default: 'qwen', yolo: 'qwen --approval-mode yolo' },
|
||||
codex: { default: 'codex', yolo: 'codex --full-auto' },
|
||||
opencode: { default: 'opencode', yolo: 'opencode' },
|
||||
};
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function DashboardToolbar({ activePanel, onTogglePanel }: DashboardToolbarProps) {
|
||||
@@ -94,117 +114,216 @@ export function DashboardToolbar({ activePanel, onTogglePanel }: DashboardToolba
|
||||
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
|
||||
const createSessionAndAssign = useTerminalGridStore((s) => s.createSessionAndAssign);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [selectedTool, setSelectedTool] = useState<CliTool>('gemini');
|
||||
const [launchMode, setLaunchMode] = useState<LaunchMode>('yolo');
|
||||
const [isConfigOpen, setIsConfigOpen] = useState(false);
|
||||
|
||||
const handleQuickCreate = useCallback(async () => {
|
||||
if (!focusedPaneId || !projectPath) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await createSessionAndAssign(focusedPaneId, {
|
||||
const created = await createSessionAndAssign(focusedPaneId, {
|
||||
workingDir: projectPath,
|
||||
preferredShell: 'bash',
|
||||
tool: selectedTool,
|
||||
}, projectPath);
|
||||
|
||||
if (created?.session?.sessionKey) {
|
||||
const command = LAUNCH_COMMANDS[selectedTool]?.[launchMode] ?? selectedTool;
|
||||
setTimeout(() => {
|
||||
sendCliSessionText(
|
||||
created.session.sessionKey,
|
||||
{ text: command, appendNewline: true },
|
||||
projectPath
|
||||
).catch((err) => console.error('[DashboardToolbar] auto-launch failed:', err));
|
||||
}, 300);
|
||||
}
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [focusedPaneId, projectPath, createSessionAndAssign, selectedTool, launchMode]);
|
||||
|
||||
const handleConfigure = useCallback(() => {
|
||||
setIsConfigOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateConfiguredSession = useCallback(async (config: CliSessionConfig) => {
|
||||
if (!focusedPaneId || !projectPath) throw new Error('No focused pane or project path');
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const created = await createSessionAndAssign(
|
||||
focusedPaneId,
|
||||
{
|
||||
workingDir: config.workingDir || projectPath,
|
||||
preferredShell: config.preferredShell,
|
||||
tool: config.tool,
|
||||
model: config.model,
|
||||
},
|
||||
projectPath
|
||||
);
|
||||
|
||||
if (!created?.session?.sessionKey) throw new Error('createSessionAndAssign failed');
|
||||
|
||||
const tool = config.tool as CliTool;
|
||||
const mode = config.launchMode as LaunchMode;
|
||||
const command = LAUNCH_COMMANDS[tool]?.[mode] ?? tool;
|
||||
setTimeout(() => {
|
||||
sendCliSessionText(
|
||||
created.session.sessionKey,
|
||||
{ text: command, appendNewline: true },
|
||||
projectPath
|
||||
).catch((err) => console.error('[DashboardToolbar] auto-launch failed:', err));
|
||||
}, 300);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [focusedPaneId, projectPath, createSessionAndAssign]);
|
||||
|
||||
const handleConfigure = useCallback(() => {
|
||||
// TODO: Open configuration modal (future implementation)
|
||||
console.log('Configure CLI session - modal to be implemented');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 h-[40px] border-b border-border bg-muted/30 shrink-0">
|
||||
{/* Launch CLI dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<>
|
||||
<div className="flex items-center gap-1 px-2 h-[40px] border-b border-border bg-muted/30 shrink-0">
|
||||
{/* Launch CLI dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted',
|
||||
isCreating && 'opacity-50 cursor-wait'
|
||||
)}
|
||||
disabled={isCreating || !projectPath}
|
||||
>
|
||||
{isCreating ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Terminal className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>{formatMessage({ id: 'terminalDashboard.toolbar.launchCli' })}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={4}>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="gap-2">
|
||||
<span>{formatMessage({ id: 'terminalDashboard.toolbar.tool' })}</span>
|
||||
<span className="text-xs text-muted-foreground">({selectedTool})</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={selectedTool}
|
||||
onValueChange={(v) => setSelectedTool(v as CliTool)}
|
||||
>
|
||||
{CLI_TOOLS.map((tool) => (
|
||||
<DropdownMenuRadioItem key={tool} value={tool}>
|
||||
{tool}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="gap-2">
|
||||
<span>{formatMessage({ id: 'terminalDashboard.toolbar.mode' })}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{launchMode === 'default'
|
||||
? formatMessage({ id: 'terminalDashboard.toolbar.modeDefault' })
|
||||
: formatMessage({ id: 'terminalDashboard.toolbar.modeYolo' })}
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={launchMode}
|
||||
onValueChange={(v) => setLaunchMode(v as LaunchMode)}
|
||||
>
|
||||
<DropdownMenuRadioItem value="default">
|
||||
{formatMessage({ id: 'terminalDashboard.toolbar.modeDefault' })}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="yolo">
|
||||
{formatMessage({ id: 'terminalDashboard.toolbar.modeYolo' })}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleQuickCreate}
|
||||
disabled={isCreating || !projectPath || !focusedPaneId}
|
||||
className="gap-2"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>{formatMessage({ id: 'terminalDashboard.toolbar.quickCreate' })}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleConfigure}
|
||||
disabled={isCreating || !projectPath || !focusedPaneId}
|
||||
className="gap-2"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>{formatMessage({ id: 'terminalDashboard.toolbar.configure' })}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
{/* Panel toggle buttons */}
|
||||
<ToolbarButton
|
||||
icon={AlertCircle}
|
||||
label={formatMessage({ id: 'terminalDashboard.toolbar.issues' })}
|
||||
isActive={activePanel === 'issues'}
|
||||
onClick={() => onTogglePanel('issues')}
|
||||
badge={openCount > 0 ? openCount : undefined}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={ListChecks}
|
||||
label={formatMessage({ id: 'terminalDashboard.toolbar.queue' })}
|
||||
isActive={activePanel === 'queue'}
|
||||
onClick={() => onTogglePanel('queue')}
|
||||
badge={queueCount > 0 ? queueCount : undefined}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Info}
|
||||
label={formatMessage({ id: 'terminalDashboard.toolbar.inspector' })}
|
||||
isActive={activePanel === 'inspector'}
|
||||
onClick={() => onTogglePanel('inspector')}
|
||||
dot={hasChain}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
{/* Layout presets */}
|
||||
{LAYOUT_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => handlePreset(preset.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted',
|
||||
isCreating && 'opacity-50 cursor-wait'
|
||||
'p-1.5 rounded transition-colors',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
disabled={isCreating || !projectPath}
|
||||
title={formatMessage({ id: preset.labelId })}
|
||||
>
|
||||
{isCreating ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Terminal className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>{formatMessage({ id: 'terminalDashboard.toolbar.launchCli' })}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
<preset.icon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={4}>
|
||||
<DropdownMenuItem
|
||||
onClick={handleQuickCreate}
|
||||
disabled={isCreating || !projectPath || !focusedPaneId}
|
||||
className="gap-2"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>{formatMessage({ id: 'terminalDashboard.toolbar.quickCreate' })}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleConfigure}
|
||||
disabled={isCreating}
|
||||
className="gap-2"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>{formatMessage({ id: 'terminalDashboard.toolbar.configure' })}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
))}
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
{/* Right-aligned title */}
|
||||
<span className="ml-auto text-xs text-muted-foreground font-medium">
|
||||
{formatMessage({ id: 'terminalDashboard.page.title' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Panel toggle buttons */}
|
||||
<ToolbarButton
|
||||
icon={AlertCircle}
|
||||
label={formatMessage({ id: 'terminalDashboard.toolbar.issues' })}
|
||||
isActive={activePanel === 'issues'}
|
||||
onClick={() => onTogglePanel('issues')}
|
||||
badge={openCount > 0 ? openCount : undefined}
|
||||
<CliConfigModal
|
||||
isOpen={isConfigOpen}
|
||||
onClose={() => setIsConfigOpen(false)}
|
||||
defaultWorkingDir={projectPath}
|
||||
onCreateSession={handleCreateConfiguredSession}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={ListChecks}
|
||||
label={formatMessage({ id: 'terminalDashboard.toolbar.queue' })}
|
||||
isActive={activePanel === 'queue'}
|
||||
onClick={() => onTogglePanel('queue')}
|
||||
badge={queueCount > 0 ? queueCount : undefined}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Info}
|
||||
label={formatMessage({ id: 'terminalDashboard.toolbar.inspector' })}
|
||||
isActive={activePanel === 'inspector'}
|
||||
onClick={() => onTogglePanel('inspector')}
|
||||
dot={hasChain}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
{/* Layout presets */}
|
||||
{LAYOUT_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => handlePreset(preset.id)}
|
||||
className={cn(
|
||||
'p-1.5 rounded transition-colors',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
title={formatMessage({ id: preset.labelId })}
|
||||
>
|
||||
<preset.icon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Right-aligned title */}
|
||||
<span className="ml-auto text-xs text-muted-foreground font-medium">
|
||||
{formatMessage({ id: 'terminalDashboard.page.title' })}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -145,6 +145,22 @@ export type {
|
||||
UseDeleteMemoryReturn,
|
||||
} from './useMemory';
|
||||
|
||||
// ========== Unified Memory ==========
|
||||
export {
|
||||
useUnifiedSearch,
|
||||
useUnifiedStats,
|
||||
useRecommendations,
|
||||
useReindex,
|
||||
} from './useUnifiedSearch';
|
||||
export type {
|
||||
UseUnifiedSearchOptions,
|
||||
UseUnifiedSearchReturn,
|
||||
UseUnifiedStatsReturn,
|
||||
UseRecommendationsOptions,
|
||||
UseRecommendationsReturn,
|
||||
UseReindexReturn,
|
||||
} from './useUnifiedSearch';
|
||||
|
||||
// ========== MCP Servers ==========
|
||||
export {
|
||||
useMcpServers,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { workspaceQueryKeys } from '@/lib/queryKeys';
|
||||
import { parseMemoryMetadata } from '@/lib/utils';
|
||||
|
||||
// Query key factory
|
||||
export const memoryKeys = {
|
||||
@@ -99,13 +100,8 @@ export function useMemory(options: UseMemoryOptions = {}): UseMemoryReturn {
|
||||
// Filter by favorite status (from metadata)
|
||||
if (filter?.favorite === true) {
|
||||
memories = memories.filter((m) => {
|
||||
if (!m.metadata) return false;
|
||||
try {
|
||||
const metadata = typeof m.metadata === 'string' ? JSON.parse(m.metadata) : m.metadata;
|
||||
return metadata.favorite === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const metadata = parseMemoryMetadata(m.metadata);
|
||||
return metadata.favorite === true;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
199
ccw/frontend/src/hooks/useUnifiedSearch.ts
Normal file
199
ccw/frontend/src/hooks/useUnifiedSearch.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// ========================================
|
||||
// useUnifiedSearch Hook
|
||||
// ========================================
|
||||
// TanStack Query hooks for unified memory search, stats, and recommendations
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchUnifiedSearch,
|
||||
fetchUnifiedStats,
|
||||
fetchRecommendations,
|
||||
triggerReindex,
|
||||
type UnifiedSearchResult,
|
||||
type UnifiedMemoryStats,
|
||||
type RecommendationResult,
|
||||
type ReindexResponse,
|
||||
} from '../lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { workspaceQueryKeys } from '@/lib/queryKeys';
|
||||
|
||||
// Default stale time: 1 minute
|
||||
const STALE_TIME = 60 * 1000;
|
||||
|
||||
// ========== Unified Search ==========
|
||||
|
||||
export interface UseUnifiedSearchOptions {
|
||||
query: string;
|
||||
categories?: string;
|
||||
topK?: number;
|
||||
minScore?: number;
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
}
|
||||
|
||||
export interface UseUnifiedSearchReturn {
|
||||
results: UnifiedSearchResult[];
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for unified vector + FTS5 search across all memory categories
|
||||
*/
|
||||
export function useUnifiedSearch(options: UseUnifiedSearchOptions): UseUnifiedSearchReturn {
|
||||
const {
|
||||
query,
|
||||
categories,
|
||||
topK,
|
||||
minScore,
|
||||
enabled = true,
|
||||
staleTime = STALE_TIME,
|
||||
} = options;
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
// Only enable query when projectPath exists and query is non-empty
|
||||
const queryEnabled = enabled && !!projectPath && query.trim().length > 0;
|
||||
|
||||
const result = useQuery({
|
||||
queryKey: workspaceQueryKeys.unifiedSearch(projectPath || '', query, categories),
|
||||
queryFn: () =>
|
||||
fetchUnifiedSearch(
|
||||
query,
|
||||
{ topK, minScore, category: categories },
|
||||
projectPath || undefined
|
||||
),
|
||||
staleTime,
|
||||
enabled: queryEnabled,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await result.refetch();
|
||||
};
|
||||
|
||||
return {
|
||||
results: result.data?.results ?? [],
|
||||
total: result.data?.total ?? 0,
|
||||
isLoading: result.isLoading,
|
||||
isFetching: result.isFetching,
|
||||
error: result.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Unified Stats ==========
|
||||
|
||||
export interface UseUnifiedStatsReturn {
|
||||
stats: UnifiedMemoryStats | null;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching unified memory statistics
|
||||
*/
|
||||
export function useUnifiedStats(): UseUnifiedStatsReturn {
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const queryEnabled = !!projectPath;
|
||||
|
||||
const result = useQuery({
|
||||
queryKey: workspaceQueryKeys.unifiedStats(projectPath || ''),
|
||||
queryFn: () => fetchUnifiedStats(projectPath || undefined),
|
||||
staleTime: STALE_TIME,
|
||||
enabled: queryEnabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await result.refetch();
|
||||
};
|
||||
|
||||
return {
|
||||
stats: result.data?.stats ?? null,
|
||||
isLoading: result.isLoading,
|
||||
isFetching: result.isFetching,
|
||||
error: result.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Recommendations ==========
|
||||
|
||||
export interface UseRecommendationsOptions {
|
||||
memoryId: string;
|
||||
limit?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseRecommendationsReturn {
|
||||
recommendations: RecommendationResult[];
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for KNN-based memory recommendations
|
||||
*/
|
||||
export function useRecommendations(options: UseRecommendationsOptions): UseRecommendationsReturn {
|
||||
const { memoryId, limit = 5, enabled = true } = options;
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const queryEnabled = enabled && !!projectPath && !!memoryId;
|
||||
|
||||
const result = useQuery({
|
||||
queryKey: workspaceQueryKeys.unifiedRecommendations(projectPath || '', memoryId),
|
||||
queryFn: () => fetchRecommendations(memoryId, limit, projectPath || undefined),
|
||||
staleTime: STALE_TIME,
|
||||
enabled: queryEnabled,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
return {
|
||||
recommendations: result.data?.recommendations ?? [],
|
||||
total: result.data?.total ?? 0,
|
||||
isLoading: result.isLoading,
|
||||
isFetching: result.isFetching,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Reindex Mutation ==========
|
||||
|
||||
export interface UseReindexReturn {
|
||||
reindex: () => Promise<ReindexResponse>;
|
||||
isReindexing: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for triggering vector index rebuild
|
||||
*/
|
||||
export function useReindex(): UseReindexReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => triggerReindex(projectPath || undefined),
|
||||
onSuccess: () => {
|
||||
// Invalidate unified memory cache after reindex
|
||||
if (projectPath) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceQueryKeys.unifiedMemory(projectPath),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
reindex: mutation.mutateAsync,
|
||||
isReindexing: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
@@ -6360,3 +6360,149 @@ export async function fetchCliSessionAudit(
|
||||
withPath(`/api/audit/cli-sessions${queryString ? `?${queryString}` : ''}`, options?.projectPath)
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Unified Memory API ==========
|
||||
|
||||
export interface UnifiedSearchResult {
|
||||
source_id: string;
|
||||
source_type: string;
|
||||
score: number;
|
||||
content: string;
|
||||
category: string;
|
||||
rank_sources: {
|
||||
vector_rank?: number;
|
||||
vector_score?: number;
|
||||
fts_rank?: number;
|
||||
heat_score?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UnifiedSearchResponse {
|
||||
success: boolean;
|
||||
query: string;
|
||||
total: number;
|
||||
results: UnifiedSearchResult[];
|
||||
}
|
||||
|
||||
export interface UnifiedMemoryStats {
|
||||
core_memories: {
|
||||
total: number;
|
||||
archived: number;
|
||||
};
|
||||
stage1_outputs: number;
|
||||
entities: number;
|
||||
prompts: number;
|
||||
conversations: number;
|
||||
vector_index: {
|
||||
available: boolean;
|
||||
total_chunks: number;
|
||||
hnsw_available: boolean;
|
||||
hnsw_count: number;
|
||||
dimension: number;
|
||||
categories?: Record<string, number>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecommendationResult {
|
||||
source_id: string;
|
||||
source_type: string;
|
||||
score: number;
|
||||
content: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface ReindexResponse {
|
||||
success: boolean;
|
||||
hnsw_count?: number;
|
||||
elapsed_time?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search unified memory using vector + FTS5 fusion (RRF)
|
||||
* @param query - Search query text
|
||||
* @param options - Search options (topK, minScore, category)
|
||||
* @param projectPath - Optional project path for workspace isolation
|
||||
*/
|
||||
export async function fetchUnifiedSearch(
|
||||
query: string,
|
||||
options?: {
|
||||
topK?: number;
|
||||
minScore?: number;
|
||||
category?: string;
|
||||
},
|
||||
projectPath?: string
|
||||
): Promise<UnifiedSearchResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('q', query);
|
||||
if (options?.topK) params.set('topK', String(options.topK));
|
||||
if (options?.minScore) params.set('minScore', String(options.minScore));
|
||||
if (options?.category) params.set('category', options.category);
|
||||
|
||||
const data = await fetchApi<UnifiedSearchResponse & { error?: string }>(
|
||||
withPath(`/api/unified-memory/search?${params.toString()}`, projectPath)
|
||||
);
|
||||
if (data.success === false) {
|
||||
throw new Error(data.error || 'Search failed');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch unified memory statistics (core memories, entities, vectors, etc.)
|
||||
* @param projectPath - Optional project path for workspace isolation
|
||||
*/
|
||||
export async function fetchUnifiedStats(
|
||||
projectPath?: string
|
||||
): Promise<{ success: boolean; stats: UnifiedMemoryStats }> {
|
||||
const data = await fetchApi<{ success: boolean; stats: UnifiedMemoryStats; error?: string }>(
|
||||
withPath('/api/unified-memory/stats', projectPath)
|
||||
);
|
||||
if (data.success === false) {
|
||||
throw new Error(data.error || 'Failed to load unified stats');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KNN-based recommendations for a specific memory
|
||||
* @param memoryId - Core memory ID (CMEM-*)
|
||||
* @param limit - Number of recommendations (default: 5)
|
||||
* @param projectPath - Optional project path for workspace isolation
|
||||
*/
|
||||
export async function fetchRecommendations(
|
||||
memoryId: string,
|
||||
limit?: number,
|
||||
projectPath?: string
|
||||
): Promise<{ success: boolean; memory_id: string; total: number; recommendations: RecommendationResult[] }> {
|
||||
const params = new URLSearchParams();
|
||||
if (limit) params.set('limit', String(limit));
|
||||
const queryString = params.toString();
|
||||
|
||||
const data = await fetchApi<{ success: boolean; memory_id: string; total: number; recommendations: RecommendationResult[]; error?: string }>(
|
||||
withPath(
|
||||
`/api/unified-memory/recommendations/${encodeURIComponent(memoryId)}${queryString ? `?${queryString}` : ''}`,
|
||||
projectPath
|
||||
)
|
||||
);
|
||||
if (data.success === false) {
|
||||
throw new Error(data.error || 'Failed to load recommendations');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger vector index rebuild
|
||||
* @param projectPath - Optional project path for workspace isolation
|
||||
*/
|
||||
export async function triggerReindex(
|
||||
projectPath?: string
|
||||
): Promise<ReindexResponse> {
|
||||
return fetchApi<ReindexResponse>(
|
||||
'/api/unified-memory/reindex',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: projectPath }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,6 +130,15 @@ export const workspaceQueryKeys = {
|
||||
offset?: number;
|
||||
}
|
||||
) => [...workspaceQueryKeys.audit(projectPath), 'cliSessions', options] as const,
|
||||
|
||||
// ========== Unified Memory ==========
|
||||
unifiedMemory: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'unifiedMemory'] as const,
|
||||
unifiedSearch: (projectPath: string, query: string, categories?: string) =>
|
||||
[...workspaceQueryKeys.unifiedMemory(projectPath), 'search', query, categories] as const,
|
||||
unifiedStats: (projectPath: string) =>
|
||||
[...workspaceQueryKeys.unifiedMemory(projectPath), 'stats'] as const,
|
||||
unifiedRecommendations: (projectPath: string, memoryId: string) =>
|
||||
[...workspaceQueryKeys.unifiedMemory(projectPath), 'recommendations', memoryId] as const,
|
||||
};
|
||||
|
||||
// ========== API Settings Keys ==========
|
||||
|
||||
@@ -14,4 +14,20 @@ export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse memory metadata from string, object, or undefined.
|
||||
* Returns an empty object on parse failure or missing input.
|
||||
*/
|
||||
export function parseMemoryMetadata(
|
||||
metadata: string | Record<string, any> | undefined | null
|
||||
): Record<string, any> {
|
||||
if (!metadata) return {};
|
||||
if (typeof metadata === 'object') return metadata;
|
||||
try {
|
||||
return JSON.parse(metadata);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export type { ClassValue };
|
||||
|
||||
@@ -22,19 +22,31 @@
|
||||
"tabs": {
|
||||
"memories": "Memories",
|
||||
"favorites": "Favorites",
|
||||
"archived": "Archived"
|
||||
"archived": "Archived",
|
||||
"unifiedSearch": "Unified Search"
|
||||
},
|
||||
"stats": {
|
||||
"totalSize": "Total Size",
|
||||
"count": "Count",
|
||||
"claudeMdCount": "CLAUDE.md Files",
|
||||
"totalEntries": "Total Entries"
|
||||
"totalEntries": "Total Entries",
|
||||
"vectorChunks": "Vector Chunks",
|
||||
"hnswStatus": "HNSW Index",
|
||||
"entities": "Entities",
|
||||
"prompts": "Prompts"
|
||||
},
|
||||
"filters": {
|
||||
"search": "Search memories...",
|
||||
"searchUnified": "Semantic search across all memory types...",
|
||||
"tags": "Tags",
|
||||
"clear": "Clear",
|
||||
"all": "All"
|
||||
"all": "All",
|
||||
"categoryAll": "All Categories",
|
||||
"categoryCoreMemory": "Core Memory",
|
||||
"categoryCliHistory": "CLI History",
|
||||
"categoryWorkflow": "Workflow",
|
||||
"categoryEntity": "Entity",
|
||||
"categoryPattern": "Pattern"
|
||||
},
|
||||
"card": {
|
||||
"id": "ID",
|
||||
@@ -82,5 +94,20 @@
|
||||
"coreMemory": "Core Memory",
|
||||
"workflow": "Workflow",
|
||||
"cliHistory": "CLI History"
|
||||
},
|
||||
"unified": {
|
||||
"score": "Score",
|
||||
"noResults": "No results found. Try a different search query.",
|
||||
"searching": "Searching...",
|
||||
"resultCount": "{count} results",
|
||||
"recommendations": "Related",
|
||||
"noRecommendations": "No recommendations available",
|
||||
"reindex": "Rebuild Index",
|
||||
"reindexing": "Rebuilding...",
|
||||
"reindexSuccess": "Index rebuilt successfully",
|
||||
"reindexError": "Failed to rebuild index",
|
||||
"vectorRank": "Vector #{rank}",
|
||||
"ftsRank": "FTS #{rank}",
|
||||
"heatScore": "Heat: {score}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,45 @@
|
||||
"on": "On",
|
||||
"off": "Off"
|
||||
},
|
||||
"remoteNotifications": {
|
||||
"title": "Remote Notifications",
|
||||
"description": "Send notifications to external platforms like Discord, Telegram, or custom webhooks when events occur.",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"platforms": "Platform Configuration",
|
||||
"events": "Event Triggers",
|
||||
"noPlatforms": "No platforms",
|
||||
"configured": "Configured",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"saved": "Configuration saved",
|
||||
"saveError": "Failed to save configuration",
|
||||
"reset": "Reset to Defaults",
|
||||
"resetConfirm": "Reset all remote notification settings to defaults?",
|
||||
"resetSuccess": "Settings reset to defaults",
|
||||
"resetError": "Failed to reset settings",
|
||||
"testConnection": "Test Connection",
|
||||
"testSuccess": "Test notification sent successfully",
|
||||
"testFailed": "Test notification failed",
|
||||
"testError": "Failed to send test notification",
|
||||
"discord": {
|
||||
"webhookUrl": "Webhook URL",
|
||||
"webhookUrlHint": "Create a webhook in your Discord channel settings",
|
||||
"username": "Custom Username (optional)"
|
||||
},
|
||||
"telegram": {
|
||||
"botToken": "Bot Token",
|
||||
"botTokenHint": "Get from @BotFather on Telegram",
|
||||
"chatId": "Chat ID",
|
||||
"chatIdHint": "User or group chat ID (use @userinfobot to find it)"
|
||||
},
|
||||
"webhook": {
|
||||
"url": "Webhook URL",
|
||||
"method": "HTTP Method",
|
||||
"headers": "Custom Headers (JSON)",
|
||||
"headersHint": "Optional JSON object with custom headers"
|
||||
}
|
||||
},
|
||||
"versionCheck": {
|
||||
"title": "Version Update",
|
||||
"currentVersion": "Current Version",
|
||||
|
||||
@@ -22,19 +22,31 @@
|
||||
"tabs": {
|
||||
"memories": "记忆",
|
||||
"favorites": "收藏",
|
||||
"archived": "归档"
|
||||
"archived": "归档",
|
||||
"unifiedSearch": "统一搜索"
|
||||
},
|
||||
"stats": {
|
||||
"totalSize": "总大小",
|
||||
"count": "数量",
|
||||
"claudeMdCount": "CLAUDE.md 文件",
|
||||
"totalEntries": "总条目"
|
||||
"totalEntries": "总条目",
|
||||
"vectorChunks": "向量块",
|
||||
"hnswStatus": "HNSW 索引",
|
||||
"entities": "实体",
|
||||
"prompts": "提示"
|
||||
},
|
||||
"filters": {
|
||||
"search": "搜索记忆...",
|
||||
"searchUnified": "跨所有记忆类型语义搜索...",
|
||||
"tags": "标签",
|
||||
"clear": "清除",
|
||||
"all": "全部"
|
||||
"all": "全部",
|
||||
"categoryAll": "所有类别",
|
||||
"categoryCoreMemory": "核心记忆",
|
||||
"categoryCliHistory": "CLI 历史",
|
||||
"categoryWorkflow": "工作流",
|
||||
"categoryEntity": "实体",
|
||||
"categoryPattern": "模式"
|
||||
},
|
||||
"card": {
|
||||
"id": "ID",
|
||||
@@ -82,5 +94,20 @@
|
||||
"coreMemory": "核心记忆",
|
||||
"workflow": "工作流",
|
||||
"cliHistory": "CLI 历史"
|
||||
},
|
||||
"unified": {
|
||||
"score": "分数",
|
||||
"noResults": "未找到结果。请尝试不同的搜索查询。",
|
||||
"searching": "搜索中...",
|
||||
"resultCount": "{count} 条结果",
|
||||
"recommendations": "相关",
|
||||
"noRecommendations": "暂无推荐",
|
||||
"reindex": "重建索引",
|
||||
"reindexing": "重建中...",
|
||||
"reindexSuccess": "索引重建成功",
|
||||
"reindexError": "索引重建失败",
|
||||
"vectorRank": "向量 #{rank}",
|
||||
"ftsRank": "全文 #{rank}",
|
||||
"heatScore": "热度: {score}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,45 @@
|
||||
"on": "开启",
|
||||
"off": "关闭"
|
||||
},
|
||||
"remoteNotifications": {
|
||||
"title": "远程通知",
|
||||
"description": "当事件发生时,发送通知到 Discord、Telegram 或自定义 Webhook 等外部平台。",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"platforms": "平台配置",
|
||||
"events": "事件触发器",
|
||||
"noPlatforms": "无平台",
|
||||
"configured": "已配置",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"saved": "配置已保存",
|
||||
"saveError": "保存配置失败",
|
||||
"reset": "重置为默认值",
|
||||
"resetConfirm": "确定要将所有远程通知设置重置为默认值吗?",
|
||||
"resetSuccess": "设置已重置为默认值",
|
||||
"resetError": "重置设置失败",
|
||||
"testConnection": "测试连接",
|
||||
"testSuccess": "测试通知发送成功",
|
||||
"testFailed": "测试通知发送失败",
|
||||
"testError": "发送测试通知失败",
|
||||
"discord": {
|
||||
"webhookUrl": "Webhook URL",
|
||||
"webhookUrlHint": "在 Discord 频道设置中创建 Webhook",
|
||||
"username": "自定义用户名(可选)"
|
||||
},
|
||||
"telegram": {
|
||||
"botToken": "Bot Token",
|
||||
"botTokenHint": "从 Telegram 的 @BotFather 获取",
|
||||
"chatId": "Chat ID",
|
||||
"chatIdHint": "用户或群组 Chat ID(使用 @userinfobot 查找)"
|
||||
},
|
||||
"webhook": {
|
||||
"url": "Webhook URL",
|
||||
"method": "HTTP 方法",
|
||||
"headers": "自定义请求头(JSON)",
|
||||
"headersHint": "可选的 JSON 对象,包含自定义请求头"
|
||||
}
|
||||
},
|
||||
"versionCheck": {
|
||||
"title": "版本更新",
|
||||
"currentVersion": "当前版本",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Memory Page
|
||||
// ========================================
|
||||
// View and manage core memory and context with CRUD operations
|
||||
// Includes unified vector search across all memory categories
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
@@ -22,6 +23,11 @@ import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
AlertCircle,
|
||||
Layers,
|
||||
Zap,
|
||||
Terminal,
|
||||
GitBranch,
|
||||
Hash,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -30,9 +36,39 @@ import { Badge } from '@/components/ui/Badge';
|
||||
import { TabsNavigation } from '@/components/ui/TabsNavigation';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||
import { Checkbox } from '@/components/ui/Checkbox';
|
||||
import { useMemory, useMemoryMutations } from '@/hooks';
|
||||
import type { CoreMemory } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useMemory, useMemoryMutations, useUnifiedSearch, useUnifiedStats, useRecommendations, useReindex } from '@/hooks';
|
||||
import type { CoreMemory, UnifiedSearchResult } from '@/lib/api';
|
||||
import { cn, parseMemoryMetadata } from '@/lib/utils';
|
||||
|
||||
// ========== Source Type Helpers ==========
|
||||
|
||||
const SOURCE_TYPE_COLORS: Record<string, string> = {
|
||||
core_memory: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
cli_history: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
|
||||
workflow: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
entity: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
pattern: 'bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-300',
|
||||
};
|
||||
|
||||
const SOURCE_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
core_memory: <Brain className="w-3 h-3" />,
|
||||
cli_history: <Terminal className="w-3 h-3" />,
|
||||
workflow: <GitBranch className="w-3 h-3" />,
|
||||
entity: <Hash className="w-3 h-3" />,
|
||||
pattern: <Layers className="w-3 h-3" />,
|
||||
};
|
||||
|
||||
function SourceTypeBadge({ sourceType }: { sourceType: string }) {
|
||||
const colorClass = SOURCE_TYPE_COLORS[sourceType] || 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300';
|
||||
const icon = SOURCE_TYPE_ICONS[sourceType] || <Database className="w-3 h-3" />;
|
||||
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium', colorClass)}>
|
||||
{icon}
|
||||
{sourceType}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Memory Card Component ==========
|
||||
|
||||
@@ -51,7 +87,7 @@ function MemoryCard({ memory, onView, onEdit, onDelete, onCopy, onToggleFavorite
|
||||
const formattedDate = new Date(memory.createdAt).toLocaleDateString();
|
||||
|
||||
// Parse metadata from memory
|
||||
const metadata = memory.metadata ? (typeof memory.metadata === 'string' ? JSON.parse(memory.metadata) : memory.metadata) : {};
|
||||
const metadata = parseMemoryMetadata(memory.metadata);
|
||||
const isFavorite = metadata.favorite === true;
|
||||
const priority = metadata.priority || 'medium';
|
||||
const isArchived = memory.archived || false;
|
||||
@@ -197,6 +233,138 @@ function MemoryCard({ memory, onView, onEdit, onDelete, onCopy, onToggleFavorite
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Unified Search Result Card ==========
|
||||
|
||||
interface UnifiedResultCardProps {
|
||||
result: UnifiedSearchResult;
|
||||
onCopy: (content: string) => void;
|
||||
}
|
||||
|
||||
function UnifiedResultCard({ result, onCopy }: UnifiedResultCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const scorePercent = (result.score * 100).toFixed(1);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
<SourceTypeBadge sourceType={result.source_type} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{result.source_id}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{formatMessage({ id: 'memory.unified.score' })}: {scorePercent}%
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Rank sources */}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{result.rank_sources.vector_rank != null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'memory.unified.vectorRank' }, { rank: result.rank_sources.vector_rank })}
|
||||
</span>
|
||||
)}
|
||||
{result.rank_sources.fts_rank != null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'memory.unified.ftsRank' }, { rank: result.rank_sources.fts_rank })}
|
||||
</span>
|
||||
)}
|
||||
{result.rank_sources.heat_score != null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'memory.unified.heatScore' }, { score: result.rank_sources.heat_score.toFixed(2) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 shrink-0"
|
||||
onClick={() => onCopy(result.content)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content preview */}
|
||||
<p className="text-sm text-muted-foreground mt-2 line-clamp-3">
|
||||
{result.content}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Recommendations Panel ==========
|
||||
|
||||
interface RecommendationsPanelProps {
|
||||
memoryId: string;
|
||||
onCopy: (content: string) => void;
|
||||
}
|
||||
|
||||
function RecommendationsPanel({ memoryId, onCopy }: RecommendationsPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { recommendations, isLoading } = useRecommendations({
|
||||
memoryId,
|
||||
limit: 5,
|
||||
enabled: !!memoryId,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground py-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">{formatMessage({ id: 'memory.unified.searching' })}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (recommendations.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
{formatMessage({ id: 'memory.unified.noRecommendations' })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{recommendations.map((rec) => (
|
||||
<div
|
||||
key={rec.source_id}
|
||||
className="flex items-start gap-2 p-2 rounded-md bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<SourceTypeBadge sourceType={rec.source_type} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-foreground truncate">
|
||||
{rec.source_id}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{(rec.score * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
|
||||
{rec.content}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
onClick={() => onCopy(rec.content)}
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== View Memory Dialog ==========
|
||||
|
||||
interface ViewMemoryDialogProps {
|
||||
@@ -211,7 +379,7 @@ function ViewMemoryDialog({ memory, open, onOpenChange, onEdit, onCopy }: ViewMe
|
||||
const { formatMessage } = useIntl();
|
||||
if (!memory) return null;
|
||||
|
||||
const metadata = memory.metadata ? (typeof memory.metadata === 'string' ? JSON.parse(memory.metadata) : memory.metadata) : {};
|
||||
const metadata = parseMemoryMetadata(memory.metadata);
|
||||
const priority = metadata.priority || 'medium';
|
||||
const formattedDate = new Date(memory.createdAt).toLocaleDateString();
|
||||
const formattedSize = memory.size
|
||||
@@ -264,6 +432,15 @@ function ViewMemoryDialog({ memory, open, onOpenChange, onEdit, onCopy }: ViewMe
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
<div className="pt-2 border-t border-border">
|
||||
<h4 className="text-sm font-medium text-foreground flex items-center gap-1.5 mb-2">
|
||||
<Zap className="w-4 h-4 text-primary" />
|
||||
{formatMessage({ id: 'memory.unified.recommendations' })}
|
||||
</h4>
|
||||
<RecommendationsPanel memoryId={memory.id} onCopy={onCopy} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-border">
|
||||
<Button variant="outline" size="sm" onClick={() => onCopy(memory.content)}>
|
||||
@@ -311,21 +488,9 @@ function NewMemoryDialog({
|
||||
setTagsInput(editingMemory.tags?.join(', ') || '');
|
||||
|
||||
// Sync metadata
|
||||
if (editingMemory.metadata) {
|
||||
try {
|
||||
const metadata = typeof editingMemory.metadata === 'string'
|
||||
? JSON.parse(editingMemory.metadata)
|
||||
: editingMemory.metadata;
|
||||
setIsFavorite(metadata.favorite === true);
|
||||
setPriority(metadata.priority || 'medium');
|
||||
} catch {
|
||||
setIsFavorite(false);
|
||||
setPriority('medium');
|
||||
}
|
||||
} else {
|
||||
setIsFavorite(false);
|
||||
setPriority('medium');
|
||||
}
|
||||
const metadata = parseMemoryMetadata(editingMemory.metadata);
|
||||
setIsFavorite(metadata.favorite === true);
|
||||
setPriority(metadata.priority || 'medium');
|
||||
} else {
|
||||
// New mode: reset all state
|
||||
setContent('');
|
||||
@@ -436,6 +601,17 @@ function NewMemoryDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Category Filter ==========
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ value: '', labelId: 'memory.filters.categoryAll' },
|
||||
{ value: 'core_memory', labelId: 'memory.filters.categoryCoreMemory' },
|
||||
{ value: 'cli_history', labelId: 'memory.filters.categoryCliHistory' },
|
||||
{ value: 'workflow', labelId: 'memory.filters.categoryWorkflow' },
|
||||
{ value: 'entity', labelId: 'memory.filters.categoryEntity' },
|
||||
{ value: 'pattern', labelId: 'memory.filters.categoryPattern' },
|
||||
];
|
||||
|
||||
// ========== Main Page Component ==========
|
||||
|
||||
export function MemoryPage() {
|
||||
@@ -445,9 +621,13 @@ export function MemoryPage() {
|
||||
const [isNewMemoryOpen, setIsNewMemoryOpen] = useState(false);
|
||||
const [editingMemory, setEditingMemory] = useState<CoreMemory | null>(null);
|
||||
const [viewingMemory, setViewingMemory] = useState<CoreMemory | null>(null);
|
||||
const [currentTab, setCurrentTab] = useState<'memories' | 'favorites' | 'archived'>('memories');
|
||||
const [currentTab, setCurrentTab] = useState<'memories' | 'favorites' | 'archived' | 'unifiedSearch'>('memories');
|
||||
const [unifiedQuery, setUnifiedQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('');
|
||||
|
||||
// Build filter based on current tab
|
||||
const isUnifiedTab = currentTab === 'unifiedSearch';
|
||||
|
||||
// Build filter based on current tab (for non-unified tabs)
|
||||
const favoriteFilter = currentTab === 'favorites' ? { favorite: true } : undefined;
|
||||
const archivedFilter = currentTab === 'archived' ? { archived: true } : { archived: false };
|
||||
|
||||
@@ -467,8 +647,34 @@ export function MemoryPage() {
|
||||
...favoriteFilter,
|
||||
...archivedFilter,
|
||||
},
|
||||
enabled: !isUnifiedTab,
|
||||
});
|
||||
|
||||
// Unified search
|
||||
const {
|
||||
results: unifiedResults,
|
||||
total: unifiedTotal,
|
||||
isLoading: unifiedLoading,
|
||||
isFetching: unifiedFetching,
|
||||
error: unifiedError,
|
||||
refetch: refetchUnified,
|
||||
} = useUnifiedSearch({
|
||||
query: unifiedQuery,
|
||||
categories: selectedCategory || undefined,
|
||||
topK: 20,
|
||||
enabled: isUnifiedTab && unifiedQuery.trim().length > 0,
|
||||
});
|
||||
|
||||
// Unified stats
|
||||
const {
|
||||
stats: unifiedStats,
|
||||
isLoading: statsLoading,
|
||||
refetch: refetchStats,
|
||||
} = useUnifiedStats();
|
||||
|
||||
// Reindex mutation
|
||||
const { reindex, isReindexing } = useReindex();
|
||||
|
||||
const { createMemory, updateMemory, deleteMemory, archiveMemory, unarchiveMemory, isCreating, isUpdating } =
|
||||
useMemoryMutations();
|
||||
|
||||
@@ -495,9 +701,7 @@ export function MemoryPage() {
|
||||
|
||||
const handleToggleFavorite = async (memory: CoreMemory) => {
|
||||
try {
|
||||
const currentMetadata = memory.metadata
|
||||
? (typeof memory.metadata === 'string' ? JSON.parse(memory.metadata) : memory.metadata)
|
||||
: {};
|
||||
const currentMetadata = parseMemoryMetadata(memory.metadata);
|
||||
const newFavorite = !(currentMetadata.favorite === true);
|
||||
await updateMemory(memory.id, {
|
||||
content: memory.content,
|
||||
@@ -544,6 +748,17 @@ export function MemoryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReindex = async () => {
|
||||
try {
|
||||
await reindex();
|
||||
toast.success(formatMessage({ id: 'memory.unified.reindexSuccess' }));
|
||||
refetchStats();
|
||||
} catch (err) {
|
||||
console.error('Failed to reindex:', err);
|
||||
toast.error(formatMessage({ id: 'memory.unified.reindexError' }));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags((prev) =>
|
||||
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||
@@ -556,6 +771,18 @@ export function MemoryPage() {
|
||||
? `${(totalSize / 1024).toFixed(1)} KB`
|
||||
: `${(totalSize / (1024 * 1024)).toFixed(1)} MB`;
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (isUnifiedTab) {
|
||||
refetchUnified();
|
||||
refetchStats();
|
||||
} else {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const isRefreshing = isUnifiedTab ? unifiedFetching : isFetching;
|
||||
const activeError = isUnifiedTab ? unifiedError : error;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
@@ -570,21 +797,37 @@ export function MemoryPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{isUnifiedTab && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReindex}
|
||||
disabled={isReindexing}
|
||||
>
|
||||
{isReindexing ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{formatMessage({ id: isReindexing ? 'memory.unified.reindexing' : 'memory.unified.reindex' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isRefreshing && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
<Button onClick={() => { setEditingMemory(null); setIsNewMemoryOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'memory.actions.add' })}
|
||||
</Button>
|
||||
{!isUnifiedTab && (
|
||||
<Button onClick={() => { setEditingMemory(null); setIsNewMemoryOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'memory.actions.add' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation - styled like LiteTasksPage */}
|
||||
{/* Tab Navigation */}
|
||||
<TabsNavigation
|
||||
value={currentTab}
|
||||
onValueChange={(v) => setCurrentTab(v as 'memories' | 'favorites' | 'archived')}
|
||||
onValueChange={(v) => setCurrentTab(v as typeof currentTab)}
|
||||
tabs={[
|
||||
{
|
||||
value: 'memories',
|
||||
@@ -601,141 +844,285 @@ export function MemoryPage() {
|
||||
label: formatMessage({ id: 'memory.tabs.archived' }),
|
||||
icon: <Archive className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: 'unifiedSearch',
|
||||
label: formatMessage({ id: 'memory.tabs.unifiedSearch' }),
|
||||
icon: <Search className="h-4 w-4" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Error alert */}
|
||||
{error && (
|
||||
{activeError && (
|
||||
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
|
||||
<p className="text-xs mt-0.5">{error.message}</p>
|
||||
<p className="text-xs mt-0.5">{activeError.message}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
{formatMessage({ id: 'home.errors.retry' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Database className="w-5 h-5 text-primary" />
|
||||
{isUnifiedTab ? (
|
||||
/* Unified Stats Cards */
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Database className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{statsLoading ? '-' : (unifiedStats?.core_memories.total ?? 0)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{formatMessage({ id: 'memory.stats.count' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">{memories.length}</div>
|
||||
<p className="text-sm text-muted-foreground">{formatMessage({ id: 'memory.stats.count' })}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-orange-500/10">
|
||||
<Hash className="w-5 h-5 text-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{statsLoading ? '-' : (unifiedStats?.entities ?? 0)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{formatMessage({ id: 'memory.stats.entities' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-info/10">
|
||||
<FileText className="w-5 h-5 text-info" />
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-500/10">
|
||||
<Layers className="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{statsLoading ? '-' : (unifiedStats?.vector_index.total_chunks ?? 0)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{formatMessage({ id: 'memory.stats.vectorChunks' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">{claudeMdCount}</div>
|
||||
<p className="text-sm text-muted-foreground">{formatMessage({ id: 'memory.stats.claudeMdCount' })}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"p-2 rounded-lg",
|
||||
unifiedStats?.vector_index.hnsw_available ? "bg-green-500/10" : "bg-muted"
|
||||
)}>
|
||||
<Zap className={cn(
|
||||
"w-5 h-5",
|
||||
unifiedStats?.vector_index.hnsw_available ? "text-green-500" : "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{statsLoading ? '-' : (unifiedStats?.vector_index.hnsw_available ? unifiedStats.vector_index.hnsw_count : 'N/A')}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{formatMessage({ id: 'memory.stats.hnswStatus' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-success/10">
|
||||
<Brain className="w-5 h-5 text-success" />
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
/* Standard Stats Cards */
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Database className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">{memories.length}</div>
|
||||
<p className="text-sm text-muted-foreground">{formatMessage({ id: 'memory.stats.count' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">{formattedTotalSize}</div>
|
||||
<p className="text-sm text-muted-foreground">{formatMessage({ id: 'memory.stats.totalSize' })}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-info/10">
|
||||
<FileText className="w-5 h-5 text-info" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">{claudeMdCount}</div>
|
||||
<p className="text-sm text-muted-foreground">{formatMessage({ id: 'memory.stats.claudeMdCount' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-success/10">
|
||||
<Brain className="w-5 h-5 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">{formattedTotalSize}</div>
|
||||
<p className="text-sm text-muted-foreground">{formatMessage({ id: 'memory.stats.totalSize' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={formatMessage({ id: 'memory.filters.search' })}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags Filter */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-sm text-muted-foreground py-1">{formatMessage({ id: 'memory.card.tags' })}:</span>
|
||||
{allTags.map((tag) => (
|
||||
<Button
|
||||
key={tag}
|
||||
variant={selectedTags.includes(tag) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={() => toggleTag(tag)}
|
||||
>
|
||||
<Tag className="w-3 h-3 mr-1" />
|
||||
{tag}
|
||||
</Button>
|
||||
))}
|
||||
{selectedTags.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={() => setSelectedTags([])}
|
||||
>
|
||||
{formatMessage({ id: 'memory.filters.clear' })}
|
||||
</Button>
|
||||
)}
|
||||
{isUnifiedTab ? (
|
||||
/* Unified Search Input + Category Filter */
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={formatMessage({ id: 'memory.filters.searchUnified' })}
|
||||
value={unifiedQuery}
|
||||
onChange={(e) => setUnifiedQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="px-3 py-2 bg-background border border-input rounded-md text-sm min-w-[160px]"
|
||||
>
|
||||
{CATEGORY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{formatMessage({ id: opt.labelId })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Memory List */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="h-64 bg-muted animate-pulse rounded-lg" />
|
||||
))}
|
||||
{unifiedQuery.trim().length > 0 && !unifiedLoading && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'memory.unified.resultCount' }, { count: unifiedTotal })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : memories.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Brain className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||
{formatMessage({ id: 'memory.emptyState.title' })}
|
||||
</h3>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{formatMessage({ id: 'memory.emptyState.message' })}
|
||||
</p>
|
||||
<Button className="mt-4" onClick={() => { setEditingMemory(null); setIsNewMemoryOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'memory.emptyState.createFirst' })}
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{memories.map((memory) => (
|
||||
<MemoryCard
|
||||
key={memory.id}
|
||||
memory={memory}
|
||||
onView={setViewingMemory}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCopy={copyToClipboard}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onArchive={handleArchive}
|
||||
onUnarchive={handleUnarchive}
|
||||
/* Standard Search + Tag Filters */
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={formatMessage({ id: 'memory.filters.search' })}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tags Filter */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-sm text-muted-foreground py-1">{formatMessage({ id: 'memory.card.tags' })}:</span>
|
||||
{allTags.map((tag) => (
|
||||
<Button
|
||||
key={tag}
|
||||
variant={selectedTags.includes(tag) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={() => toggleTag(tag)}
|
||||
>
|
||||
<Tag className="w-3 h-3 mr-1" />
|
||||
{tag}
|
||||
</Button>
|
||||
))}
|
||||
{selectedTags.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={() => setSelectedTags([])}
|
||||
>
|
||||
{formatMessage({ id: 'memory.filters.clear' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Area */}
|
||||
{isUnifiedTab ? (
|
||||
/* Unified Search Results */
|
||||
unifiedLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary mr-2" />
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'memory.unified.searching' })}
|
||||
</span>
|
||||
</div>
|
||||
) : unifiedQuery.trim().length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Search className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||
{formatMessage({ id: 'memory.tabs.unifiedSearch' })}
|
||||
</h3>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{formatMessage({ id: 'memory.filters.searchUnified' })}
|
||||
</p>
|
||||
</Card>
|
||||
) : unifiedResults.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Search className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||
{formatMessage({ id: 'memory.unified.noResults' })}
|
||||
</h3>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{unifiedResults.map((result) => (
|
||||
<UnifiedResultCard
|
||||
key={`${result.source_type}-${result.source_id}`}
|
||||
result={result}
|
||||
onCopy={copyToClipboard}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
/* Standard Memory List */
|
||||
isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="h-64 bg-muted animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : memories.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Brain className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||
{formatMessage({ id: 'memory.emptyState.title' })}
|
||||
</h3>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{formatMessage({ id: 'memory.emptyState.message' })}
|
||||
</p>
|
||||
<Button className="mt-4" onClick={() => { setEditingMemory(null); setIsNewMemoryOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'memory.emptyState.createFirst' })}
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{memories.map((memory) => (
|
||||
<MemoryCard
|
||||
key={memory.id}
|
||||
memory={memory}
|
||||
onView={setViewingMemory}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCopy={copyToClipboard}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onArchive={handleArchive}
|
||||
onUnarchive={handleUnarchive}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* View Memory Dialog */}
|
||||
<ViewMemoryDialog
|
||||
memory={viewingMemory}
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
useCcwInstallations,
|
||||
useUpgradeCcwInstallation,
|
||||
} from '@/hooks/useSystemSettings';
|
||||
import { RemoteNotificationSection } from '@/components/settings/RemoteNotificationSection';
|
||||
|
||||
// ========== File Path Input with Native File Picker ==========
|
||||
|
||||
@@ -1299,6 +1300,9 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Remote Notifications */}
|
||||
<RemoteNotificationSection />
|
||||
|
||||
{/* Reset Settings */}
|
||||
<Card className="p-6 border-destructive/50">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||
|
||||
193
ccw/frontend/src/types/remote-notification.ts
Normal file
193
ccw/frontend/src/types/remote-notification.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
// ========================================
|
||||
// Remote Notification Types (Frontend)
|
||||
// ========================================
|
||||
// Type definitions for remote notification system UI
|
||||
// Mirrors backend types with UI-specific additions
|
||||
|
||||
/**
|
||||
* Supported notification platforms
|
||||
*/
|
||||
export type NotificationPlatform = 'discord' | 'telegram' | 'webhook';
|
||||
|
||||
/**
|
||||
* Event types that can trigger notifications
|
||||
*/
|
||||
export type NotificationEventType =
|
||||
| 'ask-user-question'
|
||||
| 'session-start'
|
||||
| 'session-end'
|
||||
| 'task-completed'
|
||||
| 'task-failed';
|
||||
|
||||
/**
|
||||
* Discord platform configuration
|
||||
*/
|
||||
export interface DiscordConfig {
|
||||
enabled: boolean;
|
||||
webhookUrl: string;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram platform configuration
|
||||
*/
|
||||
export interface TelegramConfig {
|
||||
enabled: boolean;
|
||||
botToken: string;
|
||||
chatId: string;
|
||||
parseMode?: 'HTML' | 'Markdown' | 'MarkdownV2';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic Webhook platform configuration
|
||||
*/
|
||||
export interface WebhookConfig {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
method: 'POST' | 'PUT';
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event configuration
|
||||
*/
|
||||
export interface EventConfig {
|
||||
event: NotificationEventType;
|
||||
platforms: NotificationPlatform[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full remote notification configuration
|
||||
*/
|
||||
export interface RemoteNotificationConfig {
|
||||
enabled: boolean;
|
||||
platforms: {
|
||||
discord?: DiscordConfig;
|
||||
telegram?: TelegramConfig;
|
||||
webhook?: WebhookConfig;
|
||||
};
|
||||
events: EventConfig[];
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test notification request
|
||||
*/
|
||||
export interface TestNotificationRequest {
|
||||
platform: NotificationPlatform;
|
||||
config: DiscordConfig | TelegramConfig | WebhookConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test notification result
|
||||
*/
|
||||
export interface TestNotificationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
responseTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform display info
|
||||
*/
|
||||
export interface PlatformInfo {
|
||||
id: NotificationPlatform;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
requiredFields: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Event display info
|
||||
*/
|
||||
export interface EventInfo {
|
||||
id: NotificationEventType;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined platform information
|
||||
*/
|
||||
export const PLATFORM_INFO: Record<NotificationPlatform, PlatformInfo> = {
|
||||
discord: {
|
||||
id: 'discord',
|
||||
name: 'Discord',
|
||||
icon: 'message-circle',
|
||||
description: 'Send notifications to Discord channels via webhook',
|
||||
requiredFields: ['webhookUrl'],
|
||||
},
|
||||
telegram: {
|
||||
id: 'telegram',
|
||||
name: 'Telegram',
|
||||
icon: 'send',
|
||||
description: 'Send notifications to Telegram chats via bot',
|
||||
requiredFields: ['botToken', 'chatId'],
|
||||
},
|
||||
webhook: {
|
||||
id: 'webhook',
|
||||
name: 'Custom Webhook',
|
||||
icon: 'link',
|
||||
description: 'Send notifications to a custom HTTP endpoint',
|
||||
requiredFields: ['url'],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Predefined event information
|
||||
*/
|
||||
export const EVENT_INFO: Record<NotificationEventType, EventInfo> = {
|
||||
'ask-user-question': {
|
||||
id: 'ask-user-question',
|
||||
name: 'Ask User Question',
|
||||
description: 'Notification when Claude asks a question via AskUserQuestion',
|
||||
icon: 'help-circle',
|
||||
},
|
||||
'session-start': {
|
||||
id: 'session-start',
|
||||
name: 'Session Start',
|
||||
description: 'Notification when a CLI session starts',
|
||||
icon: 'play',
|
||||
},
|
||||
'session-end': {
|
||||
id: 'session-end',
|
||||
name: 'Session End',
|
||||
description: 'Notification when a CLI session ends',
|
||||
icon: 'square',
|
||||
},
|
||||
'task-completed': {
|
||||
id: 'task-completed',
|
||||
name: 'Task Completed',
|
||||
description: 'Notification when a task completes successfully',
|
||||
icon: 'check-circle',
|
||||
},
|
||||
'task-failed': {
|
||||
id: 'task-failed',
|
||||
name: 'Task Failed',
|
||||
description: 'Notification when a task fails',
|
||||
icon: 'alert-circle',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Default configuration for UI initialization
|
||||
*/
|
||||
export function getDefaultConfig(): RemoteNotificationConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
platforms: {},
|
||||
events: [
|
||||
{ event: 'ask-user-question', platforms: ['discord', 'telegram'], enabled: true },
|
||||
{ event: 'session-start', platforms: [], enabled: false },
|
||||
{ event: 'session-end', platforms: [], enabled: false },
|
||||
{ event: 'task-completed', platforms: [], enabled: false },
|
||||
{ event: 'task-failed', platforms: ['discord', 'telegram'], enabled: true },
|
||||
],
|
||||
timeout: 10000,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user