mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
feat: add Provider management UI, CLI Tasks page, and enhance NewTaskModal
- Settings: new Providers tab with full CRUD, activation, and connectivity test; Model tab shows active provider name; General tab simplified - Tasks: new CLI Tasks page displaying task lists from ~/.claude/tasks/ with status, owner, and dependency (blocks/blockedBy) visualization - NewTaskModal: add Advanced options (model, permission mode, working dir) - Backend: fix TaskService to parse CLI V2 task format; extend /api/tasks with /lists endpoint for grouped queries - Fix ModelInfo.context type from number to string Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
da2be1f81b
commit
d059a8b487
28
desktop/src/api/cliTasks.ts
Normal file
28
desktop/src/api/cliTasks.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { api } from './client'
|
||||
import type { CLITask, TaskListSummary } from '../types/cliTask'
|
||||
|
||||
type TaskListsResponse = { lists: TaskListSummary[] }
|
||||
type TasksResponse = { tasks: CLITask[] }
|
||||
type TaskResponse = { task: CLITask }
|
||||
|
||||
export const cliTasksApi = {
|
||||
/** List all task lists with summaries */
|
||||
listTaskLists() {
|
||||
return api.get<TaskListsResponse>('/api/tasks/lists')
|
||||
},
|
||||
|
||||
/** Get all tasks for a specific task list */
|
||||
getTasksForList(taskListId: string) {
|
||||
return api.get<TasksResponse>(`/api/tasks/lists/${encodeURIComponent(taskListId)}`)
|
||||
},
|
||||
|
||||
/** Get a single task */
|
||||
getTask(taskListId: string, taskId: string) {
|
||||
return api.get<TaskResponse>(`/api/tasks/lists/${encodeURIComponent(taskListId)}/${taskId}`)
|
||||
},
|
||||
|
||||
/** List all tasks across all task lists */
|
||||
listAll() {
|
||||
return api.get<TasksResponse>('/api/tasks')
|
||||
},
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { api } from './client'
|
||||
import type { ModelInfo, EffortLevel } from '../types/settings'
|
||||
|
||||
type ModelsResponse = { models: ModelInfo[] }
|
||||
type ModelsResponse = { models: ModelInfo[]; provider: { id: string; name: string } | null }
|
||||
type CurrentModelResponse = { model: ModelInfo }
|
||||
type EffortResponse = { level: EffortLevel; available: EffortLevel[] }
|
||||
|
||||
|
||||
46
desktop/src/api/providers.ts
Normal file
46
desktop/src/api/providers.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { api } from './client'
|
||||
import type {
|
||||
Provider,
|
||||
CreateProviderInput,
|
||||
UpdateProviderInput,
|
||||
TestProviderConfigInput,
|
||||
ProviderTestResult,
|
||||
} from '../types/provider'
|
||||
|
||||
type ProvidersResponse = { providers: Provider[] }
|
||||
type ProviderResponse = { provider: Provider }
|
||||
type TestResultResponse = { result: ProviderTestResult }
|
||||
|
||||
export const providersApi = {
|
||||
list() {
|
||||
return api.get<ProvidersResponse>('/api/providers')
|
||||
},
|
||||
|
||||
get(id: string) {
|
||||
return api.get<ProviderResponse>(`/api/providers/${id}`)
|
||||
},
|
||||
|
||||
create(input: CreateProviderInput) {
|
||||
return api.post<ProviderResponse>('/api/providers', input)
|
||||
},
|
||||
|
||||
update(id: string, input: UpdateProviderInput) {
|
||||
return api.put<ProviderResponse>(`/api/providers/${id}`, input)
|
||||
},
|
||||
|
||||
delete(id: string) {
|
||||
return api.delete<{ ok: true }>(`/api/providers/${id}`)
|
||||
},
|
||||
|
||||
activate(id: string, modelId: string) {
|
||||
return api.post<{ ok: true }>(`/api/providers/${id}/activate`, { modelId })
|
||||
},
|
||||
|
||||
test(id: string) {
|
||||
return api.post<TestResultResponse>(`/api/providers/${id}/test`)
|
||||
},
|
||||
|
||||
testConfig(input: TestProviderConfigInput) {
|
||||
return api.post<TestResultResponse>('/api/providers/test', input)
|
||||
},
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { useTeamStore } from '../../stores/teamStore'
|
||||
import { EmptySession } from '../../pages/EmptySession'
|
||||
import { ActiveSession } from '../../pages/ActiveSession'
|
||||
import { ScheduledTasks } from '../../pages/ScheduledTasks'
|
||||
import { Tasks } from '../../pages/Tasks'
|
||||
import { Settings } from '../../pages/Settings'
|
||||
import { AgentTranscript } from '../../pages/AgentTranscript'
|
||||
|
||||
@ -20,6 +21,10 @@ export function ContentRouter() {
|
||||
return <ScheduledTasks />
|
||||
}
|
||||
|
||||
if (activeView === 'tasks') {
|
||||
return <Tasks />
|
||||
}
|
||||
|
||||
if (activeView === 'terminal') {
|
||||
return <TerminalPlaceholder />
|
||||
}
|
||||
|
||||
@ -86,6 +86,13 @@ export function Sidebar() {
|
||||
>
|
||||
Scheduled
|
||||
</NavItem>
|
||||
<NavItem
|
||||
active={activeView === 'tasks'}
|
||||
onClick={() => setActiveView('tasks')}
|
||||
icon={<TaskIcon />}
|
||||
>
|
||||
Tasks
|
||||
</NavItem>
|
||||
</div>
|
||||
|
||||
{/* Project filter */}
|
||||
@ -280,3 +287,12 @@ function ClockIcon() {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useTaskStore } from '../../stores/taskStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { Modal } from '../shared/Modal'
|
||||
import { Input } from '../shared/Input'
|
||||
import { Textarea } from '../shared/Textarea'
|
||||
@ -13,17 +14,31 @@ type Props = {
|
||||
const FREQUENCY_OPTIONS = [
|
||||
{ value: '0 * * * *', label: 'Hourly' },
|
||||
{ value: '0 9 * * *', label: 'Daily' },
|
||||
{ value: '0 9 * * 1-5', label: 'Weekdays' },
|
||||
{ value: '0 9 * * 1', label: 'Weekly' },
|
||||
{ value: '0 9 1 * *', label: 'Monthly' },
|
||||
]
|
||||
|
||||
const PERMISSION_OPTIONS = [
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'ask', label: 'Ask permissions' },
|
||||
{ value: 'auto-accept', label: 'Auto accept edits' },
|
||||
{ value: 'plan', label: 'Plan mode' },
|
||||
{ value: 'bypass', label: 'Bypass permissions' },
|
||||
]
|
||||
|
||||
export function NewTaskModal({ open, onClose }: Props) {
|
||||
const { createTask } = useTaskStore()
|
||||
const availableModels = useSettingsStore((s) => s.availableModels)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [cron, setCron] = useState('0 9 * * *')
|
||||
const [model, setModel] = useState('')
|
||||
const [permissionMode, setPermissionMode] = useState('')
|
||||
const [folderPath, setFolderPath] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
const canSubmit = name.trim() && prompt.trim()
|
||||
|
||||
@ -38,12 +53,19 @@ export function NewTaskModal({ open, onClose }: Props) {
|
||||
prompt: prompt.trim(),
|
||||
enabled: true,
|
||||
recurring: true,
|
||||
model: model || undefined,
|
||||
permissionMode: permissionMode || undefined,
|
||||
folderPath: folderPath.trim() || undefined,
|
||||
})
|
||||
// Reset form
|
||||
setName('')
|
||||
setDescription('')
|
||||
setPrompt('')
|
||||
setCron('0 9 * * *')
|
||||
setModel('')
|
||||
setPermissionMode('')
|
||||
setFolderPath('')
|
||||
setShowAdvanced(false)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Failed to create task:', err)
|
||||
@ -52,6 +74,8 @@ export function NewTaskModal({ open, onClose }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const selectClass = 'h-10 px-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@ -99,17 +123,62 @@ export function NewTaskModal({ open, onClose }: Props) {
|
||||
{/* Frequency */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-[var(--color-text-primary)]">Frequency</label>
|
||||
<select
|
||||
value={cron}
|
||||
onChange={(e) => setCron(e.target.value)}
|
||||
className="h-10 px-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
|
||||
>
|
||||
<select value={cron} onChange={(e) => setCron(e.target.value)} className={selectClass}>
|
||||
{FREQUENCY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Advanced toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors self-start"
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-[16px] transition-transform"
|
||||
style={{ transform: showAdvanced ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
chevron_right
|
||||
</span>
|
||||
Advanced options
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="flex flex-col gap-4 pl-2 border-l-2 border-[var(--color-border)]">
|
||||
{/* Model */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-[var(--color-text-primary)]">Model</label>
|
||||
<select value={model} onChange={(e) => setModel(e.target.value)} className={selectClass}>
|
||||
<option value="">Default (current model)</option>
|
||||
{availableModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] text-[var(--color-text-tertiary)]">Override the model used for this task.</p>
|
||||
</div>
|
||||
|
||||
{/* Permission Mode */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-[var(--color-text-primary)]">Permission mode</label>
|
||||
<select value={permissionMode} onChange={(e) => setPermissionMode(e.target.value)} className={selectClass}>
|
||||
{PERMISSION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Folder Path */}
|
||||
<Input
|
||||
label="Working directory"
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
placeholder="/path/to/project"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||
Scheduled tasks use a randomized delay of up to 5 minutes to avoid rate limits.
|
||||
</p>
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useProviderStore } from '../stores/providerStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import { settingsApi } from '../api/settings'
|
||||
import { Modal } from '../components/shared/Modal'
|
||||
import { Input } from '../components/shared/Input'
|
||||
import { Button } from '../components/shared/Button'
|
||||
import type { PermissionMode, EffortLevel, ModelInfo } from '../types/settings'
|
||||
import type { Provider, ProviderModel, UpdateProviderInput, ProviderTestResult } from '../types/provider'
|
||||
|
||||
type SettingsTab = 'model' | 'permissions' | 'general'
|
||||
type SettingsTab = 'providers' | 'model' | 'permissions' | 'general'
|
||||
|
||||
export function Settings() {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('model')
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
|
||||
const setActiveView = useUIStore((s) => s.setActiveView)
|
||||
|
||||
return (
|
||||
@ -26,28 +30,15 @@ export function Settings() {
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Tab navigation */}
|
||||
<div className="w-48 border-r border-[var(--color-border)] py-3 flex-shrink-0">
|
||||
<TabButton
|
||||
icon="auto_awesome"
|
||||
label="Model"
|
||||
active={activeTab === 'model'}
|
||||
onClick={() => setActiveTab('model')}
|
||||
/>
|
||||
<TabButton
|
||||
icon="shield"
|
||||
label="Permissions"
|
||||
active={activeTab === 'permissions'}
|
||||
onClick={() => setActiveTab('permissions')}
|
||||
/>
|
||||
<TabButton
|
||||
icon="tune"
|
||||
label="General"
|
||||
active={activeTab === 'general'}
|
||||
onClick={() => setActiveTab('general')}
|
||||
/>
|
||||
<TabButton icon="dns" label="Providers" active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
|
||||
<TabButton icon="auto_awesome" label="Model" active={activeTab === 'model'} onClick={() => setActiveTab('model')} />
|
||||
<TabButton icon="shield" label="Permissions" active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
|
||||
<TabButton icon="tune" label="General" active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-y-auto px-8 py-6">
|
||||
{activeTab === 'providers' && <ProviderSettings />}
|
||||
{activeTab === 'model' && <ModelSettings />}
|
||||
{activeTab === 'permissions' && <PermissionSettings />}
|
||||
{activeTab === 'general' && <GeneralSettings />}
|
||||
@ -73,10 +64,336 @@ function TabButton({ icon, label, active, onClick }: { icon: string; label: stri
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Model Settings ──────────────────────────────────────────
|
||||
// ─── Provider Settings ──────────────────────────────────────
|
||||
|
||||
function ProviderSettings() {
|
||||
const { providers, isLoading, fetchProviders, deleteProvider, activateProvider, testProvider } = useProviderStore()
|
||||
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [activatingProvider, setActivatingProvider] = useState<Provider | null>(null)
|
||||
const [testResults, setTestResults] = useState<Record<string, { loading: boolean; result?: ProviderTestResult }>>({})
|
||||
|
||||
useEffect(() => { fetchProviders() }, [fetchProviders])
|
||||
|
||||
const handleDelete = async (provider: Provider) => {
|
||||
if (provider.isActive) return
|
||||
if (!window.confirm(`Delete provider "${provider.name}"? This cannot be undone.`)) return
|
||||
try {
|
||||
await deleteProvider(provider.id)
|
||||
} catch (err) {
|
||||
console.error('Failed to delete provider:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async (provider: Provider) => {
|
||||
setTestResults((r) => ({ ...r, [provider.id]: { loading: true } }))
|
||||
try {
|
||||
const result = await testProvider(provider.id)
|
||||
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result } }))
|
||||
} catch {
|
||||
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result: { success: false, latencyMs: 0, error: 'Request failed' } } }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleActivate = async (providerId: string, modelId: string) => {
|
||||
await activateProvider(providerId, modelId)
|
||||
await fetchSettings()
|
||||
setActivatingProvider(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">Providers</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mt-0.5">Manage API providers for model access.</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setShowCreateModal(true)}>
|
||||
<span className="material-symbols-outlined text-[16px]">add</span>
|
||||
Add Provider
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && providers.length === 0 ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="text-center py-12 border border-dashed border-[var(--color-border)] rounded-xl">
|
||||
<span className="material-symbols-outlined text-[36px] text-[var(--color-text-tertiary)] mb-2 block">dns</span>
|
||||
<p className="text-sm text-[var(--color-text-secondary)] mb-3">No providers configured</p>
|
||||
<Button size="sm" onClick={() => setShowCreateModal(true)}>Add your first provider</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{providers.map((provider) => {
|
||||
const test = testResults[provider.id]
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className={`relative flex items-center gap-4 px-4 py-3.5 rounded-xl border transition-all group ${
|
||||
provider.isActive
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)]'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)]'
|
||||
}`}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${provider.isActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)] truncate">{provider.name}</span>
|
||||
{provider.isActive && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-[var(--color-brand)] text-white leading-none">ACTIVE</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
|
||||
{provider.baseUrl} · {provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
{/* Test result inline */}
|
||||
{test && !test.loading && test.result && (
|
||||
<div className={`text-xs mt-1 ${test.result.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
|
||||
{test.result.success ? `Connected (${test.result.latencyMs}ms)` : `Failed: ${test.result.error}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
{!provider.isActive && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setActivatingProvider(provider)}>Activate</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>
|
||||
Test
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditingProvider(provider)}>Edit</Button>
|
||||
{!provider.isActive && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<ProviderFormModal
|
||||
open={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
mode="create"
|
||||
/>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingProvider && (
|
||||
<ProviderFormModal
|
||||
key={editingProvider.id}
|
||||
open={true}
|
||||
onClose={() => setEditingProvider(null)}
|
||||
mode="edit"
|
||||
provider={editingProvider}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Activate — model picker */}
|
||||
{activatingProvider && (
|
||||
<Modal open={true} onClose={() => setActivatingProvider(null)} title={`Activate ${activatingProvider.name}`} width={420}>
|
||||
<p className="text-sm text-[var(--color-text-secondary)] mb-3">Select a model to use with this provider:</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{activatingProvider.models.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => handleActivate(activatingProvider.id, m.id)}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] text-left transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">smart_toy</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)]">{m.name}</div>
|
||||
{m.description && <div className="text-xs text-[var(--color-text-tertiary)]">{m.description}{m.context ? ` · ${m.context}` : ''}</div>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Provider Form Modal ──────────────────────────────────────
|
||||
|
||||
type ProviderFormProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
mode: 'create' | 'edit'
|
||||
provider?: Provider
|
||||
}
|
||||
|
||||
function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) {
|
||||
const { createProvider, updateProvider, testConfig } = useProviderStore()
|
||||
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
||||
|
||||
const [name, setName] = useState(provider?.name ?? '')
|
||||
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? '')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [notes, setNotes] = useState(provider?.notes ?? '')
|
||||
const [models, setModels] = useState<ProviderModel[]>(provider?.models ?? [{ id: '', name: '', description: '', context: '' }])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<ProviderTestResult | null>(null)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
|
||||
const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.some((m) => m.id.trim() && m.name.trim())
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const validModels = models.filter((m) => m.id.trim() && m.name.trim())
|
||||
if (mode === 'create') {
|
||||
await createProvider({
|
||||
name: name.trim(),
|
||||
baseUrl: baseUrl.trim(),
|
||||
apiKey: apiKey.trim(),
|
||||
models: validModels,
|
||||
notes: notes.trim() || undefined,
|
||||
})
|
||||
} else if (provider) {
|
||||
const input: UpdateProviderInput = {
|
||||
name: name.trim(),
|
||||
baseUrl: baseUrl.trim(),
|
||||
models: validModels,
|
||||
notes: notes.trim() || undefined,
|
||||
}
|
||||
if (apiKey.trim()) input.apiKey = apiKey.trim()
|
||||
await updateProvider(provider.id, input)
|
||||
if (provider.isActive) await fetchSettings()
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Failed to save provider:', err)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async () => {
|
||||
const firstModel = models.find((m) => m.id.trim())
|
||||
if (!baseUrl.trim() || !apiKey.trim() || !firstModel) return
|
||||
setIsTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: firstModel.id.trim() })
|
||||
setTestResult(result)
|
||||
} catch {
|
||||
setTestResult({ success: false, latencyMs: 0, error: 'Request failed' })
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addModel = () => setModels([...models, { id: '', name: '', description: '', context: '' }])
|
||||
const removeModel = (index: number) => setModels(models.filter((_, i) => i !== index))
|
||||
const updateModel = (index: number, field: keyof ProviderModel, value: string) => {
|
||||
setModels(models.map((m, i) => i === index ? { ...m, [field]: value } : m))
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={mode === 'create' ? 'Add Provider' : 'Edit Provider'}
|
||||
width={600}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>
|
||||
{mode === 'create' ? 'Add' : 'Save'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input label="Name" required value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. OpenRouter, Anthropic Official" />
|
||||
<Input label="Base URL" required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.anthropic.com" />
|
||||
<Input
|
||||
label={mode === 'edit' ? 'API Key (leave blank to keep current)' : 'API Key'}
|
||||
required={mode === 'create'}
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={mode === 'edit' ? '****' : 'sk-ant-...'}
|
||||
/>
|
||||
<Input label="Notes" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional notes..." />
|
||||
|
||||
{/* Models */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
Models <span className="text-[var(--color-error)]">*</span>
|
||||
</label>
|
||||
<button onClick={addModel} className="text-xs text-[var(--color-brand)] hover:underline">+ Add model</button>
|
||||
</div>
|
||||
{models.map((m, i) => (
|
||||
<div key={i} className="flex gap-2 items-start">
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
<input
|
||||
value={m.id}
|
||||
onChange={(e) => updateModel(i, 'id', e.target.value)}
|
||||
placeholder="Model ID *"
|
||||
className="h-8 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
|
||||
/>
|
||||
<input
|
||||
value={m.name}
|
||||
onChange={(e) => updateModel(i, 'name', e.target.value)}
|
||||
placeholder="Display name *"
|
||||
className="h-8 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
|
||||
/>
|
||||
<input
|
||||
value={m.description ?? ''}
|
||||
onChange={(e) => updateModel(i, 'description', e.target.value)}
|
||||
placeholder="Description"
|
||||
className="h-8 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
|
||||
/>
|
||||
<input
|
||||
value={m.context ?? ''}
|
||||
onChange={(e) => updateModel(i, 'context', e.target.value)}
|
||||
placeholder="Context (e.g. 200k)"
|
||||
className="h-8 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
|
||||
/>
|
||||
</div>
|
||||
{models.length > 1 && (
|
||||
<button onClick={() => removeModel(i)} className="mt-1 p-1 text-[var(--color-text-tertiary)] hover:text-[var(--color-error)] transition-colors">
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Test connection */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="secondary" size="sm" onClick={handleTest} loading={isTesting} disabled={!baseUrl.trim() || !apiKey.trim() || !models.some((m) => m.id.trim())}>
|
||||
Test Connection
|
||||
</Button>
|
||||
{testResult && (
|
||||
<span className={`text-xs ${testResult.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
|
||||
{testResult.success ? `Connected (${testResult.latencyMs}ms)` : `Failed: ${testResult.error}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Model Settings ──────────────────────────────────────
|
||||
|
||||
function ModelSettings() {
|
||||
const { availableModels, currentModel, effortLevel, setModel, setEffort, fetchAll } = useSettingsStore()
|
||||
const { availableModels, currentModel, effortLevel, activeProviderName, setModel, setEffort, fetchAll } = useSettingsStore()
|
||||
const [customModelId, setCustomModelId] = useState('')
|
||||
const [showCustom, setShowCustom] = useState(false)
|
||||
|
||||
@ -117,7 +434,14 @@ function ModelSettings() {
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">Model</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">Choose the AI model for new conversations.</p>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
|
||||
Choose the AI model for new conversations.
|
||||
{activeProviderName && (
|
||||
<span className="ml-1 text-[var(--color-text-secondary)]">
|
||||
(via <span className="font-medium">{activeProviderName}</span>)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
{availableModels.map((model) => {
|
||||
@ -247,51 +571,15 @@ function PermissionSettings() {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── General Settings ──────────────────────────────────────────
|
||||
// ─── General Settings ──────────────────────────────────────
|
||||
|
||||
function GeneralSettings() {
|
||||
const [apiBase, setApiBase] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi.getUser().then((s) => {
|
||||
setApiBase((s as any).apiBaseUrl || '')
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
const updates: Record<string, unknown> = {}
|
||||
if (apiBase) updates.apiBaseUrl = apiBase
|
||||
await settingsApi.updateUser(updates)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">General</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">Advanced configuration settings.</p>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-[var(--color-text-secondary)] mb-1.5">API Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={apiBase}
|
||||
onChange={(e) => setApiBase(e.target.value)}
|
||||
placeholder="https://api.anthropic.com"
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--color-text-tertiary)] mt-1">Override the Anthropic API endpoint. Leave empty for default.</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="self-start px-4 py-2 text-xs font-semibold text-white bg-[var(--color-brand)] rounded-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{saved ? 'Saved!' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
|
||||
API configuration is now managed through the <strong>Providers</strong> tab.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
239
desktop/src/pages/Tasks.tsx
Normal file
239
desktop/src/pages/Tasks.tsx
Normal file
@ -0,0 +1,239 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useCLITaskStore } from '../stores/cliTaskStore'
|
||||
import type { CLITask, TaskListSummary } from '../types/cliTask'
|
||||
|
||||
export function Tasks() {
|
||||
const { taskLists, selectedListId, tasks, isLoading, fetchTaskLists, selectTaskList, clearSelection } = useCLITaskStore()
|
||||
|
||||
useEffect(() => { fetchTaskLists() }, [fetchTaskLists])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Sidebar: task lists */}
|
||||
<div className="w-64 border-r border-[var(--color-border)] flex flex-col overflow-hidden bg-[var(--color-surface)]">
|
||||
<div className="px-4 py-4 border-b border-[var(--color-border)]">
|
||||
<h2 className="text-sm font-bold text-[var(--color-text-primary)]">Task Lists</h2>
|
||||
<p className="text-[10px] text-[var(--color-text-tertiary)] mt-0.5">CLI task tracking sessions</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{isLoading && taskLists.length === 0 ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin w-4 h-4 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : taskLists.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<span className="material-symbols-outlined text-[28px] text-[var(--color-text-tertiary)] block mb-2">task_alt</span>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">No task lists found</p>
|
||||
</div>
|
||||
) : (
|
||||
taskLists.map((list) => (
|
||||
<TaskListItem
|
||||
key={list.id}
|
||||
list={list}
|
||||
selected={selectedListId === list.id}
|
||||
onClick={() => selectTaskList(list.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main: task details */}
|
||||
<div className="flex-1 overflow-y-auto bg-[var(--color-surface)]">
|
||||
{selectedListId ? (
|
||||
<TaskListDetail
|
||||
listId={selectedListId}
|
||||
tasks={tasks}
|
||||
isLoading={isLoading}
|
||||
onBack={clearSelection}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<span className="material-symbols-outlined text-[48px] text-[var(--color-text-tertiary)] mb-3">checklist</span>
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">Select a task list to view tasks</p>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">{taskLists.length} task list{taskLists.length !== 1 ? 's' : ''} available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Task List Item ─────────────────────────────────────────
|
||||
|
||||
function TaskListItem({ list, selected, onClick }: { list: TaskListSummary; selected: boolean; onClick: () => void }) {
|
||||
const isTeamOrNamed = !list.id.match(/^[0-9a-f]{8}-/)
|
||||
const displayName = isTeamOrNamed ? list.id : list.id.slice(0, 8) + '...'
|
||||
|
||||
const progressPercent = list.taskCount > 0
|
||||
? Math.round((list.completedCount / list.taskCount) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full flex flex-col gap-1 px-4 py-2.5 text-left transition-colors ${
|
||||
selected
|
||||
? 'bg-[var(--color-surface-selected)]'
|
||||
: 'hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">
|
||||
{isTeamOrNamed ? 'group' : 'terminal'}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-[var(--color-text-primary)] truncate">{displayName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-5">
|
||||
{/* Mini progress bar */}
|
||||
<div className="flex-1 h-1 rounded-full bg-[var(--color-border)] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--color-success)] transition-all"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)] flex-shrink-0">
|
||||
{list.completedCount}/{list.taskCount}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Task List Detail ──────────────────────────────────────
|
||||
|
||||
function TaskListDetail({ listId, tasks, isLoading, onBack }: {
|
||||
listId: string
|
||||
tasks: CLITask[]
|
||||
isLoading: boolean
|
||||
onBack: () => void
|
||||
}) {
|
||||
const completedCount = tasks.filter((t) => t.status === 'completed').length
|
||||
const inProgressCount = tasks.filter((t) => t.status === 'in_progress').length
|
||||
const pendingCount = tasks.filter((t) => t.status === 'pending').length
|
||||
|
||||
return (
|
||||
<div className="px-8 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button onClick={onBack} className="p-1 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors text-[var(--color-text-secondary)] lg:hidden">
|
||||
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-lg font-bold text-[var(--color-text-primary)] truncate">{listId}</h1>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span>{tasks.length} tasks</span>
|
||||
{completedCount > 0 && <StatusBadge status="completed" count={completedCount} />}
|
||||
{inProgressCount > 0 && <StatusBadge status="in_progress" count={inProgressCount} />}
|
||||
{pendingCount > 0 && <StatusBadge status="pending" count={pendingCount} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task list */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">No tasks in this list</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} allTasks={tasks} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Task Card ──────────────────────────────────────────────
|
||||
|
||||
function TaskCard({ task, allTasks }: { task: CLITask; allTasks: CLITask[] }) {
|
||||
const statusConfig = {
|
||||
pending: { icon: 'radio_button_unchecked', color: 'text-[var(--color-text-tertiary)]', bg: '' },
|
||||
in_progress: { icon: 'pending', color: 'text-[var(--color-warning)]', bg: 'bg-amber-50/50' },
|
||||
completed: { icon: 'check_circle', color: 'text-[var(--color-success)]', bg: '' },
|
||||
}
|
||||
|
||||
const config = statusConfig[task.status]
|
||||
|
||||
// Resolve blocker names
|
||||
const blockerNames = task.blockedBy
|
||||
.map((id) => allTasks.find((t) => t.id === id))
|
||||
.filter(Boolean)
|
||||
.map((t) => `#${t!.id} ${t!.subject}`)
|
||||
|
||||
const blockingNames = task.blocks
|
||||
.map((id) => allTasks.find((t) => t.id === id))
|
||||
.filter(Boolean)
|
||||
.map((t) => `#${t!.id}`)
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 px-4 py-3 rounded-xl border border-[var(--color-border)] transition-colors hover:border-[var(--color-border-focus)] ${config.bg}`}>
|
||||
{/* Status icon */}
|
||||
<span className={`material-symbols-outlined text-[20px] mt-0.5 flex-shrink-0 ${config.color}`} style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||
{config.icon}
|
||||
</span>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono text-[var(--color-text-tertiary)]">#{task.id}</span>
|
||||
<span className="text-sm font-medium text-[var(--color-text-primary)]">{task.subject}</span>
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-xs text-[var(--color-text-secondary)] mt-0.5 line-clamp-2">{task.description}</p>
|
||||
)}
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex items-center gap-3 mt-1.5 flex-wrap">
|
||||
{task.owner && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-tertiary)]">
|
||||
<span className="material-symbols-outlined text-[12px]">person</span>
|
||||
{task.owner}
|
||||
</span>
|
||||
)}
|
||||
{task.blockedBy.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-error)]" title={blockerNames.join(', ')}>
|
||||
<span className="material-symbols-outlined text-[12px]">block</span>
|
||||
blocked by {task.blockedBy.map((id) => `#${id}`).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{task.blocks.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-tertiary)]" title={blockingNames.join(', ')}>
|
||||
<span className="material-symbols-outlined text-[12px]">arrow_forward</span>
|
||||
blocks {blockingNames.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Status Badge ───────────────────────────────────────────
|
||||
|
||||
function StatusBadge({ status, count }: { status: string; count: number }) {
|
||||
const styles: Record<string, string> = {
|
||||
completed: 'text-[var(--color-success)]',
|
||||
in_progress: 'text-[var(--color-warning)]',
|
||||
pending: 'text-[var(--color-text-tertiary)]',
|
||||
}
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
completed: 'done',
|
||||
in_progress: 'active',
|
||||
pending: 'pending',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`${styles[status] || ''}`}>
|
||||
{count} {labels[status] || status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
45
desktop/src/stores/cliTaskStore.ts
Normal file
45
desktop/src/stores/cliTaskStore.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { create } from 'zustand'
|
||||
import { cliTasksApi } from '../api/cliTasks'
|
||||
import type { CLITask, TaskListSummary } from '../types/cliTask'
|
||||
|
||||
type CLITaskStore = {
|
||||
taskLists: TaskListSummary[]
|
||||
selectedListId: string | null
|
||||
tasks: CLITask[]
|
||||
isLoading: boolean
|
||||
|
||||
fetchTaskLists: () => Promise<void>
|
||||
selectTaskList: (id: string) => Promise<void>
|
||||
clearSelection: () => void
|
||||
}
|
||||
|
||||
export const useCLITaskStore = create<CLITaskStore>((set) => ({
|
||||
taskLists: [],
|
||||
selectedListId: null,
|
||||
tasks: [],
|
||||
isLoading: false,
|
||||
|
||||
fetchTaskLists: async () => {
|
||||
set({ isLoading: true })
|
||||
try {
|
||||
const { lists } = await cliTasksApi.listTaskLists()
|
||||
set({ taskLists: lists, isLoading: false })
|
||||
} catch {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
selectTaskList: async (id) => {
|
||||
set({ selectedListId: id, isLoading: true })
|
||||
try {
|
||||
const { tasks } = await cliTasksApi.getTasksForList(id)
|
||||
set({ tasks, isLoading: false })
|
||||
} catch {
|
||||
set({ tasks: [], isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
set({ selectedListId: null, tasks: [] })
|
||||
},
|
||||
}))
|
||||
69
desktop/src/stores/providerStore.ts
Normal file
69
desktop/src/stores/providerStore.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { create } from 'zustand'
|
||||
import { providersApi } from '../api/providers'
|
||||
import type {
|
||||
Provider,
|
||||
CreateProviderInput,
|
||||
UpdateProviderInput,
|
||||
TestProviderConfigInput,
|
||||
ProviderTestResult,
|
||||
} from '../types/provider'
|
||||
|
||||
type ProviderStore = {
|
||||
providers: Provider[]
|
||||
isLoading: boolean
|
||||
|
||||
fetchProviders: () => Promise<void>
|
||||
createProvider: (input: CreateProviderInput) => Promise<Provider>
|
||||
updateProvider: (id: string, input: UpdateProviderInput) => Promise<Provider>
|
||||
deleteProvider: (id: string) => Promise<void>
|
||||
activateProvider: (id: string, modelId: string) => Promise<void>
|
||||
testProvider: (id: string) => Promise<ProviderTestResult>
|
||||
testConfig: (input: TestProviderConfigInput) => Promise<ProviderTestResult>
|
||||
}
|
||||
|
||||
export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
providers: [],
|
||||
isLoading: false,
|
||||
|
||||
fetchProviders: async () => {
|
||||
set({ isLoading: true })
|
||||
try {
|
||||
const { providers } = await providersApi.list()
|
||||
set({ providers, isLoading: false })
|
||||
} catch {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
createProvider: async (input) => {
|
||||
const { provider } = await providersApi.create(input)
|
||||
await get().fetchProviders()
|
||||
return provider
|
||||
},
|
||||
|
||||
updateProvider: async (id, input) => {
|
||||
const { provider } = await providersApi.update(id, input)
|
||||
await get().fetchProviders()
|
||||
return provider
|
||||
},
|
||||
|
||||
deleteProvider: async (id) => {
|
||||
await providersApi.delete(id)
|
||||
await get().fetchProviders()
|
||||
},
|
||||
|
||||
activateProvider: async (id, modelId) => {
|
||||
await providersApi.activate(id, modelId)
|
||||
await get().fetchProviders()
|
||||
},
|
||||
|
||||
testProvider: async (id) => {
|
||||
const { result } = await providersApi.test(id)
|
||||
return result
|
||||
},
|
||||
|
||||
testConfig: async (input) => {
|
||||
const { result } = await providersApi.testConfig(input)
|
||||
return result
|
||||
},
|
||||
}))
|
||||
@ -8,6 +8,7 @@ type SettingsStore = {
|
||||
currentModel: ModelInfo | null
|
||||
effortLevel: EffortLevel
|
||||
availableModels: ModelInfo[]
|
||||
activeProviderName: string | null
|
||||
isLoading: boolean
|
||||
|
||||
fetchAll: () => Promise<void>
|
||||
@ -21,18 +22,26 @@ export const useSettingsStore = create<SettingsStore>((set) => ({
|
||||
currentModel: null,
|
||||
effortLevel: 'high',
|
||||
availableModels: [],
|
||||
activeProviderName: null,
|
||||
isLoading: false,
|
||||
|
||||
fetchAll: async () => {
|
||||
set({ isLoading: true })
|
||||
try {
|
||||
const [{ mode }, { models }, { model }, { level }] = await Promise.all([
|
||||
const [{ mode }, modelsRes, { model }, { level }] = await Promise.all([
|
||||
settingsApi.getPermissionMode(),
|
||||
modelsApi.list(),
|
||||
modelsApi.getCurrent(),
|
||||
modelsApi.getEffort(),
|
||||
])
|
||||
set({ permissionMode: mode, availableModels: models, currentModel: model, effortLevel: level, isLoading: false })
|
||||
set({
|
||||
permissionMode: mode,
|
||||
availableModels: modelsRes.models,
|
||||
activeProviderName: modelsRes.provider?.name ?? null,
|
||||
currentModel: model,
|
||||
effortLevel: level,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ export type Toast = {
|
||||
duration?: number
|
||||
}
|
||||
|
||||
type ActiveView = 'code' | 'scheduled' | 'terminal' | 'history' | 'settings'
|
||||
type ActiveView = 'code' | 'scheduled' | 'tasks' | 'terminal' | 'history' | 'settings'
|
||||
|
||||
type UIStore = {
|
||||
theme: 'light' | 'dark'
|
||||
|
||||
24
desktop/src/types/cliTask.ts
Normal file
24
desktop/src/types/cliTask.ts
Normal file
@ -0,0 +1,24 @@
|
||||
// Source: src/server/services/taskService.ts (CLI V2 Tasks)
|
||||
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed'
|
||||
|
||||
export type CLITask = {
|
||||
id: string
|
||||
subject: string
|
||||
description: string
|
||||
activeForm?: string
|
||||
owner?: string
|
||||
status: TaskStatus
|
||||
blocks: string[]
|
||||
blockedBy: string[]
|
||||
metadata?: Record<string, unknown>
|
||||
taskListId: string
|
||||
}
|
||||
|
||||
export type TaskListSummary = {
|
||||
id: string
|
||||
taskCount: number
|
||||
completedCount: number
|
||||
inProgressCount: number
|
||||
pendingCount: number
|
||||
}
|
||||
50
desktop/src/types/provider.ts
Normal file
50
desktop/src/types/provider.ts
Normal file
@ -0,0 +1,50 @@
|
||||
// Source: src/server/types/provider.ts
|
||||
|
||||
export type ProviderModel = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
context?: string
|
||||
}
|
||||
|
||||
export type Provider = {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
apiKey: string // masked from server: "sk-a****xyz"
|
||||
models: ProviderModel[]
|
||||
isActive: boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export type CreateProviderInput = {
|
||||
name: string
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
models: ProviderModel[]
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export type UpdateProviderInput = {
|
||||
name?: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
models?: ProviderModel[]
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export type TestProviderConfigInput = {
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
modelId: string
|
||||
}
|
||||
|
||||
export type ProviderTestResult = {
|
||||
success: boolean
|
||||
latencyMs: number
|
||||
error?: string
|
||||
modelUsed?: string
|
||||
httpStatus?: number
|
||||
}
|
||||
@ -8,7 +8,7 @@ export type ModelInfo = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
context: number
|
||||
context: string
|
||||
}
|
||||
|
||||
export type UserSettings = {
|
||||
|
||||
@ -98,6 +98,11 @@ async function handleAgents(
|
||||
}
|
||||
|
||||
// ─── Tasks API ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// GET /api/tasks → list all tasks (across all task lists)
|
||||
// GET /api/tasks/lists → list all task lists with summaries
|
||||
// GET /api/tasks/lists/:taskListId → get all tasks for a specific task list
|
||||
// GET /api/tasks/lists/:taskListId/:id → get a single task
|
||||
|
||||
async function handleTasksApi(
|
||||
req: Request,
|
||||
@ -111,18 +116,32 @@ async function handleTasksApi(
|
||||
)
|
||||
}
|
||||
|
||||
const taskId = segments[2]
|
||||
const sub = segments[2] // 'lists' or undefined
|
||||
|
||||
if (taskId) {
|
||||
// GET /api/tasks/:id
|
||||
const task = await taskService.getTask(taskId)
|
||||
if (!task) {
|
||||
throw ApiError.notFound(`Task not found: ${taskId}`)
|
||||
// GET /api/tasks/lists — list all task lists
|
||||
if (sub === 'lists') {
|
||||
const taskListId = segments[3]
|
||||
const taskId = segments[4]
|
||||
|
||||
if (taskListId && taskId) {
|
||||
// GET /api/tasks/lists/:taskListId/:taskId
|
||||
const task = await taskService.getTask(taskListId, taskId)
|
||||
if (!task) throw ApiError.notFound(`Task not found: ${taskListId}/${taskId}`)
|
||||
return Response.json({ task })
|
||||
}
|
||||
return Response.json({ task })
|
||||
|
||||
if (taskListId) {
|
||||
// GET /api/tasks/lists/:taskListId
|
||||
const tasks = await taskService.getTasksForList(taskListId)
|
||||
return Response.json({ tasks })
|
||||
}
|
||||
|
||||
// GET /api/tasks/lists
|
||||
const lists = await taskService.listTaskLists()
|
||||
return Response.json({ lists })
|
||||
}
|
||||
|
||||
// GET /api/tasks
|
||||
// GET /api/tasks — list all tasks
|
||||
const tasks = await taskService.listTasks()
|
||||
return Response.json({ tasks })
|
||||
}
|
||||
|
||||
@ -1,25 +1,35 @@
|
||||
/**
|
||||
* TaskService — 后台任务的读取与查询
|
||||
* TaskService — CLI Task V2 的读取与查询
|
||||
*
|
||||
* 任务信息存储在 ~/.claude/tasks/ 目录下,每个任务一个 JSON 文件。
|
||||
* 支持嵌套子目录(例如按 team/project 分组)。
|
||||
* 任务信息存储在 ~/.claude/tasks/<task_list_id>/ 目录下,每个任务一个 JSON 文件。
|
||||
* Task list ID 可以是 session ID、team name 等。
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed'
|
||||
|
||||
export type TaskInfo = {
|
||||
id: string
|
||||
type: 'local_shell' | 'local_agent' | 'in_process_teammate' | 'remote_agent' | 'workflow'
|
||||
status: 'running' | 'completed' | 'failed' | 'pending'
|
||||
name?: string
|
||||
description?: string
|
||||
createdAt?: number
|
||||
completedAt?: number
|
||||
teamName?: string
|
||||
agentName?: string
|
||||
subject: string
|
||||
description: string
|
||||
activeForm?: string
|
||||
owner?: string
|
||||
status: TaskStatus
|
||||
blocks: string[]
|
||||
blockedBy: string[]
|
||||
metadata?: Record<string, unknown>
|
||||
taskListId: string
|
||||
}
|
||||
|
||||
export type TaskListSummary = {
|
||||
id: string
|
||||
taskCount: number
|
||||
completedCount: number
|
||||
inProgressCount: number
|
||||
pendingCount: number
|
||||
}
|
||||
|
||||
export class TaskService {
|
||||
@ -31,68 +41,105 @@ export class TaskService {
|
||||
return path.join(this.getConfigDir(), 'tasks')
|
||||
}
|
||||
|
||||
/** 列出所有持久化任务 */
|
||||
async listTasks(): Promise<TaskInfo[]> {
|
||||
/** 列出所有 task list (目录) */
|
||||
async listTaskLists(): Promise<TaskListSummary[]> {
|
||||
const tasksDir = this.getTasksDir()
|
||||
try {
|
||||
const result: TaskInfo[] = []
|
||||
await this.scanTasksRecursive(tasksDir, result)
|
||||
return result.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
|
||||
const entries = await fs.readdir(tasksDir, { withFileTypes: true })
|
||||
const results: TaskListSummary[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const tasks = await this.getTasksForList(entry.name)
|
||||
if (tasks.length === 0) continue
|
||||
|
||||
results.push({
|
||||
id: entry.name,
|
||||
taskCount: tasks.length,
|
||||
completedCount: tasks.filter((t) => t.status === 'completed').length,
|
||||
inProgressCount: tasks.filter((t) => t.status === 'in_progress').length,
|
||||
pendingCount: tasks.filter((t) => t.status === 'pending').length,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') return []
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** 递归扫描任务目录 */
|
||||
private async scanTasksRecursive(dir: string, result: TaskInfo[]): Promise<void> {
|
||||
let entries
|
||||
/** 获取指定 task list 的所有任务 */
|
||||
async getTasksForList(taskListId: string): Promise<TaskInfo[]> {
|
||||
const listDir = path.join(this.getTasksDir(), taskListId)
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const entries = await fs.readdir(listDir)
|
||||
const tasks: TaskInfo[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await this.scanTasksRecursive(fullPath, result)
|
||||
} else if (entry.name.endsWith('.json')) {
|
||||
for (const filename of entries) {
|
||||
if (!filename.endsWith('.json')) continue
|
||||
try {
|
||||
const raw = await fs.readFile(fullPath, 'utf-8')
|
||||
const raw = await fs.readFile(path.join(listDir, filename), 'utf-8')
|
||||
const data = JSON.parse(raw)
|
||||
const task = this.parseTaskFile(entry.name, data)
|
||||
if (task) result.push(task)
|
||||
const task = this.parseTaskFile(data, taskListId)
|
||||
if (task) tasks.push(task)
|
||||
} catch {
|
||||
// 跳过无法解析的文件
|
||||
// skip unparseable files
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by numeric ID
|
||||
return tasks.sort((a, b) => {
|
||||
const numA = parseInt(a.id, 10)
|
||||
const numB = parseInt(b.id, 10)
|
||||
if (!isNaN(numA) && !isNaN(numB)) return numA - numB
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') return []
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析单个任务文件 */
|
||||
private parseTaskFile(filename: string, data: any): TaskInfo | null {
|
||||
if (!data || typeof data !== 'object') return null
|
||||
|
||||
const id = filename.replace('.json', '')
|
||||
return {
|
||||
id: data.id || id,
|
||||
type: data.type || 'local_agent',
|
||||
status: data.status || 'completed',
|
||||
name: data.name || data.title,
|
||||
description: data.description || data.prompt,
|
||||
createdAt: data.createdAt,
|
||||
completedAt: data.completedAt,
|
||||
teamName: data.teamName,
|
||||
agentName: data.agentName || data.name,
|
||||
metadata: data.metadata,
|
||||
/** 列出所有任务(跨所有 task list) */
|
||||
async listTasks(): Promise<TaskInfo[]> {
|
||||
const taskLists = await this.listTaskLists()
|
||||
const allTasks: TaskInfo[] = []
|
||||
for (const list of taskLists) {
|
||||
const tasks = await this.getTasksForList(list.id)
|
||||
allTasks.push(...tasks)
|
||||
}
|
||||
return allTasks
|
||||
}
|
||||
|
||||
/** 获取单个任务详情 */
|
||||
async getTask(taskId: string): Promise<TaskInfo | null> {
|
||||
const tasks = await this.listTasks()
|
||||
return tasks.find(t => t.id === taskId) || null
|
||||
async getTask(taskListId: string, taskId: string): Promise<TaskInfo | null> {
|
||||
const tasks = await this.getTasksForList(taskListId)
|
||||
return tasks.find((t) => t.id === taskId) || null
|
||||
}
|
||||
|
||||
/** 解析单个任务文件 — 匹配 CLI V2 Task 格式 */
|
||||
private parseTaskFile(data: any, taskListId: string): TaskInfo | null {
|
||||
if (!data || typeof data !== 'object') return null
|
||||
if (!data.id || !data.subject) return null
|
||||
|
||||
// Skip internal tasks
|
||||
if (data.metadata?._internal) return null
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
subject: data.subject || '',
|
||||
description: data.description || '',
|
||||
activeForm: data.activeForm,
|
||||
owner: data.owner,
|
||||
status: (['pending', 'in_progress', 'completed'].includes(data.status)
|
||||
? data.status
|
||||
: 'pending') as TaskStatus,
|
||||
blocks: Array.isArray(data.blocks) ? data.blocks : [],
|
||||
blockedBy: Array.isArray(data.blockedBy) ? data.blockedBy : [],
|
||||
metadata: data.metadata,
|
||||
taskListId,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user