mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat: add CLI config preview API for Codex and Gemini
- Implemented `fetchCodexConfigPreview` and `fetchGeminiConfigPreview` functions in the API layer to retrieve masked configuration files. - Added new interfaces `CodexConfigPreviewResponse` and `GeminiConfigPreviewResponse` to define the structure of the API responses. - Created utility functions to read and mask sensitive values from `config.toml` and `auth.json` for Codex, and `settings.json` for Gemini. - Updated CLI settings routes to handle new preview endpoints. - Enhanced session content parser to support Claude JSONL format. - Updated UI components to reflect changes in history page and navigation, including new tabs for observability. - Localized changes for English and Chinese languages to reflect "CLI History" terminology.
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { Check, Eye, EyeOff, X, Plus } from 'lucide-react';
|
import { Check, Eye, EyeOff, X, Plus, Loader2, Download } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -23,6 +23,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||||
import { useCreateCliSettings, useUpdateCliSettings, useProviders } from '@/hooks/useApiSettings';
|
import { useCreateCliSettings, useUpdateCliSettings, useProviders } from '@/hooks/useApiSettings';
|
||||||
import { useNotifications } from '@/hooks/useNotifications';
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
|
import { fetchCodexConfigPreview, fetchGeminiConfigPreview } from '@/lib/api';
|
||||||
import type { CliSettingsEndpoint, CliProvider } from '@/lib/api';
|
import type { CliSettingsEndpoint, CliProvider } from '@/lib/api';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
@@ -101,6 +102,8 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
|||||||
// Gemini specific
|
// Gemini specific
|
||||||
const [geminiApiKey, setGeminiApiKey] = useState('');
|
const [geminiApiKey, setGeminiApiKey] = useState('');
|
||||||
const [showGeminiKey, setShowGeminiKey] = useState(false);
|
const [showGeminiKey, setShowGeminiKey] = useState(false);
|
||||||
|
const [geminiSettingsJson, setGeminiSettingsJson] = useState('');
|
||||||
|
const [isLoadingGeminiConfig, setIsLoadingGeminiConfig] = useState(false);
|
||||||
|
|
||||||
// Shared
|
// Shared
|
||||||
const [model, setModel] = useState('');
|
const [model, setModel] = useState('');
|
||||||
@@ -112,6 +115,9 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
|||||||
const [showJsonInput, setShowJsonInput] = useState(false);
|
const [showJsonInput, setShowJsonInput] = useState(false);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Codex config preview loading state
|
||||||
|
const [isLoadingCodexConfig, setIsLoadingCodexConfig] = useState(false);
|
||||||
|
|
||||||
// Initialize form
|
// Initialize form
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cliSettings) {
|
if (cliSettings) {
|
||||||
@@ -171,6 +177,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
|||||||
setWriteCommonConfig(false);
|
setWriteCommonConfig(false);
|
||||||
setGeminiApiKey('');
|
setGeminiApiKey('');
|
||||||
setShowGeminiKey(false);
|
setShowGeminiKey(false);
|
||||||
|
setGeminiSettingsJson('');
|
||||||
setAvailableModels([]);
|
setAvailableModels([]);
|
||||||
setModelInput('');
|
setModelInput('');
|
||||||
setTags([]);
|
setTags([]);
|
||||||
@@ -224,6 +231,47 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
|||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle load Codex config preview
|
||||||
|
const handleLoadCodexConfig = async () => {
|
||||||
|
setIsLoadingCodexConfig(true);
|
||||||
|
try {
|
||||||
|
const result = await fetchCodexConfigPreview();
|
||||||
|
if (result.success) {
|
||||||
|
if (result.configToml) {
|
||||||
|
setConfigToml(result.configToml);
|
||||||
|
}
|
||||||
|
if (result.authJson) {
|
||||||
|
setAuthJson(result.authJson);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error(formatMessage({ id: 'apiSettings.cliSettings.loadConfigError' }) || 'Failed to load config');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error(formatMessage({ id: 'apiSettings.cliSettings.loadConfigError' }) || 'Failed to load config');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingCodexConfig(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle load Gemini config preview
|
||||||
|
const handleLoadGeminiConfig = async () => {
|
||||||
|
setIsLoadingGeminiConfig(true);
|
||||||
|
try {
|
||||||
|
const result = await fetchGeminiConfigPreview();
|
||||||
|
if (result.success) {
|
||||||
|
if (result.settingsJson) {
|
||||||
|
setGeminiSettingsJson(result.settingsJson);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error(formatMessage({ id: 'apiSettings.cliSettings.loadConfigError' }) || 'Failed to load config');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error(formatMessage({ id: 'apiSettings.cliSettings.loadConfigError' }) || 'Failed to load config');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingGeminiConfig(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle save
|
// Handle save
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!validateForm()) return;
|
if (!validateForm()) return;
|
||||||
@@ -521,6 +569,35 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
|||||||
{/* ========== Codex Settings ========== */}
|
{/* ========== Codex Settings ========== */}
|
||||||
{cliProvider === 'codex' && (
|
{cliProvider === 'codex' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Load Global Config Button */}
|
||||||
|
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Load Global Config</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Load config.toml and auth.json from global Codex config directory
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleLoadCodexConfig}
|
||||||
|
disabled={isLoadingCodexConfig}
|
||||||
|
>
|
||||||
|
{isLoadingCodexConfig ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Load Config
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* API Key */}
|
{/* API Key */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="codex-apikey">API Key</Label>
|
<Label htmlFor="codex-apikey">API Key</Label>
|
||||||
@@ -645,6 +722,35 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
|||||||
{/* ========== Gemini Settings ========== */}
|
{/* ========== Gemini Settings ========== */}
|
||||||
{cliProvider === 'gemini' && (
|
{cliProvider === 'gemini' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Load Global Config Button */}
|
||||||
|
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Load Global Config</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Load settings.json from global Gemini config directory
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleLoadGeminiConfig}
|
||||||
|
disabled={isLoadingGeminiConfig}
|
||||||
|
>
|
||||||
|
{isLoadingGeminiConfig ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Load Config
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="gemini-apikey">API Key</Label>
|
<Label htmlFor="gemini-apikey">API Key</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -675,6 +781,38 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
|||||||
placeholder="gemini-2.5-flash"
|
placeholder="gemini-2.5-flash"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* settings.json Editor */}
|
||||||
|
{geminiSettingsJson && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="gemini-settingsjson">settings.json (Preview)</Label>
|
||||||
|
<Button
|
||||||
|
type="button" variant="ghost" size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
try {
|
||||||
|
const formatted = JSON.stringify(JSON.parse(geminiSettingsJson), null, 2);
|
||||||
|
setGeminiSettingsJson(formatted);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Format
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
id="gemini-settingsjson"
|
||||||
|
value={geminiSettingsJson}
|
||||||
|
onChange={(e) => setGeminiSettingsJson(e.target.value)}
|
||||||
|
placeholder='{"model": "gemini-2.5-flash", ...}'
|
||||||
|
className="font-mono text-sm"
|
||||||
|
rows={8}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Gemini settings.json content (read-only preview)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,8 @@
|
|||||||
// Dynamic header component for IssueHub
|
// Dynamic header component for IssueHub
|
||||||
|
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { AlertCircle, Radar, ListTodo, LayoutGrid, Activity, Terminal } from 'lucide-react';
|
import { AlertCircle, Radar, ListTodo, LayoutGrid, Play } from 'lucide-react';
|
||||||
|
import type { IssueTab } from './IssueHubTabs';
|
||||||
type IssueTab = 'issues' | 'board' | 'queue' | 'discovery' | 'observability' | 'executions';
|
|
||||||
|
|
||||||
interface IssueHubHeaderProps {
|
interface IssueHubHeaderProps {
|
||||||
currentTab: IssueTab;
|
currentTab: IssueTab;
|
||||||
@@ -37,19 +36,14 @@ export function IssueHubHeader({ currentTab }: IssueHubHeaderProps) {
|
|||||||
title: formatMessage({ id: 'issues.discovery.pageTitle' }),
|
title: formatMessage({ id: 'issues.discovery.pageTitle' }),
|
||||||
description: formatMessage({ id: 'issues.discovery.description' }),
|
description: formatMessage({ id: 'issues.discovery.description' }),
|
||||||
},
|
},
|
||||||
observability: {
|
|
||||||
icon: <Activity className="w-6 h-6 text-primary" />,
|
|
||||||
title: formatMessage({ id: 'issues.observability.pageTitle' }),
|
|
||||||
description: formatMessage({ id: 'issues.observability.description' }),
|
|
||||||
},
|
|
||||||
executions: {
|
executions: {
|
||||||
icon: <Terminal className="w-6 h-6 text-primary" />,
|
icon: <Play className="w-6 h-6 text-primary" />,
|
||||||
title: formatMessage({ id: 'issues.executions.pageTitle' }),
|
title: formatMessage({ id: 'issues.executions.pageTitle' }),
|
||||||
description: formatMessage({ id: 'issues.executions.description' }),
|
description: formatMessage({ id: 'issues.executions.description' }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = tabConfig[currentTab];
|
const config = tabConfig[currentTab] || tabConfig.issues;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// Keep in sync with IssueHubHeader/IssueHubPage
|
// Keep in sync with IssueHubHeader/IssueHubPage
|
||||||
export type IssueTab = 'issues' | 'board' | 'queue' | 'discovery' | 'observability' | 'executions';
|
export type IssueTab = 'issues' | 'board' | 'queue' | 'discovery' | 'executions';
|
||||||
|
|
||||||
interface IssueHubTabsProps {
|
interface IssueHubTabsProps {
|
||||||
currentTab: IssueTab;
|
currentTab: IssueTab;
|
||||||
@@ -23,7 +23,6 @@ export function IssueHubTabs({ currentTab, onTabChange }: IssueHubTabsProps) {
|
|||||||
{ value: 'board', label: formatMessage({ id: 'issues.hub.tabs.board' }) },
|
{ value: 'board', label: formatMessage({ id: 'issues.hub.tabs.board' }) },
|
||||||
{ value: 'queue', label: formatMessage({ id: 'issues.hub.tabs.queue' }) },
|
{ value: 'queue', label: formatMessage({ id: 'issues.hub.tabs.queue' }) },
|
||||||
{ value: 'discovery', label: formatMessage({ id: 'issues.hub.tabs.discovery' }) },
|
{ value: 'discovery', label: formatMessage({ id: 'issues.hub.tabs.discovery' }) },
|
||||||
{ value: 'observability', label: formatMessage({ id: 'issues.hub.tabs.observability' }) },
|
|
||||||
{ value: 'executions', label: formatMessage({ id: 'issues.hub.tabs.executions' }) },
|
{ value: 'executions', label: formatMessage({ id: 'issues.hub.tabs.executions' }) },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Clock,
|
|
||||||
Zap,
|
Zap,
|
||||||
GitFork,
|
GitFork,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -78,7 +77,6 @@ const navGroupDefinitions: NavGroupDef[] = [
|
|||||||
{ path: '/sessions', labelKey: 'navigation.main.sessions', icon: FolderKanban },
|
{ path: '/sessions', labelKey: 'navigation.main.sessions', icon: FolderKanban },
|
||||||
{ path: '/lite-tasks', labelKey: 'navigation.main.liteTasks', icon: Zap },
|
{ path: '/lite-tasks', labelKey: 'navigation.main.liteTasks', icon: Zap },
|
||||||
{ path: '/orchestrator', labelKey: 'navigation.main.orchestrator', icon: Workflow },
|
{ path: '/orchestrator', labelKey: 'navigation.main.orchestrator', icon: Workflow },
|
||||||
{ path: '/history', labelKey: 'navigation.main.history', icon: Clock },
|
|
||||||
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
|
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
|
||||||
{ path: '/teams', labelKey: 'navigation.main.teams', icon: Users },
|
{ path: '/teams', labelKey: 'navigation.main.teams', icon: Users },
|
||||||
{ path: '/terminal-dashboard', labelKey: 'navigation.main.terminalDashboard', icon: Terminal },
|
{ path: '/terminal-dashboard', labelKey: 'navigation.main.terminalDashboard', icon: Terminal },
|
||||||
|
|||||||
@@ -6150,6 +6150,54 @@ export async function getCliSettingsPath(endpointId: string): Promise<{ endpoint
|
|||||||
return fetchApi(`/api/cli/settings/${encodeURIComponent(endpointId)}/path`);
|
return fetchApi(`/api/cli/settings/${encodeURIComponent(endpointId)}/path`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== CLI Config Preview API ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codex config preview response
|
||||||
|
*/
|
||||||
|
export interface CodexConfigPreviewResponse {
|
||||||
|
/** Whether preview was successful */
|
||||||
|
success: boolean;
|
||||||
|
/** Path to config.toml */
|
||||||
|
configPath: string;
|
||||||
|
/** Path to auth.json */
|
||||||
|
authPath: string;
|
||||||
|
/** config.toml content with sensitive values masked */
|
||||||
|
configToml: string | null;
|
||||||
|
/** auth.json content with API keys masked */
|
||||||
|
authJson: string | null;
|
||||||
|
/** Error messages if any files could not be read */
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemini config preview response
|
||||||
|
*/
|
||||||
|
export interface GeminiConfigPreviewResponse {
|
||||||
|
/** Whether preview was successful */
|
||||||
|
success: boolean;
|
||||||
|
/** Path to settings.json */
|
||||||
|
settingsPath: string;
|
||||||
|
/** settings.json content with sensitive values masked */
|
||||||
|
settingsJson: string | null;
|
||||||
|
/** Error messages if file could not be read */
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Codex config files preview (config.toml and auth.json)
|
||||||
|
*/
|
||||||
|
export async function fetchCodexConfigPreview(): Promise<CodexConfigPreviewResponse> {
|
||||||
|
return fetchApi('/api/cli/settings/codex/preview');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Gemini settings file preview (settings.json)
|
||||||
|
*/
|
||||||
|
export async function fetchGeminiConfigPreview(): Promise<GeminiConfigPreviewResponse> {
|
||||||
|
return fetchApi('/api/cli/settings/gemini/preview');
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Orchestrator Execution Monitoring API ==========
|
// ========== Orchestrator Execution Monitoring API ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -245,7 +245,7 @@
|
|||||||
"coordinator": "Coordinator",
|
"coordinator": "Coordinator",
|
||||||
"executions": "Executions",
|
"executions": "Executions",
|
||||||
"loops": "Loops",
|
"loops": "Loops",
|
||||||
"history": "History",
|
"history": "CLI History",
|
||||||
"memory": "Memory",
|
"memory": "Memory",
|
||||||
"prompts": "Prompts",
|
"prompts": "Prompts",
|
||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"sessions": "Sessions",
|
"sessions": "Sessions",
|
||||||
"liteTasks": "Lite Tasks",
|
"liteTasks": "Lite Tasks",
|
||||||
"project": "Project",
|
"project": "Project",
|
||||||
"history": "History",
|
"history": "CLI History",
|
||||||
"orchestrator": "Orchestrator",
|
"orchestrator": "Orchestrator",
|
||||||
"coordinator": "Coordinator",
|
"coordinator": "Coordinator",
|
||||||
"loops": "Loop Monitor",
|
"loops": "Loop Monitor",
|
||||||
|
|||||||
@@ -245,7 +245,7 @@
|
|||||||
"coordinator": "协调器",
|
"coordinator": "协调器",
|
||||||
"executions": "执行监控",
|
"executions": "执行监控",
|
||||||
"loops": "循环",
|
"loops": "循环",
|
||||||
"history": "历史",
|
"history": "CLI执行历史",
|
||||||
"memory": "记忆",
|
"memory": "记忆",
|
||||||
"prompts": "提示词",
|
"prompts": "提示词",
|
||||||
"skills": "技能",
|
"skills": "技能",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"sessions": "会话",
|
"sessions": "会话",
|
||||||
"liteTasks": "轻量任务",
|
"liteTasks": "轻量任务",
|
||||||
"project": "项目",
|
"project": "项目",
|
||||||
"history": "历史",
|
"history": "CLI执行历史",
|
||||||
"orchestrator": "编排器",
|
"orchestrator": "编排器",
|
||||||
"coordinator": "协调器",
|
"coordinator": "协调器",
|
||||||
"loops": "循环监控",
|
"loops": "循环监控",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// HistoryPage Component
|
// HistoryPage Component
|
||||||
// ========================================
|
// ========================================
|
||||||
// CLI execution history page with filtering and bulk actions
|
// CLI execution history page with filtering and bulk actions
|
||||||
|
// Includes tabs: Executions + Session Audit (Observability)
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
@@ -22,6 +23,7 @@ import { useHistory } from '@/hooks/useHistory';
|
|||||||
import { ConversationCard } from '@/components/shared/ConversationCard';
|
import { ConversationCard } from '@/components/shared/ConversationCard';
|
||||||
import { CliStreamPanel } from '@/components/shared/CliStreamPanel';
|
import { CliStreamPanel } from '@/components/shared/CliStreamPanel';
|
||||||
import { NativeSessionPanel } from '@/components/shared/NativeSessionPanel';
|
import { NativeSessionPanel } from '@/components/shared/NativeSessionPanel';
|
||||||
|
import { ObservabilityPanel } from '@/components/issue/hub/ObservabilityPanel';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import {
|
import {
|
||||||
@@ -42,11 +44,14 @@ import {
|
|||||||
} from '@/components/ui/Dropdown';
|
} from '@/components/ui/Dropdown';
|
||||||
import type { CliExecution } from '@/lib/api';
|
import type { CliExecution } from '@/lib/api';
|
||||||
|
|
||||||
|
type HistoryTab = 'executions' | 'observability';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HistoryPage component - Display CLI execution history
|
* HistoryPage component - Display CLI execution history
|
||||||
*/
|
*/
|
||||||
export function HistoryPage() {
|
export function HistoryPage() {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
const [currentTab, setCurrentTab] = React.useState<HistoryTab>('executions');
|
||||||
const [searchQuery, setSearchQuery] = React.useState('');
|
const [searchQuery, setSearchQuery] = React.useState('');
|
||||||
const [toolFilter, setToolFilter] = React.useState<string | undefined>(undefined);
|
const [toolFilter, setToolFilter] = React.useState<string | undefined>(undefined);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
||||||
@@ -163,148 +168,189 @@ export function HistoryPage() {
|
|||||||
>
|
>
|
||||||
{isImmersiveMode ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
{isImmersiveMode ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => refetch()}
|
|
||||||
disabled={isFetching}
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
|
||||||
{formatMessage({ id: 'common.actions.refresh' })}
|
|
||||||
</Button>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
{formatMessage({ id: 'history.deleteOptions' })}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuLabel>{formatMessage({ id: 'history.deleteBy' })}</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{tools.map((tool) => (
|
|
||||||
<DropdownMenuItem key={tool} onClick={() => handleDeleteByTool(tool)}>
|
|
||||||
{formatMessage({ id: 'history.deleteAllTool' }, { tool })}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={handleDeleteAll}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<AlertTriangle className="mr-2 h-4 w-4" />
|
|
||||||
{formatMessage({ id: 'history.deleteAll' })}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error alert */}
|
{/* Tabs - matching IssueHubTabs style */}
|
||||||
{error && (
|
<div className="flex gap-2 border-b border-border">
|
||||||
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
|
<Button
|
||||||
<Terminal className="h-5 w-5 flex-shrink-0" />
|
variant="ghost"
|
||||||
<div className="flex-1">
|
className={cn(
|
||||||
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
|
"border-b-2 rounded-none h-11 px-4",
|
||||||
<p className="text-xs mt-0.5">{error.message}</p>
|
currentTab === 'executions'
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
onClick={() => setCurrentTab('executions')}
|
||||||
|
>
|
||||||
|
{formatMessage({ id: 'history.tabs.executions' })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"border-b-2 rounded-none h-11 px-4",
|
||||||
|
currentTab === 'observability'
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
onClick={() => setCurrentTab('observability')}
|
||||||
|
>
|
||||||
|
{formatMessage({ id: 'history.tabs.observability' })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{currentTab === 'executions' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Error alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
|
||||||
|
<Terminal 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>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
|
{formatMessage({ id: 'common.actions.retry' })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters and Actions on same row */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="flex-1 max-w-sm relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={formatMessage({ id: 'history.searchPlaceholder' })}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9 pr-9"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tool filter */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="gap-2 min-w-[160px] justify-between">
|
||||||
|
{toolFilter || formatMessage({ id: 'history.filterAllTools' })}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-48">
|
||||||
|
<DropdownMenuItem onClick={() => setToolFilter(undefined)}>
|
||||||
|
{formatMessage({ id: 'history.filterAllTools' })}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={tool}
|
||||||
|
onClick={() => setToolFilter(tool)}
|
||||||
|
className={toolFilter === tool ? 'bg-accent' : ''}
|
||||||
|
>
|
||||||
|
{tool}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Clear filters */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleClearFilters}>
|
||||||
|
{formatMessage({ id: 'common.actions.clearFilters' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||||
|
{formatMessage({ id: 'common.actions.refresh' })}
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
{formatMessage({ id: 'history.deleteOptions' })}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>{formatMessage({ id: 'history.deleteBy' })}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<DropdownMenuItem key={tool} onClick={() => handleDeleteByTool(tool)}>
|
||||||
|
{formatMessage({ id: 'history.deleteAllTool' }, { tool })}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={handleDeleteAll}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="mr-2 h-4 w-4" />
|
||||||
|
{formatMessage({ id: 'history.deleteAll' })}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
|
||||||
{formatMessage({ id: 'common.actions.retry' })}
|
{/* Executions list */}
|
||||||
</Button>
|
{isLoading ? (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-28 rounded-lg bg-muted animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : executions.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 px-4">
|
||||||
|
<SearchX className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
{hasActiveFilters
|
||||||
|
? formatMessage({ id: 'history.empty.filtered' })
|
||||||
|
: formatMessage({ id: 'history.empty.title' })}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{hasActiveFilters
|
||||||
|
? formatMessage({ id: 'history.empty.filteredMessage' })
|
||||||
|
: formatMessage({ id: 'history.empty.message' })}
|
||||||
|
</p>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button variant="outline" onClick={handleClearFilters} className="mt-4">
|
||||||
|
{formatMessage({ id: 'common.actions.clearFilters' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{executions.map((execution) => (
|
||||||
|
<ConversationCard
|
||||||
|
key={execution.id}
|
||||||
|
execution={execution}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
onViewNative={handleViewNative}
|
||||||
|
onDelete={handleDeleteClick}
|
||||||
|
actionsDisabled={isDeleting}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{currentTab === 'observability' && (
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
<div className="mt-4">
|
||||||
{/* Search input */}
|
<ObservabilityPanel />
|
||||||
<div className="flex-1 max-w-sm relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder={formatMessage({ id: 'history.searchPlaceholder' })}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-9 pr-9"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
onClick={handleClearSearch}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tool filter */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" className="gap-2 min-w-[160px] justify-between">
|
|
||||||
{toolFilter || formatMessage({ id: 'history.filterAllTools' })}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="w-48">
|
|
||||||
<DropdownMenuItem onClick={() => setToolFilter(undefined)}>
|
|
||||||
{formatMessage({ id: 'history.filterAllTools' })}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{tools.map((tool) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={tool}
|
|
||||||
onClick={() => setToolFilter(tool)}
|
|
||||||
className={toolFilter === tool ? 'bg-accent' : ''}
|
|
||||||
>
|
|
||||||
{tool}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
{/* Clear filters */}
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={handleClearFilters}>
|
|
||||||
{formatMessage({ id: 'common.actions.clearFilters' })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Executions list */}
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<div key={i} className="h-28 rounded-lg bg-muted animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : executions.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 px-4">
|
|
||||||
<SearchX className="h-12 w-12 text-muted-foreground mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
|
||||||
{hasActiveFilters
|
|
||||||
? formatMessage({ id: 'history.empty.filtered' })
|
|
||||||
: formatMessage({ id: 'history.empty.title' })}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
|
||||||
{hasActiveFilters
|
|
||||||
? formatMessage({ id: 'history.empty.filteredMessage' })
|
|
||||||
: formatMessage({ id: 'history.empty.message' })}
|
|
||||||
</p>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Button variant="outline" onClick={handleClearFilters} className="mt-4">
|
|
||||||
{formatMessage({ id: 'common.actions.clearFilters' })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{executions.map((execution) => (
|
|
||||||
<ConversationCard
|
|
||||||
key={execution.id}
|
|
||||||
execution={execution}
|
|
||||||
onClick={handleCardClick}
|
|
||||||
onViewNative={handleViewNative}
|
|
||||||
onDelete={handleDeleteClick}
|
|
||||||
actionsDisabled={isDeleting}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// ========================================
|
// ========================================
|
||||||
// Unified page for issues, queue, and discovery with tab navigation
|
// Unified page for issues, queue, and discovery with tab navigation
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import {
|
import {
|
||||||
@@ -16,11 +16,12 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { IssueHubHeader } from '@/components/issue/hub/IssueHubHeader';
|
import { IssueHubHeader } from '@/components/issue/hub/IssueHubHeader';
|
||||||
import { IssueHubTabs, type IssueTab } from '@/components/issue/hub/IssueHubTabs';
|
import { IssueHubTabs, type IssueTab } from '@/components/issue/hub/IssueHubTabs';
|
||||||
|
|
||||||
|
const VALID_TABS: IssueTab[] = ['issues', 'board', 'queue', 'discovery', 'executions'];
|
||||||
import { IssuesPanel } from '@/components/issue/hub/IssuesPanel';
|
import { IssuesPanel } from '@/components/issue/hub/IssuesPanel';
|
||||||
import { IssueBoardPanel } from '@/components/issue/hub/IssueBoardPanel';
|
import { IssueBoardPanel } from '@/components/issue/hub/IssueBoardPanel';
|
||||||
import { QueuePanel } from '@/components/issue/hub/QueuePanel';
|
import { QueuePanel } from '@/components/issue/hub/QueuePanel';
|
||||||
import { DiscoveryPanel } from '@/components/issue/hub/DiscoveryPanel';
|
import { DiscoveryPanel } from '@/components/issue/hub/DiscoveryPanel';
|
||||||
import { ObservabilityPanel } from '@/components/issue/hub/ObservabilityPanel';
|
|
||||||
import { ExecutionPanel } from '@/components/issue/hub/ExecutionPanel';
|
import { ExecutionPanel } from '@/components/issue/hub/ExecutionPanel';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
@@ -285,7 +286,16 @@ function NewIssueDialog({ open, onOpenChange, onSubmit, isCreating }: {
|
|||||||
export function IssueHubPage() {
|
export function IssueHubPage() {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const currentTab = (searchParams.get('tab') as IssueTab) || 'issues';
|
const rawTab = searchParams.get('tab') as IssueTab;
|
||||||
|
const currentTab = VALID_TABS.includes(rawTab) ? rawTab : 'issues';
|
||||||
|
|
||||||
|
// Redirect invalid tabs to 'issues'
|
||||||
|
useEffect(() => {
|
||||||
|
if (!VALID_TABS.includes(rawTab)) {
|
||||||
|
setSearchParams({ tab: 'issues' });
|
||||||
|
}
|
||||||
|
}, [rawTab, setSearchParams]);
|
||||||
|
|
||||||
const [isNewIssueOpen, setIsNewIssueOpen] = useState(false);
|
const [isNewIssueOpen, setIsNewIssueOpen] = useState(false);
|
||||||
const [isGithubSyncing, setIsGithubSyncing] = useState(false);
|
const [isGithubSyncing, setIsGithubSyncing] = useState(false);
|
||||||
|
|
||||||
@@ -387,11 +397,8 @@ export function IssueHubPage() {
|
|||||||
case 'discovery':
|
case 'discovery':
|
||||||
return null; // Discovery panel has its own controls
|
return null; // Discovery panel has its own controls
|
||||||
|
|
||||||
case 'observability':
|
|
||||||
return null; // Observability panel has its own controls
|
|
||||||
|
|
||||||
case 'executions':
|
case 'executions':
|
||||||
return null; // Execution panel has its own controls
|
return null; // Executions panel has its own controls
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
@@ -436,7 +443,6 @@ export function IssueHubPage() {
|
|||||||
{currentTab === 'board' && <IssueBoardPanel />}
|
{currentTab === 'board' && <IssueBoardPanel />}
|
||||||
{currentTab === 'queue' && <QueuePanel />}
|
{currentTab === 'queue' && <QueuePanel />}
|
||||||
{currentTab === 'discovery' && <DiscoveryPanel />}
|
{currentTab === 'discovery' && <DiscoveryPanel />}
|
||||||
{currentTab === 'observability' && <ObservabilityPanel />}
|
|
||||||
{currentTab === 'executions' && <ExecutionPanel />}
|
{currentTab === 'executions' && <ExecutionPanel />}
|
||||||
|
|
||||||
<NewIssueDialog open={isNewIssueOpen} onOpenChange={setIsNewIssueOpen} onSubmit={handleCreateIssue} isCreating={isCreating} />
|
<NewIssueDialog open={isNewIssueOpen} onOpenChange={setIsNewIssueOpen} onSubmit={handleCreateIssue} isCreating={isCreating} />
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
* Handles Claude CLI settings file management API endpoints
|
* Handles Claude CLI settings file management API endpoints
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
import type { RouteContext } from './types.js';
|
import type { RouteContext } from './types.js';
|
||||||
import {
|
import {
|
||||||
saveEndpointSettings,
|
saveEndpointSettings,
|
||||||
@@ -20,6 +24,129 @@ import type { SaveEndpointRequest, ImportOptions } from '../../types/cli-setting
|
|||||||
import { validateSettings } from '../../types/cli-settings.js';
|
import { validateSettings } from '../../types/cli-settings.js';
|
||||||
import { syncBuiltinToolsAvailability, getBuiltinToolsSyncReport } from '../../tools/claude-cli-tools.js';
|
import { syncBuiltinToolsAvailability, getBuiltinToolsSyncReport } from '../../tools/claude-cli-tools.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mask sensitive values (API keys) for display
|
||||||
|
* Shows first 8 characters, masks the rest with asterisks
|
||||||
|
*/
|
||||||
|
function maskSensitiveValue(value: string): string {
|
||||||
|
if (!value || value.length <= 8) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value.substring(0, 8) + '*'.repeat(value.length - 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mask API keys in JSON content
|
||||||
|
*/
|
||||||
|
function maskJsonApiKeys(content: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
// Mask common API key fields
|
||||||
|
const sensitiveFields = ['api_key', 'apiKey', 'API_KEY', 'OPENAI_API_KEY', 'token', 'auth_token'];
|
||||||
|
for (const field of sensitiveFields) {
|
||||||
|
if (parsed[field] && typeof parsed[field] === 'string') {
|
||||||
|
parsed[field] = maskSensitiveValue(parsed[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle nested objects (e.g., api key in providers)
|
||||||
|
if (parsed.providers && Array.isArray(parsed.providers)) {
|
||||||
|
parsed.providers = parsed.providers.map((provider: any) => {
|
||||||
|
if (provider.api_key) {
|
||||||
|
return { ...provider, api_key: maskSensitiveValue(provider.api_key) };
|
||||||
|
}
|
||||||
|
return provider;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return JSON.stringify(parsed, null, 2);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If not valid JSON, return as-is
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mask API keys in TOML content
|
||||||
|
*/
|
||||||
|
function maskTomlApiKeys(content: string): string {
|
||||||
|
// Match patterns like api_key = "value" or apiKey = "value"
|
||||||
|
return content.replace(
|
||||||
|
/(api[_-]?key|apiKey|API_KEY|token|auth_token)\s*=\s*["']([^"']+)["']/gi,
|
||||||
|
(match, key, value) => {
|
||||||
|
return `${key} = "${maskSensitiveValue(value)}"`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and mask Codex config files for preview
|
||||||
|
*/
|
||||||
|
function readCodexConfigPreview(): { configToml: string | null; authJson: string | null; errors: string[] } {
|
||||||
|
const home = homedir();
|
||||||
|
const codexDir = join(home, '.codex');
|
||||||
|
const result: { configToml: string | null; authJson: string | null; errors: string[] } = {
|
||||||
|
configToml: null,
|
||||||
|
authJson: null,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read config.toml
|
||||||
|
const configTomlPath = join(codexDir, 'config.toml');
|
||||||
|
if (existsSync(configTomlPath)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(configTomlPath, 'utf-8');
|
||||||
|
result.configToml = maskTomlApiKeys(content);
|
||||||
|
} catch (err) {
|
||||||
|
result.errors.push(`Failed to read config.toml: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.errors.push('config.toml not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read auth.json
|
||||||
|
const authJsonPath = join(codexDir, 'auth.json');
|
||||||
|
if (existsSync(authJsonPath)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(authJsonPath, 'utf-8');
|
||||||
|
result.authJson = maskJsonApiKeys(content);
|
||||||
|
} catch (err) {
|
||||||
|
result.errors.push(`Failed to read auth.json: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.errors.push('auth.json not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and mask Gemini settings file for preview
|
||||||
|
*/
|
||||||
|
function readGeminiConfigPreview(): { settingsJson: string | null; errors: string[] } {
|
||||||
|
const home = homedir();
|
||||||
|
const geminiDir = join(home, '.gemini');
|
||||||
|
const result: { settingsJson: string | null; errors: string[] } = {
|
||||||
|
settingsJson: null,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read settings.json
|
||||||
|
const settingsPath = join(geminiDir, 'settings.json');
|
||||||
|
if (existsSync(settingsPath)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(settingsPath, 'utf-8');
|
||||||
|
result.settingsJson = maskJsonApiKeys(content);
|
||||||
|
} catch (err) {
|
||||||
|
result.errors.push(`Failed to read settings.json: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.errors.push('settings.json not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle CLI Settings routes
|
* Handle CLI Settings routes
|
||||||
* @returns true if route was handled, false otherwise
|
* @returns true if route was handled, false otherwise
|
||||||
@@ -334,5 +461,49 @@ export async function handleCliSettingsRoutes(ctx: RouteContext): Promise<boolea
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== CODEX CONFIG PREVIEW ==========
|
||||||
|
// GET /api/cli/settings/codex/preview
|
||||||
|
if (pathname === '/api/cli/settings/codex/preview' && req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const preview = readCodexConfigPreview();
|
||||||
|
const home = homedir();
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
configPath: join(home, '.codex', 'config.toml'),
|
||||||
|
authPath: join(home, '.codex', 'auth.json'),
|
||||||
|
configToml: preview.configToml,
|
||||||
|
authJson: preview.authJson,
|
||||||
|
errors: preview.errors.length > 0 ? preview.errors : undefined
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== GEMINI CONFIG PREVIEW ==========
|
||||||
|
// GET /api/cli/settings/gemini/preview
|
||||||
|
if (pathname === '/api/cli/settings/gemini/preview' && req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const preview = readGeminiConfigPreview();
|
||||||
|
const home = homedir();
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
settingsPath: join(home, '.gemini', 'settings.json'),
|
||||||
|
settingsJson: preview.settingsJson,
|
||||||
|
errors: preview.errors.length > 0 ? preview.errors : undefined
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
539
ccw/src/tools/claude-session-parser.ts
Normal file
539
ccw/src/tools/claude-session-parser.ts
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
/**
|
||||||
|
* Claude Session Parser - Parses Claude Code CLI JSONL session files
|
||||||
|
* Storage path: ~/.claude/projects/<projectHash>/<sessionId>.jsonl
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { basename } from 'path';
|
||||||
|
import type { ParsedSession, ParsedTurn, ToolCallInfo, TokenInfo } from './session-content-parser.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude JSONL line types
|
||||||
|
* Each line is a JSON object with a type field indicating the content type
|
||||||
|
*/
|
||||||
|
export interface ClaudeJsonlLine {
|
||||||
|
type: 'user' | 'assistant' | 'summary' | 'file-history-snapshot';
|
||||||
|
uuid: string;
|
||||||
|
parentUuid: string | null;
|
||||||
|
timestamp: string;
|
||||||
|
sessionId?: string;
|
||||||
|
cwd?: string;
|
||||||
|
version?: string;
|
||||||
|
gitBranch?: string;
|
||||||
|
isSidechain?: boolean;
|
||||||
|
isMeta?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User message line in Claude JSONL
|
||||||
|
* Content can be string or array of content blocks
|
||||||
|
*/
|
||||||
|
export interface ClaudeUserLine extends ClaudeJsonlLine {
|
||||||
|
type: 'user';
|
||||||
|
message: {
|
||||||
|
role: 'user';
|
||||||
|
content: string | ClaudeContentBlock[];
|
||||||
|
};
|
||||||
|
userType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assistant message line in Claude JSONL
|
||||||
|
* Contains content blocks, tool calls, and usage info
|
||||||
|
*/
|
||||||
|
export interface ClaudeAssistantLine extends ClaudeJsonlLine {
|
||||||
|
type: 'assistant';
|
||||||
|
message: {
|
||||||
|
role: 'assistant';
|
||||||
|
content: ClaudeContentBlock[];
|
||||||
|
model?: string;
|
||||||
|
id?: string;
|
||||||
|
stop_reason?: string | null;
|
||||||
|
stop_sequence?: string | null;
|
||||||
|
};
|
||||||
|
usage?: ClaudeUsage;
|
||||||
|
requestId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary line in Claude JSONL (for compacted conversations)
|
||||||
|
*/
|
||||||
|
export interface ClaudeSummaryLine extends ClaudeJsonlLine {
|
||||||
|
type: 'summary';
|
||||||
|
summary: string;
|
||||||
|
lemon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File history snapshot (metadata, not conversation content)
|
||||||
|
*/
|
||||||
|
export interface ClaudeFileHistoryLine extends ClaudeJsonlLine {
|
||||||
|
type: 'file-history-snapshot';
|
||||||
|
messageId: string;
|
||||||
|
snapshot: {
|
||||||
|
messageId: string;
|
||||||
|
trackedFileBackups: Record<string, unknown>;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
isSnapshotUpdate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content block in Claude messages
|
||||||
|
*/
|
||||||
|
export interface ClaudeContentBlock {
|
||||||
|
type: 'text' | 'tool_use' | 'tool_result' | 'image' | 'thinking';
|
||||||
|
text?: string;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
input?: Record<string, unknown>;
|
||||||
|
tool_use_id?: string;
|
||||||
|
content?: string;
|
||||||
|
source?: {
|
||||||
|
type: string;
|
||||||
|
media_type?: string;
|
||||||
|
data?: string;
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
thinking?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token usage information in Claude messages
|
||||||
|
*/
|
||||||
|
export interface ClaudeUsage {
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cache_creation_input_tokens?: number;
|
||||||
|
cache_read_input_tokens?: number;
|
||||||
|
service_tier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Claude Code CLI session file (JSONL format)
|
||||||
|
* @param filePath - Path to the Claude JSONL session file
|
||||||
|
* @returns ParsedSession object with turns, tokens, and metadata
|
||||||
|
*/
|
||||||
|
export function parseClaudeSession(filePath: string): ParsedSession | null {
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(filePath, 'utf8');
|
||||||
|
const lines = content.split('\n').filter(l => l.trim());
|
||||||
|
|
||||||
|
// Extract session ID from filename (UUID format)
|
||||||
|
const sessionId = extractSessionId(filePath);
|
||||||
|
|
||||||
|
const turns: ParsedTurn[] = [];
|
||||||
|
let workingDir = '';
|
||||||
|
let startTime = '';
|
||||||
|
let lastUpdated = '';
|
||||||
|
let model: string | undefined;
|
||||||
|
let totalTokens: TokenInfo = { input: 0, output: 0, total: 0 };
|
||||||
|
|
||||||
|
// Track conversation structure using uuid/parentUuid
|
||||||
|
const messageMap = new Map<string, ClaudeJsonlLine>();
|
||||||
|
const rootUuids: string[] = [];
|
||||||
|
|
||||||
|
// First pass: collect all messages and find roots
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const entry: ClaudeJsonlLine = JSON.parse(line);
|
||||||
|
|
||||||
|
// Skip non-conversation types
|
||||||
|
if (entry.type === 'file-history-snapshot') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
messageMap.set(entry.uuid, entry);
|
||||||
|
|
||||||
|
// Track root messages (no parent)
|
||||||
|
if (!entry.parentUuid) {
|
||||||
|
rootUuids.push(entry.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata from first entry
|
||||||
|
if (!startTime && entry.timestamp) {
|
||||||
|
startTime = entry.timestamp;
|
||||||
|
}
|
||||||
|
if (!workingDir && entry.cwd) {
|
||||||
|
workingDir = entry.cwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last timestamp
|
||||||
|
if (entry.timestamp) {
|
||||||
|
lastUpdated = entry.timestamp;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: build conversation turns
|
||||||
|
let turnNumber = 0;
|
||||||
|
const processedUuids = new Set<string>();
|
||||||
|
|
||||||
|
for (const rootUuid of rootUuids) {
|
||||||
|
const turn = processConversationBranch(
|
||||||
|
rootUuid,
|
||||||
|
messageMap,
|
||||||
|
processedUuids,
|
||||||
|
++turnNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (turn) {
|
||||||
|
turns.push(turn);
|
||||||
|
|
||||||
|
// Accumulate tokens
|
||||||
|
if (turn.tokens) {
|
||||||
|
totalTokens.input = (totalTokens.input || 0) + (turn.tokens.input || 0);
|
||||||
|
totalTokens.output = (totalTokens.output || 0) + (turn.tokens.output || 0);
|
||||||
|
totalTokens.total = (totalTokens.total || 0) + (turn.tokens.total || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track model
|
||||||
|
if (!model && turn.tokens?.input) {
|
||||||
|
// Model info is typically in assistant messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract model from assistant messages if not found
|
||||||
|
if (!model) {
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (entry.type === 'assistant' && entry.message?.model) {
|
||||||
|
model = entry.message.model;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total tokens
|
||||||
|
totalTokens.total = (totalTokens.input || 0) + (totalTokens.output || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
tool: 'claude',
|
||||||
|
workingDir,
|
||||||
|
startTime,
|
||||||
|
lastUpdated,
|
||||||
|
turns,
|
||||||
|
totalTokens: totalTokens.total > 0 ? totalTokens : undefined,
|
||||||
|
model
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to parse Claude session file ${filePath}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract session ID from file path
|
||||||
|
* Claude session files are named <uuid>.jsonl
|
||||||
|
*/
|
||||||
|
function extractSessionId(filePath: string): string {
|
||||||
|
const filename = basename(filePath);
|
||||||
|
// Remove .jsonl extension
|
||||||
|
const nameWithoutExt = filename.replace(/\.jsonl$/i, '');
|
||||||
|
// Check if it's a UUID format
|
||||||
|
const uuidMatch = nameWithoutExt.match(
|
||||||
|
/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i
|
||||||
|
);
|
||||||
|
return uuidMatch ? uuidMatch[1] : nameWithoutExt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a conversation branch starting from a root UUID
|
||||||
|
* Returns a combined turn with user and assistant messages
|
||||||
|
*/
|
||||||
|
function processConversationBranch(
|
||||||
|
rootUuid: string,
|
||||||
|
messageMap: Map<string, ClaudeJsonlLine>,
|
||||||
|
processedUuids: Set<string>,
|
||||||
|
turnNumber: number
|
||||||
|
): ParsedTurn | null {
|
||||||
|
const rootEntry = messageMap.get(rootUuid);
|
||||||
|
if (!rootEntry || processedUuids.has(rootUuid)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the user message at this root
|
||||||
|
let userContent = '';
|
||||||
|
let userTimestamp = '';
|
||||||
|
let assistantContent = '';
|
||||||
|
let assistantTimestamp = '';
|
||||||
|
let toolCalls: ToolCallInfo[] = [];
|
||||||
|
let tokens: TokenInfo | undefined;
|
||||||
|
let thoughts: string[] = [];
|
||||||
|
|
||||||
|
// Process this entry if it's a user message
|
||||||
|
if (rootEntry.type === 'user') {
|
||||||
|
const userEntry = rootEntry as ClaudeUserLine;
|
||||||
|
processedUuids.add(rootEntry.uuid);
|
||||||
|
|
||||||
|
// Skip meta messages (command messages, etc.)
|
||||||
|
if (userEntry.isMeta) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
userContent = extractUserContent(userEntry);
|
||||||
|
userTimestamp = rootEntry.timestamp;
|
||||||
|
|
||||||
|
// Find child assistant message
|
||||||
|
for (const [uuid, entry] of messageMap) {
|
||||||
|
if (entry.parentUuid === rootEntry.uuid && entry.type === 'assistant') {
|
||||||
|
const assistantEntry = entry as ClaudeAssistantLine;
|
||||||
|
processedUuids.add(uuid);
|
||||||
|
|
||||||
|
const extracted = extractAssistantContent(assistantEntry);
|
||||||
|
assistantContent = extracted.content;
|
||||||
|
assistantTimestamp = entry.timestamp;
|
||||||
|
toolCalls = extracted.toolCalls;
|
||||||
|
thoughts = extracted.thoughts;
|
||||||
|
|
||||||
|
if (assistantEntry.usage) {
|
||||||
|
tokens = {
|
||||||
|
input: assistantEntry.usage.input_tokens,
|
||||||
|
output: assistantEntry.usage.output_tokens,
|
||||||
|
total: assistantEntry.usage.input_tokens + assistantEntry.usage.output_tokens,
|
||||||
|
cached: (assistantEntry.usage.cache_read_input_tokens || 0) +
|
||||||
|
(assistantEntry.usage.cache_creation_input_tokens || 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tool result messages (follow-up user messages)
|
||||||
|
for (const [uuid, entry] of messageMap) {
|
||||||
|
if (entry.parentUuid === rootEntry.uuid && entry.type === 'user') {
|
||||||
|
const followUpUser = entry as ClaudeUserLine;
|
||||||
|
if (!followUpUser.isMeta && processedUuids.has(uuid)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Check if this is a tool result message
|
||||||
|
if (followUpUser.message?.content && Array.isArray(followUpUser.message.content)) {
|
||||||
|
const hasToolResult = followUpUser.message.content.some(
|
||||||
|
block => block.type === 'tool_result'
|
||||||
|
);
|
||||||
|
if (hasToolResult) {
|
||||||
|
processedUuids.add(uuid);
|
||||||
|
// Tool results are typically not displayed as separate turns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userContent) {
|
||||||
|
return {
|
||||||
|
turnNumber,
|
||||||
|
timestamp: userTimestamp,
|
||||||
|
role: 'user',
|
||||||
|
content: userContent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no user content but we have assistant content (edge case)
|
||||||
|
if (assistantContent) {
|
||||||
|
return {
|
||||||
|
turnNumber,
|
||||||
|
timestamp: assistantTimestamp,
|
||||||
|
role: 'assistant',
|
||||||
|
content: assistantContent,
|
||||||
|
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||||
|
thoughts: thoughts.length > 0 ? thoughts : undefined,
|
||||||
|
tokens
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract text content from user message
|
||||||
|
* Handles both string and array content formats
|
||||||
|
*/
|
||||||
|
function extractUserContent(entry: ClaudeUserLine): string {
|
||||||
|
const content = entry.message?.content;
|
||||||
|
if (!content) return '';
|
||||||
|
|
||||||
|
// Simple string content
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
// Skip command messages
|
||||||
|
if (content.startsWith('<command-') || content.includes('<local-command')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// Skip meta messages
|
||||||
|
if (content.includes('<local-command-caveat>')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array content - extract text blocks
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const textParts: string[] = [];
|
||||||
|
for (const block of content) {
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
textParts.push(block.text);
|
||||||
|
}
|
||||||
|
// Note: tool_result blocks contain tool outputs, not user text
|
||||||
|
}
|
||||||
|
return textParts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract content, tool calls, and thoughts from assistant message
|
||||||
|
*/
|
||||||
|
function extractAssistantContent(entry: ClaudeAssistantLine): {
|
||||||
|
content: string;
|
||||||
|
toolCalls: ToolCallInfo[];
|
||||||
|
thoughts: string[];
|
||||||
|
} {
|
||||||
|
const result: { content: string; toolCalls: ToolCallInfo[]; thoughts: string[] } = {
|
||||||
|
content: '',
|
||||||
|
toolCalls: [],
|
||||||
|
thoughts: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = entry.message?.content;
|
||||||
|
if (!content || !Array.isArray(content)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textParts: string[] = [];
|
||||||
|
|
||||||
|
for (const block of content) {
|
||||||
|
switch (block.type) {
|
||||||
|
case 'text':
|
||||||
|
if (block.text) {
|
||||||
|
textParts.push(block.text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'thinking':
|
||||||
|
if (block.thinking) {
|
||||||
|
result.thoughts.push(block.thinking);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tool_use':
|
||||||
|
result.toolCalls.push({
|
||||||
|
name: block.name || 'unknown',
|
||||||
|
arguments: block.input ? JSON.stringify(block.input) : undefined
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.content = textParts.join('\n');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Claude session content from string (for in-memory parsing)
|
||||||
|
* @param content - The JSONL content as string
|
||||||
|
* @param filePath - Optional file path for session ID extraction
|
||||||
|
* @returns ParsedSession object
|
||||||
|
*/
|
||||||
|
export function parseClaudeSessionContent(content: string, filePath?: string): ParsedSession | null {
|
||||||
|
const sessionId = filePath ? extractSessionId(filePath) : 'unknown';
|
||||||
|
|
||||||
|
const lines = content.split('\n').filter(l => l.trim());
|
||||||
|
const turns: ParsedTurn[] = [];
|
||||||
|
let workingDir = '';
|
||||||
|
let startTime = '';
|
||||||
|
let lastUpdated = '';
|
||||||
|
let model: string | undefined;
|
||||||
|
let totalTokens: TokenInfo = { input: 0, output: 0, total: 0 };
|
||||||
|
|
||||||
|
// Track conversation structure
|
||||||
|
const messageMap = new Map<string, ClaudeJsonlLine>();
|
||||||
|
const rootUuids: string[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const entry: ClaudeJsonlLine = JSON.parse(line);
|
||||||
|
|
||||||
|
if (entry.type === 'file-history-snapshot') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
messageMap.set(entry.uuid, entry);
|
||||||
|
|
||||||
|
if (!entry.parentUuid) {
|
||||||
|
rootUuids.push(entry.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startTime && entry.timestamp) {
|
||||||
|
startTime = entry.timestamp;
|
||||||
|
}
|
||||||
|
if (!workingDir && entry.cwd) {
|
||||||
|
workingDir = entry.cwd;
|
||||||
|
}
|
||||||
|
if (entry.timestamp) {
|
||||||
|
lastUpdated = entry.timestamp;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let turnNumber = 0;
|
||||||
|
const processedUuids = new Set<string>();
|
||||||
|
|
||||||
|
for (const rootUuid of rootUuids) {
|
||||||
|
const turn = processConversationBranch(
|
||||||
|
rootUuid,
|
||||||
|
messageMap,
|
||||||
|
processedUuids,
|
||||||
|
++turnNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (turn) {
|
||||||
|
turns.push(turn);
|
||||||
|
|
||||||
|
if (turn.tokens) {
|
||||||
|
totalTokens.input = (totalTokens.input || 0) + (turn.tokens.input || 0);
|
||||||
|
totalTokens.output = (totalTokens.output || 0) + (turn.tokens.output || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract model
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (entry.type === 'assistant' && entry.message?.model) {
|
||||||
|
model = entry.message.model;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalTokens.total = (totalTokens.input || 0) + (totalTokens.output || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
tool: 'claude',
|
||||||
|
workingDir,
|
||||||
|
startTime,
|
||||||
|
lastUpdated,
|
||||||
|
turns,
|
||||||
|
totalTokens: totalTokens.total > 0 ? totalTokens : undefined,
|
||||||
|
model
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Session Content Parser - Parses native CLI tool session files
|
* Session Content Parser - Parses native CLI tool session files
|
||||||
* Supports Gemini/Qwen JSON and Codex JSONL formats
|
* Supports Gemini/Qwen JSON, Codex JSONL, and Claude JSONL formats
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync, existsSync } from 'fs';
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { parseClaudeSession } from './claude-session-parser.js';
|
||||||
|
|
||||||
// Standardized conversation turn
|
// Standardized conversation turn
|
||||||
export interface ParsedTurn {
|
export interface ParsedTurn {
|
||||||
@@ -197,6 +198,8 @@ export function parseSessionFile(filePath: string, tool: string): ParsedSession
|
|||||||
return parseGeminiQwenSession(content, tool);
|
return parseGeminiQwenSession(content, tool);
|
||||||
case 'codex':
|
case 'codex':
|
||||||
return parseCodexSession(content);
|
return parseCodexSession(content);
|
||||||
|
case 'claude':
|
||||||
|
return parseClaudeSession(filePath);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,3 +298,35 @@ export interface ImportResult {
|
|||||||
/** List of imported endpoint IDs */
|
/** List of imported endpoint IDs */
|
||||||
importedIds?: string[];
|
importedIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codex config preview response
|
||||||
|
*/
|
||||||
|
export interface CodexConfigPreviewResponse {
|
||||||
|
/** Whether preview was successful */
|
||||||
|
success: boolean;
|
||||||
|
/** Path to config.toml */
|
||||||
|
configPath: string;
|
||||||
|
/** Path to auth.json */
|
||||||
|
authPath: string;
|
||||||
|
/** config.toml content with sensitive values masked */
|
||||||
|
configToml: string | null;
|
||||||
|
/** auth.json content with API keys masked */
|
||||||
|
authJson: string | null;
|
||||||
|
/** Error messages if any files could not be read */
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemini config preview response
|
||||||
|
*/
|
||||||
|
export interface GeminiConfigPreviewResponse {
|
||||||
|
/** Whether preview was successful */
|
||||||
|
success: boolean;
|
||||||
|
/** Path to settings.json */
|
||||||
|
settingsPath: string;
|
||||||
|
/** settings.json content with sensitive values masked */
|
||||||
|
settingsJson: string | null;
|
||||||
|
/** Error messages if file could not be read */
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user