mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 00:15:05 +00:00
refactor(desktop): react-arch audit — schemas, RHF forms, file splits, services, store cleanup, dir restructure
- Phase 1: add zod, react-hook-form, @hookform/resolvers; create 8 Zod schemas - Phase 2: migrate 6 form dialogs from useState to useForm+zodResolver - Phase 3: split 24 files >200L into ~40 focused modules (0 files >300L remaining) - Phase 4: create services/ layer (8 service files), eliminate direct ws.call in components - Phase 5: split chat-store into chat-message-store + chat-activity-store - Phase 6: flatten settings/ directory — move 9 feature subdirs to top-level components/
This commit is contained in:
1 parent
4d9c286aae
commit
6be88f4178
123 files changed
+4717
-4346
No files matched your search
@@ -12,17 +12,20 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"i18next": "^26.0.1",
|
||||
"immer": "^11.1.4",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-i18next": "^17.0.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+39
@@ -17,6 +17,9 @@ importers:
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.4)
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.72.1(react@19.2.4))
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
@@ -35,6 +38,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^19.2.4
|
||||
version: 19.2.4(react@19.2.4)
|
||||
react-hook-form:
|
||||
specifier: ^7.72.1
|
||||
version: 7.72.1(react@19.2.4)
|
||||
react-i18next:
|
||||
specifier: ^17.0.1
|
||||
version: 17.0.1(i18next@26.0.1(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2)
|
||||
@@ -50,6 +56,9 @@ importers:
|
||||
remark-gfm:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
zustand:
|
||||
specifier: ^5.0.12
|
||||
version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
|
||||
@@ -116,6 +125,11 @@ packages:
|
||||
'@emnapi/wasi-threads@1.2.0':
|
||||
resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==}
|
||||
|
||||
'@hookform/resolvers@5.2.2':
|
||||
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
|
||||
peerDependencies:
|
||||
react-hook-form: ^7.55.0
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
@@ -242,6 +256,9 @@ packages:
|
||||
'@rolldown/pluginutils@1.0.0-rc.7':
|
||||
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
|
||||
|
||||
'@standard-schema/utils@0.3.0':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
'@tailwindcss/node@4.2.2':
|
||||
resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
|
||||
|
||||
@@ -812,6 +829,12 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.2.4
|
||||
|
||||
react-hook-form@7.72.1:
|
||||
resolution: {integrity: sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||
|
||||
react-i18next@17.0.1:
|
||||
resolution: {integrity: sha512-iG65FGnFHcYyHNuT01ukffYWCOBFTWSdVD8EZd/dCVWgtjFPObcSsvYYNwcsokO/rDcTb5d6D8Acv8MrOdm6Hw==}
|
||||
peerDependencies:
|
||||
@@ -987,6 +1010,9 @@ packages:
|
||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
zustand@5.0.12:
|
||||
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -1053,6 +1079,11 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@hookform/resolvers@5.2.2(react-hook-form@7.72.1(react@19.2.4))':
|
||||
dependencies:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
react-hook-form: 7.72.1(react@19.2.4)
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -1135,6 +1166,8 @@ snapshots:
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@tailwindcss/node@4.2.2':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
@@ -1844,6 +1877,10 @@ snapshots:
|
||||
react: 19.2.4
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-hook-form@7.72.1(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
react-i18next@17.0.1(i18next@26.0.1(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
@@ -2054,6 +2091,8 @@ snapshots:
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
zustand@5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
@@ -8,7 +8,8 @@ import { wails } from './lib/wails'
|
||||
import { initWsClient } from './lib/ws'
|
||||
import { initApiClient } from './lib/api'
|
||||
import { useSessionStore } from './stores/session-store'
|
||||
import { useChatStore } from './stores/chat-store'
|
||||
import { useChatMessageStore } from './stores/chat-message-store'
|
||||
import { useChatActivityStore } from './stores/chat-activity-store'
|
||||
import { ErrorBoundary } from './components/common/ErrorBoundary'
|
||||
import { Toaster } from './components/common/Toaster'
|
||||
import { SplashScreen } from './components/common/SplashScreen'
|
||||
@@ -27,7 +28,8 @@ function AppReady() {
|
||||
e.preventDefault()
|
||||
// "New Chat" — clear session + chat directly (avoids duplicate useSessions instance)
|
||||
useSessionStore.getState().setActiveSession(null)
|
||||
useChatStore.getState().clear()
|
||||
useChatMessageStore.getState().clear()
|
||||
useChatActivityStore.getState().clear()
|
||||
}
|
||||
if (mod && e.key === ',') { e.preventDefault(); openSettings() }
|
||||
if (e.key === 'Escape' && activeView === 'settings') { closeSettings() }
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AgentData } from '../../../types/agent'
|
||||
import type { AgentData } from '../../types/agent'
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ import { EvolutionSection } from './EvolutionSection'
|
||||
import { AgentSkillsSection } from './AgentSkillsSection'
|
||||
import { AgentMcpSection } from './AgentMcpSection'
|
||||
import { AgentFilesTab } from './AgentFilesTab'
|
||||
import { ConfirmDialog } from '../../common/ConfirmDialog'
|
||||
import type { AgentData } from '../../../types/agent'
|
||||
import { ConfirmDialog } from '../common/ConfirmDialog'
|
||||
import type { AgentData } from '../../types/agent'
|
||||
|
||||
type DetailTab = 'overview' | 'files'
|
||||
|
||||
+14
-89
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getWsClient } from '../../../lib/ws'
|
||||
import { getApiClient } from '../../../lib/api'
|
||||
import type { BootstrapFile } from '../../../types/agent'
|
||||
import { agentService } from '../../services/agent-service'
|
||||
import { RegenerateDialog } from './agent-regenerate-dialog'
|
||||
import type { BootstrapFile } from '../../types/agent'
|
||||
|
||||
/** Files hidden from desktop UI — USER.md is per-user (managed by bootstrap), HEARTBEAT.md needs cron config */
|
||||
const HIDDEN_FILES = ['MEMORY.json', 'BOOTSTRAP.md', 'USER.md', 'USER_PREDEFINED.md', 'HEARTBEAT.md']
|
||||
@@ -24,16 +24,12 @@ export function AgentFilesTab({ agentId, agentKey, agentType }: AgentFilesTabPro
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [regenerateOpen, setRegenerateOpen] = useState(false)
|
||||
|
||||
// Load file list
|
||||
useEffect(() => {
|
||||
const ws = getWsClient()
|
||||
setLoading(true)
|
||||
ws.call('agents.files.list', { agentId: agentKey })
|
||||
.then((res: unknown) => {
|
||||
const r = res as { files?: BootstrapFile[] }
|
||||
const fileList = (r?.files ?? []).filter((f) => !HIDDEN_FILES.includes(f.name))
|
||||
agentService.listFiles(agentKey)
|
||||
.then((res) => {
|
||||
const fileList = (res?.files ?? []).filter((f) => !HIDDEN_FILES.includes(f.name))
|
||||
setFiles(fileList)
|
||||
// Auto-select first file
|
||||
if (fileList.length > 0 && !selectedFile) {
|
||||
setSelectedFile(fileList[0].name)
|
||||
}
|
||||
@@ -42,12 +38,10 @@ export function AgentFilesTab({ agentId, agentKey, agentType }: AgentFilesTabPro
|
||||
.finally(() => setLoading(false))
|
||||
}, [agentKey, agentType, selectedFile])
|
||||
|
||||
// Load file content
|
||||
const loadFile = useCallback(async (name: string) => {
|
||||
setFileLoading(true)
|
||||
try {
|
||||
const ws = getWsClient()
|
||||
const res = (await ws.call('agents.files.get', { agentId: agentKey, name })) as { file?: BootstrapFile }
|
||||
const res = await agentService.getFile(agentKey, name)
|
||||
setContent(res?.file?.content ?? '')
|
||||
setDirty(false)
|
||||
} catch {
|
||||
@@ -65,8 +59,7 @@ export function AgentFilesTab({ agentId, agentKey, agentType }: AgentFilesTabPro
|
||||
if (!selectedFile) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const ws = getWsClient()
|
||||
await ws.call('agents.files.set', { agentId: agentKey, name: selectedFile, content })
|
||||
await agentService.setFile(agentKey, selectedFile, content)
|
||||
setDirty(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to save file:', err)
|
||||
@@ -77,8 +70,7 @@ export function AgentFilesTab({ agentId, agentKey, agentType }: AgentFilesTabPro
|
||||
|
||||
const handleRegenerate = async (prompt: string) => {
|
||||
try {
|
||||
await getApiClient().post(`/v1/agents/${agentId}/regenerate`, { prompt })
|
||||
// Reload current file after regeneration
|
||||
await agentService.regenerate(agentId, prompt)
|
||||
if (selectedFile) {
|
||||
setTimeout(() => loadFile(selectedFile), 2000)
|
||||
}
|
||||
@@ -176,78 +168,11 @@ export function AgentFilesTab({ agentId, agentKey, agentType }: AgentFilesTabPro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit with AI dialog */}
|
||||
{regenerateOpen && (
|
||||
<RegenerateDialog
|
||||
open={regenerateOpen}
|
||||
onOpenChange={setRegenerateOpen}
|
||||
onRegenerate={handleRegenerate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Dialog for "Edit with AI" — sends prompt to regenerate endpoint */
|
||||
function RegenerateDialog({ open, onOpenChange, onRegenerate }: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onRegenerate: (prompt: string) => Promise<void>
|
||||
}) {
|
||||
const { t } = useTranslation('agents')
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!prompt.trim()) return
|
||||
setLoading(true)
|
||||
try {
|
||||
await onRegenerate(prompt.trim())
|
||||
onOpenChange(false)
|
||||
setPrompt('')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-surface-secondary border border-border rounded-xl shadow-xl max-w-lg w-full mx-4 p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
||||
</svg>
|
||||
Edit with AI
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
Describe how you want to modify the agent's personality, knowledge, or behavior. The AI will regenerate the context files accordingly.
|
||||
</p>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="e.g. Make the agent more formal and add expertise in data analysis..."
|
||||
rows={4}
|
||||
autoFocus
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent resize-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => onOpenChange(false)} disabled={loading} className="px-3 py-1.5 text-xs border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!prompt.trim() || loading}
|
||||
className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
||||
</svg>
|
||||
{loading ? t('fileEditor.sending') : t('fileEditor.regenerate')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<RegenerateDialog
|
||||
open={regenerateOpen}
|
||||
onOpenChange={setRegenerateOpen}
|
||||
onRegenerate={handleRegenerate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProviders } from '../../hooks/use-providers'
|
||||
import { getApiClient } from '../../lib/api'
|
||||
import { slugify } from '../../lib/slug'
|
||||
import { AgentPresetSelector } from './agent-preset-selector'
|
||||
import { AgentIdentityFields } from './agent-identity-fields'
|
||||
import { agentFormSchema, type AgentFormData } from '../../schemas/agent.schema'
|
||||
import type { AgentData, AgentInput } from '../../types/agent'
|
||||
|
||||
interface AgentFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
agent?: AgentData | null
|
||||
onSubmit: (input: AgentInput) => Promise<AgentData | void>
|
||||
}
|
||||
|
||||
export function AgentFormDialog({ open, onOpenChange, agent, onSubmit }: AgentFormDialogProps) {
|
||||
const isEditing = !!agent
|
||||
const { t } = useTranslation(['agents', 'desktop', 'common'])
|
||||
const { providers } = useProviders()
|
||||
|
||||
const { register, handleSubmit, watch, setValue, reset, formState: { errors, isSubmitting } } = useForm<AgentFormData>({
|
||||
resolver: zodResolver(agentFormSchema),
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
// UI-only state (not form data)
|
||||
const [models, setModels] = useState<string[]>([])
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [verifyResult, setVerifyResult] = useState<{ valid: boolean; error?: string } | null>(null)
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
const [submitError, setSubmitError] = useState('')
|
||||
const [selectedPresetKey, setSelectedPresetKey] = useState('')
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
reset({
|
||||
displayName: agent?.display_name ?? '',
|
||||
emoji: (agent?.other_config?.emoji as string) ?? '🦊',
|
||||
agentKey: agent?.agent_key ?? '',
|
||||
providerName: agent?.provider ?? '',
|
||||
model: agent?.model ?? '',
|
||||
description: (agent?.other_config?.description as string) ?? '',
|
||||
isDefault: agent?.is_default ?? false,
|
||||
})
|
||||
setSubmitError('')
|
||||
setSelectedPresetKey('')
|
||||
setVerifyResult(isEditing ? { valid: true } : null)
|
||||
setModels([])
|
||||
}, [open, agent, isEditing, reset])
|
||||
|
||||
const providerName = watch('providerName')
|
||||
const model = watch('model')
|
||||
const displayName = watch('displayName')
|
||||
|
||||
// Derive agentKey from displayName/preset when creating
|
||||
useEffect(() => {
|
||||
if (isEditing) return
|
||||
const derived = selectedPresetKey || slugify(displayName) || 'agent'
|
||||
setValue('agentKey', derived)
|
||||
}, [displayName, selectedPresetKey, isEditing, setValue])
|
||||
|
||||
const selectedProvider = useMemo(() => providers.find((p) => p.name === providerName), [providers, providerName])
|
||||
|
||||
// Load models when provider changes
|
||||
const loadModels = useCallback(async (providerId: string) => {
|
||||
setModelsLoading(true)
|
||||
try {
|
||||
const res = await getApiClient().get<{ models: Array<{ id: string }> }>(`/v1/providers/${providerId}/models`)
|
||||
setModels((res.models ?? []).map((m) => m.id))
|
||||
} catch { setModels([]) } finally { setModelsLoading(false) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { if (selectedProvider?.id) loadModels(selectedProvider.id) }, [selectedProvider?.id, loadModels])
|
||||
useEffect(() => { if (!isEditing) setVerifyResult(null) }, [providerName, model, isEditing])
|
||||
|
||||
const providerOptions = useMemo(() => providers.filter((p) => p.enabled).map((p) => ({ value: p.name, label: p.display_name || p.name })), [providers])
|
||||
const modelOptions = useMemo(() => models.map((m) => ({ value: m, label: m })), [models])
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!selectedProvider?.id || !model.trim()) return
|
||||
setVerifying(true)
|
||||
try {
|
||||
const res = await getApiClient().post<{ valid: boolean; error?: string }>(`/v1/providers/${selectedProvider.id}/verify`, { model: model.trim() })
|
||||
setVerifyResult({ valid: res.valid, error: res.error })
|
||||
} catch (err) {
|
||||
setVerifyResult({ valid: false, error: err instanceof Error ? err.message : 'Verification failed' })
|
||||
} finally { setVerifying(false) }
|
||||
}
|
||||
|
||||
const onValid = async (data: AgentFormData) => {
|
||||
setSubmitError('')
|
||||
try {
|
||||
const otherConfig: Record<string, unknown> = {}
|
||||
if (data.description?.trim()) otherConfig.description = data.description.trim()
|
||||
if (data.emoji?.trim()) otherConfig.emoji = data.emoji.trim()
|
||||
await onSubmit({
|
||||
agent_key: data.agentKey,
|
||||
display_name: data.displayName.trim() || undefined,
|
||||
provider: data.providerName,
|
||||
model: data.model.trim(),
|
||||
agent_type: isEditing ? agent!.agent_type : 'predefined',
|
||||
is_default: data.isDefault || undefined,
|
||||
other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined,
|
||||
})
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof Error ? err.message : 'Failed to save agent')
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const canCreate = isEditing
|
||||
? !!providerName && !!model.trim()
|
||||
: !!displayName.trim() && !!providerName && !!model.trim() && verifyResult?.valid && !!watch('description')?.trim()
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-surface-secondary border border-border rounded-xl shadow-xl max-w-3xl w-full mx-4 overflow-hidden flex flex-col" style={{ maxHeight: '85vh' }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4 shrink-0">
|
||||
<h3 className="text-sm font-semibold text-text-primary">
|
||||
{isEditing ? t('agents:detail.tabs.agent') + ' — ' + t('common:edit') : t('agents:create.title')}
|
||||
</h3>
|
||||
<button onClick={() => onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body — 2 columns */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-5">
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
{/* Left column — identity & model */}
|
||||
<AgentIdentityFields
|
||||
isEditing={isEditing}
|
||||
register={register}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
errors={errors}
|
||||
providerOptions={providerOptions}
|
||||
modelOptions={modelOptions}
|
||||
modelsLoading={modelsLoading}
|
||||
verifyResult={verifyResult}
|
||||
onAgentKeyChange={(val) => { setSelectedPresetKey(''); setValue('agentKey', val, { shouldValidate: true }) }}
|
||||
/>
|
||||
|
||||
{/* Right column — personality */}
|
||||
<div className="space-y-1.5 flex flex-col">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('agents:detail.personality')}</label>
|
||||
{!isEditing && (
|
||||
<AgentPresetSelector
|
||||
currentDescription={watch('description') ?? ''}
|
||||
onSelect={({ description, emoji, displayName, agentKey }) => {
|
||||
setValue('description', description)
|
||||
setValue('emoji', emoji)
|
||||
setValue('displayName', displayName)
|
||||
setSelectedPresetKey(agentKey)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<textarea {...register('description')} placeholder={t('agents:create.descriptionPlaceholder')} className="flex-1 min-h-[200px] w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent resize-y" />
|
||||
{errors.description && <p className="text-xs text-error">{errors.description.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submitError && <div className="px-5"><p className="text-xs text-error">{submitError}</p></div>}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-border px-5 py-4 shrink-0">
|
||||
<div>{verifyResult && !verifyResult.valid && <span className="text-[11px] text-error">{verifyResult.error}</span>}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => onOpenChange(false)} className="px-3 py-1.5 text-xs border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors">{t('agents:create.cancel')}</button>
|
||||
{!isEditing && selectedProvider?.id && model.trim() && !verifyResult?.valid && (
|
||||
<button onClick={handleVerify} disabled={verifying} className="border border-border rounded-lg px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-tertiary transition-colors disabled:opacity-50 flex items-center gap-1.5">
|
||||
{verifying ? (<><svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>{t('desktop:agent.verifying')}</>) : t('desktop:agent.verifyModel')}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleSubmit(onValid)} disabled={!canCreate || isSubmitting} className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 flex items-center gap-1.5">
|
||||
{isSubmitting ? '...' : isEditing ? t('common:save') : (<>{verifyResult?.valid && (<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>)}{t('desktop:agent.summon')}</>)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+5
-5
@@ -1,13 +1,13 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAgentCrud } from '../../../hooks/use-agent-crud'
|
||||
import { useAgentStore } from '../../../stores/agent-store'
|
||||
import { useAgentCrud } from '../../hooks/use-agent-crud'
|
||||
import { useAgentStore } from '../../stores/agent-store'
|
||||
import { AgentCard } from './AgentCard'
|
||||
import { AgentFormDialog } from './AgentFormDialog'
|
||||
import { AgentDetailPanel } from './AgentDetailPanel'
|
||||
import { ConfirmDeleteDialog } from '../../common/ConfirmDeleteDialog'
|
||||
import { SummoningModal } from '../../onboarding/SummoningModal'
|
||||
import type { AgentData, AgentInput } from '../../../types/agent'
|
||||
import { ConfirmDeleteDialog } from '../common/ConfirmDeleteDialog'
|
||||
import { SummoningModal } from '../onboarding/SummoningModal'
|
||||
import type { AgentData, AgentInput } from '../../types/agent'
|
||||
|
||||
export function AgentList() {
|
||||
const { t } = useTranslation(['agents', 'common'])
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getApiClient, isApiClientReady } from '../../../lib/api'
|
||||
import { Switch } from '../../common/Switch'
|
||||
import type { MCPServerData, MCPAgentGrant } from '../../../types/mcp'
|
||||
import { getApiClient, isApiClientReady } from '../../lib/api'
|
||||
import { Switch } from '../common/Switch'
|
||||
import type { MCPServerData, MCPAgentGrant } from '../../types/mcp'
|
||||
|
||||
interface AgentMcpSectionProps {
|
||||
agentId: string
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getApiClient, isApiClientReady } from '../../../lib/api'
|
||||
import { Switch } from '../../common/Switch'
|
||||
import { getApiClient, isApiClientReady } from '../../lib/api'
|
||||
import { Switch } from '../common/Switch'
|
||||
|
||||
interface SkillWithGrant {
|
||||
id: string
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Switch } from '../../common/Switch'
|
||||
import { Switch } from '../common/Switch'
|
||||
|
||||
interface EvolutionSectionProps {
|
||||
selfEvolve: boolean
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { MemoryConfig } from '../../../types/agent'
|
||||
import type { MemoryConfig } from '../../types/agent'
|
||||
|
||||
interface MemorySectionProps {
|
||||
config: MemoryConfig | null | undefined
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Combobox } from '../../common/Combobox'
|
||||
import { useProviders } from '../../../hooks/use-providers'
|
||||
import { getApiClient } from '../../../lib/api'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
import { useProviders } from '../../hooks/use-providers'
|
||||
import { getApiClient } from '../../lib/api'
|
||||
|
||||
interface ModelBudgetSectionProps {
|
||||
provider: string
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Switch } from '../../common/Switch'
|
||||
import { Combobox } from '../../common/Combobox'
|
||||
import { Switch } from '../common/Switch'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
|
||||
interface PersonalitySectionProps {
|
||||
emoji: string
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
import { Switch } from '../common/Switch'
|
||||
import type { UseFormRegister, UseFormWatch, UseFormSetValue, FieldErrors } from 'react-hook-form'
|
||||
import type { AgentFormData } from '../../schemas/agent.schema'
|
||||
|
||||
interface AgentIdentityFieldsProps {
|
||||
isEditing: boolean
|
||||
register: UseFormRegister<AgentFormData>
|
||||
watch: UseFormWatch<AgentFormData>
|
||||
setValue: UseFormSetValue<AgentFormData>
|
||||
errors: FieldErrors<AgentFormData>
|
||||
providerOptions: { value: string; label: string }[]
|
||||
modelOptions: { value: string; label: string }[]
|
||||
modelsLoading: boolean
|
||||
verifyResult: { valid: boolean; error?: string } | null
|
||||
onAgentKeyChange: (val: string) => void
|
||||
}
|
||||
|
||||
export function AgentIdentityFields({
|
||||
isEditing, register, watch, setValue, errors,
|
||||
providerOptions, modelOptions, modelsLoading, verifyResult,
|
||||
onAgentKeyChange,
|
||||
}: AgentIdentityFieldsProps) {
|
||||
const { t } = useTranslation(['agents', 'desktop', 'common'])
|
||||
const agentKey = watch('agentKey')
|
||||
const providerName = watch('providerName')
|
||||
const model = watch('model')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Display name + emoji */}
|
||||
<div className="flex gap-2">
|
||||
<div className="space-y-1 flex-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('agents:create.displayName').replace(' *', '')}</label>
|
||||
<input {...register('displayName')} placeholder={t('agents:create.displayNamePlaceholder')} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
|
||||
</div>
|
||||
<div className="space-y-1 w-16">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('agents:identity.emoji')}</label>
|
||||
<input value={watch('emoji') ?? ''} onChange={(e) => setValue('emoji', e.target.value.slice(0, 2))} maxLength={2} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary text-center focus:outline-none focus:ring-1 focus:ring-accent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent key (create only) */}
|
||||
{!isEditing && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">Agent Key</label>
|
||||
<input value={agentKey} onChange={(e) => onAgentKeyChange(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'))} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary font-mono focus:outline-none focus:ring-1 focus:ring-accent" />
|
||||
{errors.agentKey && <p className="text-xs text-error">{errors.agentKey.message}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('common:provider')}</label>
|
||||
<Combobox value={providerName} onChange={(v) => setValue('providerName', v, { shouldValidate: true })} options={providerOptions} placeholder={t('agents:create.selectProvider')} />
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('common:model')}</label>
|
||||
<Combobox value={model} onChange={(v) => setValue('model', v, { shouldValidate: true })} options={modelOptions} placeholder={modelsLoading ? t('agents:create.loadingModels') : t('agents:create.enterOrSelectModel')} allowCustom />
|
||||
{verifyResult && !verifyResult.valid && <p className="text-xs text-error">{verifyResult.error || t('desktop:agent.verifyFailed')}</p>}
|
||||
{verifyResult?.valid && !isEditing && <p className="text-xs text-success">{t('desktop:agent.verified')}</p>}
|
||||
</div>
|
||||
|
||||
{/* Default toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={watch('isDefault')} onCheckedChange={(v) => setValue('isDefault', v)} />
|
||||
<span className="text-xs text-text-secondary">{t('agents:identity.defaultAgent')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const PRESET_KEYS = [
|
||||
{ key: 'foxSpirit', emoji: '🦊', ns: 'agents', agentKey: 'little-fox' },
|
||||
{ key: 'artisan', emoji: '🎨', ns: 'agents', agentKey: 'artisan' },
|
||||
{ key: 'astrologer', emoji: '🔮', ns: 'agents', agentKey: 'mimi' },
|
||||
{ key: 'researcher', emoji: '🔬', ns: 'desktop', agentKey: 'scholar' },
|
||||
{ key: 'writer', emoji: '✍️', ns: 'desktop', agentKey: 'quill' },
|
||||
{ key: 'coder', emoji: '👨💻', ns: 'desktop', agentKey: 'dev' },
|
||||
]
|
||||
|
||||
interface AgentPresetSelectorProps {
|
||||
currentDescription: string
|
||||
onSelect: (preset: { description: string; emoji: string; displayName: string; agentKey: string }) => void
|
||||
}
|
||||
|
||||
export function AgentPresetSelector({ currentDescription, onSelect }: AgentPresetSelectorProps) {
|
||||
const { t } = useTranslation(['agents', 'desktop'])
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PRESET_KEYS.map((p) => {
|
||||
const label = t(`${p.ns}:presets.${p.key}.label`)
|
||||
const prompt = t(`${p.ns}:presets.${p.key}.prompt`)
|
||||
const nameMatch = prompt.match(/^Name:\s*([^.]+)/)
|
||||
const presetDisplayName = nameMatch ? nameMatch[1].trim() : label.replace(/^\S+\s*/, '')
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
onClick={() => onSelect({ description: prompt, emoji: p.emoji, displayName: presetDisplayName, agentKey: p.agentKey })}
|
||||
className={`rounded-full border px-2.5 py-1 text-[11px] transition-colors ${
|
||||
currentDescription === prompt
|
||||
? 'border-accent bg-accent/10 text-accent font-medium'
|
||||
: 'border-border text-text-secondary hover:bg-surface-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Dialog for "Edit with AI" — sends prompt to regenerate agent context files endpoint.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface RegenerateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onRegenerate: (prompt: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function RegenerateDialog({ open, onOpenChange, onRegenerate }: RegenerateDialogProps) {
|
||||
const { t } = useTranslation('agents')
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!prompt.trim()) return
|
||||
setLoading(true)
|
||||
try {
|
||||
await onRegenerate(prompt.trim())
|
||||
onOpenChange(false)
|
||||
setPrompt('')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-surface-secondary border border-border rounded-xl shadow-xl max-w-lg w-full mx-4 p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
||||
</svg>
|
||||
Edit with AI
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
Describe how you want to modify the agent's personality, knowledge, or behavior. The AI will regenerate the context files accordingly.
|
||||
</p>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="e.g. Make the agent more formal and add expertise in data analysis..."
|
||||
rows={4}
|
||||
autoFocus
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent resize-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-xs border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!prompt.trim() || loading}
|
||||
className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
||||
</svg>
|
||||
{loading ? t('fileEditor.sending') : t('fileEditor.regenerate')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChannelFields } from './channel-field-renderer'
|
||||
import { configSchema, NETWORK_KEYS, LIMITS_KEYS, STREAMING_KEYS, BEHAVIOR_KEYS, ACCESS_KEYS } from './channel-schemas'
|
||||
import type { ChannelInstanceData } from '../../../types/channel'
|
||||
import type { ChannelInstanceData } from '../../types/channel'
|
||||
|
||||
interface ChannelAdvancedTabProps {
|
||||
instance: ChannelInstanceData
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch } from "../../common/Switch";
|
||||
import { Switch } from "../common/Switch";
|
||||
import type {
|
||||
ChannelInstanceData,
|
||||
ChannelStatus,
|
||||
} from "../../../types/channel";
|
||||
import { getChannelStatusDisplay } from "../../../utils/channel-status";
|
||||
} from "../../types/channel";
|
||||
import { getChannelStatusDisplay } from "../../utils/channel-status";
|
||||
|
||||
interface ChannelCardProps {
|
||||
instance: ChannelInstanceData;
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChannelFields } from './channel-field-renderer'
|
||||
import { credentialsSchema } from './channel-schemas'
|
||||
import type { ChannelInstanceData } from '../../../types/channel'
|
||||
import type { ChannelInstanceData } from '../../types/channel'
|
||||
|
||||
interface ChannelCredentialsTabProps {
|
||||
instance: ChannelInstanceData
|
||||
+4
-4
@@ -1,13 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChannelDetail } from "../../../hooks/use-channel-detail";
|
||||
import { useAgentCrud } from "../../../hooks/use-agent-crud";
|
||||
import { useChannelDetail } from "../../hooks/use-channel-detail";
|
||||
import { useAgentCrud } from "../../hooks/use-agent-crud";
|
||||
import { ChannelGeneralTab } from "./ChannelGeneralTab";
|
||||
import { ChannelCredentialsTab } from "./ChannelCredentialsTab";
|
||||
import { ChannelManagersTab } from "./ChannelManagersTab";
|
||||
import { ChannelAdvancedTab } from "./ChannelAdvancedTab";
|
||||
import type { ChannelStatus } from "../../../types/channel";
|
||||
import { getChannelStatusDisplay } from "../../../utils/channel-status";
|
||||
import type { ChannelStatus } from "../../types/channel";
|
||||
import { getChannelStatusDisplay } from "../../utils/channel-status";
|
||||
|
||||
interface ChannelDetailPanelProps {
|
||||
instanceId: string;
|
||||
+46
-52
@@ -1,11 +1,14 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Combobox } from '../../common/Combobox'
|
||||
import { Switch } from '../../common/Switch'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
import { Switch } from '../common/Switch'
|
||||
import { ChannelFields } from './channel-field-renderer'
|
||||
import { credentialsSchema } from './channel-schemas'
|
||||
import type { ChannelInstanceInput } from '../../../types/channel'
|
||||
import type { AgentData } from '../../../types/agent'
|
||||
import { channelFormSchema, type ChannelFormData } from '../../schemas/channel.schema'
|
||||
import type { ChannelInstanceInput } from '../../types/channel'
|
||||
import type { AgentData } from '../../types/agent'
|
||||
|
||||
interface ChannelFormDialogProps {
|
||||
open: boolean
|
||||
@@ -19,30 +22,28 @@ interface ChannelFormDialogProps {
|
||||
export function ChannelFormDialog({ open, onOpenChange, agents, telegramExists, discordExists, onSubmit }: ChannelFormDialogProps) {
|
||||
const { t } = useTranslation('channels')
|
||||
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [channelType, setChannelType] = useState('')
|
||||
const [agentId, setAgentId] = useState('')
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [credentials, setCredentials] = useState<Record<string, string>>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const { handleSubmit, watch, setValue, reset, formState: { errors, isSubmitting } } = useForm<ChannelFormData>({
|
||||
resolver: zodResolver(channelFormSchema),
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const channelType = watch('channelType')
|
||||
const credentials = watch('credentials')
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setDisplayName('')
|
||||
setChannelType('')
|
||||
setAgentId('')
|
||||
setEnabled(true)
|
||||
setCredentials({})
|
||||
setError('')
|
||||
|
||||
// Auto-select the only available type
|
||||
const available = []
|
||||
if (!telegramExists) available.push('telegram')
|
||||
if (!discordExists) available.push('discord')
|
||||
if (available.length === 1) setChannelType(available[0])
|
||||
}, [open, telegramExists, discordExists])
|
||||
reset({
|
||||
displayName: '',
|
||||
channelType: available.length === 1 ? available[0] : '',
|
||||
agentId: '',
|
||||
enabled: true,
|
||||
credentials: {},
|
||||
})
|
||||
}, [open, telegramExists, discordExists, reset])
|
||||
|
||||
const typeOptions = useMemo(() => {
|
||||
const opts = []
|
||||
@@ -59,31 +60,23 @@ export function ChannelFormDialog({ open, onOpenChange, agents, telegramExists,
|
||||
const credFields = channelType ? (credentialsSchema[channelType] ?? []) : []
|
||||
|
||||
const handleCredChange = (key: string, value: unknown) => {
|
||||
setCredentials((prev) => ({ ...prev, [key]: String(value ?? '') }))
|
||||
setValue('credentials', { ...credentials, [key]: String(value ?? '') })
|
||||
}
|
||||
|
||||
const canCreate = !!channelType && !!agentId
|
||||
&& credFields.filter((f) => f.required).every((f) => credentials[f.key]?.trim())
|
||||
const canCreate = !!channelType && !!watch('agentId')
|
||||
&& credFields.filter((f) => f.required).every((f) => credentials?.[f.key]?.trim())
|
||||
|
||||
const handleCreate = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await onSubmit({
|
||||
name: channelType, // auto-slug: "telegram" or "discord"
|
||||
displayName: displayName.trim(),
|
||||
channelType,
|
||||
agentId,
|
||||
credentials,
|
||||
config: {},
|
||||
enabled,
|
||||
})
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create channel')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
const onValid = async (data: ChannelFormData) => {
|
||||
await onSubmit({
|
||||
name: data.channelType,
|
||||
displayName: data.displayName,
|
||||
channelType: data.channelType,
|
||||
agentId: data.agentId,
|
||||
credentials: data.credentials,
|
||||
config: {},
|
||||
enabled: data.enabled,
|
||||
})
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
@@ -106,7 +99,7 @@ export function ChannelFormDialog({ open, onOpenChange, agents, telegramExists,
|
||||
{/* Display Name */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.displayName')}</label>
|
||||
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder={t('form.displayNamePlaceholder')} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
|
||||
<input value={watch('displayName')} onChange={(e) => setValue('displayName', e.target.value)} placeholder={t('form.displayNamePlaceholder')} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
|
||||
</div>
|
||||
|
||||
{/* Channel Type */}
|
||||
@@ -119,23 +112,25 @@ export function ChannelFormDialog({ open, onOpenChange, agents, telegramExists,
|
||||
) : (
|
||||
<Combobox
|
||||
value={channelType}
|
||||
onChange={(v) => { setChannelType(v); setCredentials({}) }}
|
||||
onChange={(v) => { setValue('channelType', v, { shouldValidate: true }); setValue('credentials', {}) }}
|
||||
options={typeOptions}
|
||||
placeholder={t('form.selectType')}
|
||||
allowCustom={false}
|
||||
/>
|
||||
)}
|
||||
{errors.channelType && <p className="text-xs text-error">{errors.channelType.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Agent */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.agent')} *</label>
|
||||
<Combobox value={agentId} onChange={setAgentId} options={agentOptions} placeholder={t('form.selectAgent')} />
|
||||
<Combobox value={watch('agentId')} onChange={(v) => setValue('agentId', v, { shouldValidate: true })} options={agentOptions} placeholder={t('form.selectAgent')} />
|
||||
{errors.agentId && <p className="text-xs text-error">{errors.agentId.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Enabled */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
<Switch checked={watch('enabled')} onCheckedChange={(v) => setValue('enabled', v)} />
|
||||
<span className="text-xs text-text-secondary">{t('form.enabled')}</span>
|
||||
</div>
|
||||
|
||||
@@ -143,19 +138,18 @@ export function ChannelFormDialog({ open, onOpenChange, agents, telegramExists,
|
||||
{credFields.length > 0 && (
|
||||
<div className="space-y-2 border-t border-border pt-4">
|
||||
<h4 className="text-xs font-semibold text-text-secondary">{t('form.credentials')}</h4>
|
||||
<ChannelFields fields={credFields} values={credentials} onChange={handleCredChange} idPrefix="cf-cred" />
|
||||
<ChannelFields fields={credFields} values={credentials ?? {}} onChange={handleCredChange} idPrefix="cf-cred" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{error && <div className="px-5"><p className="text-xs text-error">{error}</p></div>}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-4 shrink-0">
|
||||
<button onClick={() => onOpenChange(false)} className="px-3 py-1.5 text-xs border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors cursor-pointer">
|
||||
{t('form.cancel')}
|
||||
</button>
|
||||
<button onClick={handleCreate} disabled={!canCreate || loading} className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 cursor-pointer">
|
||||
{loading ? t('form.saving') : t('form.create')}
|
||||
<button onClick={handleSubmit(onValid)} disabled={!canCreate || isSubmitting} className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 cursor-pointer">
|
||||
{isSubmitting ? t('form.saving') : t('form.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Switch } from '../../common/Switch'
|
||||
import { Combobox } from '../../common/Combobox'
|
||||
import { Switch } from '../common/Switch'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
import { ChannelFields } from './channel-field-renderer'
|
||||
import { configSchema, ESSENTIAL_CONFIG_KEYS } from './channel-schemas'
|
||||
import type { ChannelInstanceData } from '../../../types/channel'
|
||||
import type { AgentData } from '../../../types/agent'
|
||||
import type { ChannelInstanceData } from '../../types/channel'
|
||||
import type { AgentData } from '../../types/agent'
|
||||
|
||||
interface ChannelGeneralTabProps {
|
||||
instance: ChannelInstanceData
|
||||
+6
-6
@@ -1,15 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useChannelCrud } from '../../../hooks/use-channel-crud'
|
||||
import { useChannelStatus } from '../../../hooks/use-channel-status'
|
||||
import { useAgentCrud } from '../../../hooks/use-agent-crud'
|
||||
import { useChannelCrud } from '../../hooks/use-channel-crud'
|
||||
import { useChannelStatus } from '../../hooks/use-channel-status'
|
||||
import { useAgentCrud } from '../../hooks/use-agent-crud'
|
||||
import { ChannelCard } from './ChannelCard'
|
||||
import { ChannelFormDialog } from './ChannelFormDialog'
|
||||
import { ChannelDetailPanel } from './ChannelDetailPanel'
|
||||
import { PairedDevicesSection } from './PairedDevicesSection'
|
||||
import { ConfirmDialog } from '../../common/ConfirmDialog'
|
||||
import { RefreshButton } from '../../common/RefreshButton'
|
||||
import type { ChannelInstanceData } from '../../../types/channel'
|
||||
import { ConfirmDialog } from '../common/ConfirmDialog'
|
||||
import { RefreshButton } from '../common/RefreshButton'
|
||||
import type { ChannelInstanceData } from '../../types/channel'
|
||||
|
||||
export function ChannelList() {
|
||||
const { t } = useTranslation('channels')
|
||||
+21
-86
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Combobox } from '../../common/Combobox'
|
||||
import type { GroupManagerGroupInfo, GroupManagerData, ChannelContact } from '../../../types/channel'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
import { ManagerGroupCard } from './manager-group-card'
|
||||
import type { GroupManagerGroupInfo, GroupManagerData, ChannelContact } from '../../types/channel'
|
||||
|
||||
interface ChannelManagersTabProps {
|
||||
listManagerGroups: () => Promise<GroupManagerGroupInfo[]>
|
||||
@@ -11,10 +12,6 @@ interface ChannelManagersTabProps {
|
||||
listContacts: (search: string) => Promise<ChannelContact[]>
|
||||
}
|
||||
|
||||
function shortGroupId(id: string): string {
|
||||
return id.match(/^group:[^:]+:(.+)$/)?.[1] ?? id
|
||||
}
|
||||
|
||||
export function ChannelManagersTab({
|
||||
listManagerGroups, listManagers, addManager, removeManager, listContacts,
|
||||
}: ChannelManagersTabProps) {
|
||||
@@ -31,11 +28,7 @@ export function ChannelManagersTab({
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
try {
|
||||
setGroups(await listManagerGroups())
|
||||
} catch {
|
||||
setGroups([])
|
||||
}
|
||||
try { setGroups(await listManagerGroups()) } catch { setGroups([]) }
|
||||
}, [listManagerGroups])
|
||||
|
||||
useEffect(() => { loadGroups() }, [loadGroups])
|
||||
@@ -63,9 +56,7 @@ export function ChannelManagersTab({
|
||||
value: c.sender_id,
|
||||
label: c.display_name ? `${c.display_name} (${c.sender_id})` : c.sender_id,
|
||||
})))
|
||||
} catch {
|
||||
setContactOptions([])
|
||||
}
|
||||
} catch { setContactOptions([]) }
|
||||
}, [listContacts])
|
||||
|
||||
const handleInlineAdd = async (groupId: string) => {
|
||||
@@ -110,7 +101,6 @@ export function ChannelManagersTab({
|
||||
setNewGroupId('')
|
||||
setNewUserId('')
|
||||
await loadGroups()
|
||||
// Auto-expand new group
|
||||
setExpanded((prev) => ({ ...prev, [gid]: true }))
|
||||
const data = await listManagers(gid)
|
||||
setManagersMap((prev) => ({ ...prev, [gid]: data }))
|
||||
@@ -136,77 +126,22 @@ export function ChannelManagersTab({
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{groups.map((g) => (
|
||||
<div key={g.group_id} className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => handleToggle(g.group_id)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-surface-tertiary/30 transition-colors text-left cursor-pointer"
|
||||
>
|
||||
<span className="text-xs font-medium text-text-primary font-mono">{shortGroupId(g.group_id)}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="rounded-full bg-surface-tertiary px-2 py-0.5 text-[11px] text-text-muted tabular-nums">
|
||||
{g.writer_count === 1
|
||||
? t('detail.managers.managersCount', { count: g.writer_count })
|
||||
: t('detail.managers.managersCountPlural', { count: g.writer_count })}
|
||||
</span>
|
||||
<svg className={`w-3.5 h-3.5 text-text-muted transition-transform ${expanded[g.group_id] ? 'rotate-90' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}><path d="M9 18l6-6-6-6" /></svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded[g.group_id] && (
|
||||
<div className="border-t border-border bg-surface-tertiary/10 px-4 pb-3 pt-2 space-y-3">
|
||||
{loadingMap[g.group_id] ? (
|
||||
<p className="text-xs text-text-muted py-2">{t('detail.managers.loadingManagers')}</p>
|
||||
) : (managersMap[g.group_id] ?? []).length === 0 ? (
|
||||
<p className="text-xs text-text-muted py-2">{t('detail.managers.noManagers')}</p>
|
||||
) : (
|
||||
<div className="rounded-md border border-border bg-surface-secondary overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-tertiary/40">
|
||||
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.userId')}</th>
|
||||
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.name')}</th>
|
||||
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.username')}</th>
|
||||
<th className="px-3 py-1.5 w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(managersMap[g.group_id] ?? []).map((m) => (
|
||||
<tr key={m.user_id} className="border-b border-border last:border-0 hover:bg-surface-tertiary/20">
|
||||
<td className="px-3 py-1.5 font-mono">{m.user_id}</td>
|
||||
<td className="px-3 py-1.5 text-text-muted">{m.display_name || '—'}</td>
|
||||
<td className="px-3 py-1.5 text-text-muted">{m.username ? `@${m.username}` : '—'}</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
<button onClick={() => handleRemove(g.group_id, m.user_id)} className="text-[11px] text-text-muted hover:text-error transition-colors cursor-pointer">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{/* Inline add */}
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Combobox
|
||||
value={inlineUserId[g.group_id] ?? ''}
|
||||
onChange={(v) => { setInlineUserId((prev) => ({ ...prev, [g.group_id]: v })); handleContactSearch(v) }}
|
||||
options={contactOptions}
|
||||
placeholder={t('detail.managers.addForm.userIdPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleInlineAdd(g.group_id)}
|
||||
disabled={addingMap[g.group_id] || !inlineUserId[g.group_id]?.trim()}
|
||||
className="px-3 py-1.5 bg-accent text-white text-xs rounded-lg disabled:opacity-50 cursor-pointer hover:bg-accent-hover transition-colors shrink-0"
|
||||
>
|
||||
{t('detail.managers.addForm.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ManagerGroupCard
|
||||
key={g.group_id}
|
||||
groupId={g.group_id}
|
||||
writerCount={g.writer_count}
|
||||
expanded={!!expanded[g.group_id]}
|
||||
loading={!!loadingMap[g.group_id]}
|
||||
managers={managersMap[g.group_id] ?? []}
|
||||
adding={!!addingMap[g.group_id]}
|
||||
inlineUserId={inlineUserId[g.group_id] ?? ''}
|
||||
contactOptions={contactOptions}
|
||||
onToggle={() => handleToggle(g.group_id)}
|
||||
onInlineUserIdChange={(v) => setInlineUserId((prev) => ({ ...prev, [g.group_id]: v }))}
|
||||
onContactSearch={handleContactSearch}
|
||||
onInlineAdd={() => handleInlineAdd(g.group_id)}
|
||||
onRemove={(userId) => handleRemove(g.group_id, userId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePairedDevices } from '../../../hooks/use-paired-devices'
|
||||
import { ConfirmDialog } from '../../common/ConfirmDialog'
|
||||
import { RefreshButton } from '../../common/RefreshButton'
|
||||
import type { PendingPairing, PairedDevice } from '../../../types/channel'
|
||||
import { usePairedDevices } from '../../hooks/use-paired-devices'
|
||||
import { ConfirmDialog } from '../common/ConfirmDialog'
|
||||
import { RefreshButton } from '../common/RefreshButton'
|
||||
import type { PendingPairing, PairedDevice } from '../../types/channel'
|
||||
|
||||
function formatRelativeTime(ms: number): string {
|
||||
const diff = Date.now() - ms
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Switch } from '../../common/Switch'
|
||||
import { Combobox } from '../../common/Combobox'
|
||||
import { Switch } from '../common/Switch'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
import type { FieldDef } from './channel-schemas'
|
||||
|
||||
interface ChannelFieldsProps {
|
||||
File renamed without changes.
@@ -0,0 +1,117 @@
|
||||
// Expandable group card showing managers list and inline add form for a single channel group.
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
import type { GroupManagerData } from '../../types/channel'
|
||||
|
||||
function shortGroupId(id: string): string {
|
||||
return id.match(/^group:[^:]+:(.+)$/)?.[1] ?? id
|
||||
}
|
||||
|
||||
interface ManagerGroupCardProps {
|
||||
groupId: string
|
||||
writerCount: number
|
||||
expanded: boolean
|
||||
loading: boolean
|
||||
managers: GroupManagerData[]
|
||||
adding: boolean
|
||||
inlineUserId: string
|
||||
contactOptions: { value: string; label: string }[]
|
||||
onToggle: () => void
|
||||
onInlineUserIdChange: (v: string) => void
|
||||
onContactSearch: (v: string) => void
|
||||
onInlineAdd: () => void
|
||||
onRemove: (userId: string) => void
|
||||
}
|
||||
|
||||
export function ManagerGroupCard({
|
||||
groupId, writerCount, expanded, loading, managers, adding, inlineUserId,
|
||||
contactOptions, onToggle, onInlineUserIdChange, onContactSearch, onInlineAdd, onRemove,
|
||||
}: ManagerGroupCardProps) {
|
||||
const { t } = useTranslation('channels')
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-surface-tertiary/30 transition-colors text-left cursor-pointer"
|
||||
>
|
||||
<span className="text-xs font-medium text-text-primary font-mono">{shortGroupId(groupId)}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="rounded-full bg-surface-tertiary px-2 py-0.5 text-[11px] text-text-muted tabular-nums">
|
||||
{writerCount === 1
|
||||
? t('detail.managers.managersCount', { count: writerCount })
|
||||
: t('detail.managers.managersCountPlural', { count: writerCount })}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 text-text-muted transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-border bg-surface-tertiary/10 px-4 pb-3 pt-2 space-y-3">
|
||||
{loading ? (
|
||||
<p className="text-xs text-text-muted py-2">{t('detail.managers.loadingManagers')}</p>
|
||||
) : managers.length === 0 ? (
|
||||
<p className="text-xs text-text-muted py-2">{t('detail.managers.noManagers')}</p>
|
||||
) : (
|
||||
<div className="rounded-md border border-border bg-surface-secondary overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-tertiary/40">
|
||||
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.userId')}</th>
|
||||
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.name')}</th>
|
||||
<th className="px-3 py-1.5 text-left text-[11px] font-medium text-text-muted">{t('detail.managers.columns.username')}</th>
|
||||
<th className="px-3 py-1.5 w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{managers.map((m) => (
|
||||
<tr key={m.user_id} className="border-b border-border last:border-0 hover:bg-surface-tertiary/20">
|
||||
<td className="px-3 py-1.5 font-mono">{m.user_id}</td>
|
||||
<td className="px-3 py-1.5 text-text-muted">{m.display_name || '—'}</td>
|
||||
<td className="px-3 py-1.5 text-text-muted">{m.username ? `@${m.username}` : '—'}</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
<button
|
||||
onClick={() => onRemove(m.user_id)}
|
||||
className="text-[11px] text-text-muted hover:text-error transition-colors cursor-pointer"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline add */}
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Combobox
|
||||
value={inlineUserId}
|
||||
onChange={(v) => { onInlineUserIdChange(v); onContactSearch(v) }}
|
||||
options={contactOptions}
|
||||
placeholder={t('detail.managers.addForm.userIdPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onInlineAdd}
|
||||
disabled={adding || !inlineUserId.trim()}
|
||||
className="px-3 py-1.5 bg-accent text-white text-xs rounded-lg disabled:opacity-50 cursor-pointer hover:bg-accent-hover transition-colors shrink-0"
|
||||
>
|
||||
{t('detail.managers.addForm.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import { AuthImage, downloadFile } from './AuthImage'
|
||||
import { downloadFile } from './AuthImage'
|
||||
import { getApiClient, isApiClientReady } from '../../lib/api'
|
||||
import { FilePreviewRenderer, isMarkdown, isCode, isText } from './file-preview-renderer'
|
||||
|
||||
// Strip ?ft= token and timestamps from filename for display
|
||||
function cleanFilename(name: string): string {
|
||||
// Remove query params
|
||||
const base = name.split('?')[0]
|
||||
// Remove timestamp suffix: "file.1774537056.md" → "file.md"
|
||||
return base.replace(/\.\d{9,}(\.\w+)$/, '$1')
|
||||
}
|
||||
|
||||
@@ -21,52 +17,11 @@ interface FilePreviewDialogProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function getLanguage(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
const map: Record<string, string> = {
|
||||
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
|
||||
py: 'python', go: 'go', rs: 'rust', java: 'java',
|
||||
c: 'c', cpp: 'cpp', h: 'c', rb: 'ruby', sh: 'bash',
|
||||
yaml: 'yaml', yml: 'yaml', toml: 'toml', json: 'json',
|
||||
xml: 'xml', html: 'html', css: 'css',
|
||||
}
|
||||
return map[ext] ?? 'text'
|
||||
}
|
||||
|
||||
function isImage(filename: string, mimeType?: string): boolean {
|
||||
if (mimeType?.startsWith('image/')) return true
|
||||
return /\.(jpe?g|png|gif|webp|svg|bmp|ico)$/i.test(filename)
|
||||
}
|
||||
|
||||
function isVideo(filename: string, mimeType?: string): boolean {
|
||||
if (mimeType?.startsWith('video/')) return true
|
||||
return /\.(mp4|webm|ogg|mov)$/i.test(filename)
|
||||
}
|
||||
|
||||
function isAudio(filename: string, mimeType?: string): boolean {
|
||||
if (mimeType?.startsWith('audio/')) return true
|
||||
return /\.(mp3|wav|ogg|flac|aac|m4a)$/i.test(filename)
|
||||
}
|
||||
|
||||
function isMarkdown(filename: string): boolean {
|
||||
return /\.(md|markdown)$/i.test(filename)
|
||||
}
|
||||
|
||||
function isCode(filename: string): boolean {
|
||||
return /\.(ts|tsx|js|jsx|py|go|rs|java|c|cpp|h|rb|sh|yaml|yml|toml|json|xml|html|css)$/i.test(filename)
|
||||
}
|
||||
|
||||
function isText(filename: string, mimeType?: string): boolean {
|
||||
if (mimeType?.startsWith('text/')) return true
|
||||
return /\.(txt|log|csv|conf|ini|env)$/i.test(filename)
|
||||
}
|
||||
|
||||
export function FilePreviewDialog({ url, filename: rawFilename, mimeType, onClose }: FilePreviewDialogProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const [textContent, setTextContent] = useState<string | null>(null)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
|
||||
// Normalize: if filename is a full URL/path, extract the basename
|
||||
const filename = (rawFilename.includes('/') ? rawFilename.split('/').pop() : rawFilename)?.split('?')[0] ?? rawFilename
|
||||
const needsTextFetch = isMarkdown(filename) || isCode(filename) || isText(filename, mimeType)
|
||||
|
||||
@@ -75,9 +30,6 @@ export function FilePreviewDialog({ url, filename: rawFilename, mimeType, onClos
|
||||
let cancelled = false
|
||||
setTextContent(null)
|
||||
setLoadError(false)
|
||||
// For URLs without ?ft= token, sign them first to get a valid ft= token.
|
||||
// This avoids tenant header issues with direct Bearer auth fetch.
|
||||
// Fetch with Bearer auth via ApiClient.fetchFile (handles base URL + auth header).
|
||||
const doFetch = isApiClientReady()
|
||||
? getApiClient().fetchFile(url)
|
||||
: fetch(url)
|
||||
@@ -103,81 +55,6 @@ export function FilePreviewDialog({ url, filename: rawFilename, mimeType, onClos
|
||||
|
||||
const displayName = cleanFilename(filename)
|
||||
|
||||
function renderContent() {
|
||||
if (isImage(filename, mimeType)) {
|
||||
return (
|
||||
<AuthImage src={url} alt={displayName} className="max-w-full max-h-[70vh] object-contain mx-auto rounded-lg" />
|
||||
)
|
||||
}
|
||||
if (isVideo(filename, mimeType)) {
|
||||
return <video src={url} controls className="max-w-full rounded-lg" />
|
||||
}
|
||||
if (isAudio(filename, mimeType)) {
|
||||
return <audio src={url} controls className="w-full" />
|
||||
}
|
||||
if (needsTextFetch) {
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="p-6 flex flex-col items-center gap-3 text-text-secondary">
|
||||
<p className="text-sm">{t('errors.serverError', 'Failed to load file preview')}</p>
|
||||
<button
|
||||
onClick={() => downloadFile(url, displayName)}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-accent/10 px-4 py-2 text-sm text-accent hover:bg-accent/20 transition-colors cursor-pointer"
|
||||
>
|
||||
{t('download', 'Download')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (textContent === null) {
|
||||
return <p className="text-text-muted text-sm p-4">{t('loading')}</p>
|
||||
}
|
||||
if (isMarkdown(filename)) {
|
||||
// Resolve relative image/link paths in markdown against the file's directory URL
|
||||
const baseDir = url.substring(0, url.lastIndexOf('/') + 1)
|
||||
const resolved = textContent.replace(
|
||||
/(!?\[.*?\])\((?!https?:\/\/|\/|#)(.*?)\)/g,
|
||||
(_, prefix, relPath) => `${prefix}(${baseDir}${relPath})`,
|
||||
)
|
||||
return (
|
||||
<div className="p-4 overflow-y-auto max-h-[70vh]">
|
||||
<MarkdownRenderer content={resolved} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isCode(filename)) {
|
||||
return (
|
||||
<div className="overflow-y-auto max-h-[70vh]">
|
||||
<SyntaxHighlighter
|
||||
language={getLanguage(filename)}
|
||||
style={oneDark}
|
||||
customStyle={{ margin: 0, fontSize: '13px', borderRadius: 0 }}
|
||||
>
|
||||
{textContent}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// plain text / log / csv
|
||||
return (
|
||||
<pre className="p-4 text-xs font-mono text-text-primary overflow-auto max-h-[70vh] whitespace-pre-wrap break-words">
|
||||
{textContent}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="p-6 flex flex-col items-center gap-3 text-text-secondary">
|
||||
<p className="text-sm">{t('selectFileToView')}</p>
|
||||
<button
|
||||
onClick={() => downloadFile(url, displayName)}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-accent/10 px-4 py-2 text-sm text-accent hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
{t('download')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@@ -217,7 +94,15 @@ export function FilePreviewDialog({ url, filename: rawFilename, mimeType, onClos
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-hidden">
|
||||
{renderContent()}
|
||||
<FilePreviewRenderer
|
||||
url={url}
|
||||
filename={filename}
|
||||
displayName={displayName}
|
||||
mimeType={mimeType}
|
||||
textContent={textContent}
|
||||
loadError={loadError}
|
||||
needsTextFetch={needsTextFetch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { useState, useRef, useCallback, type KeyboardEvent, type DragEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFileAttachments, extractFilePaths } from './use-file-attachments'
|
||||
import { AttachmentPreviewStrip } from './attachment-preview-strip'
|
||||
import type { AttachedFile } from './use-file-attachments'
|
||||
|
||||
/** A file queued for upload or a direct filesystem path (desktop). */
|
||||
export interface AttachedFile {
|
||||
id: string
|
||||
/** Browser File object — present for drag/drop and file picker uploads. */
|
||||
file?: File
|
||||
/** Direct filesystem path — present for pasted paths (desktop only, skip HTTP upload). */
|
||||
localPath?: string
|
||||
name: string
|
||||
/** Image thumbnail data URL (only for browser File images). */
|
||||
preview?: string
|
||||
}
|
||||
export type { AttachedFile }
|
||||
|
||||
interface InputBarProps {
|
||||
onSend: (text: string, files?: AttachedFile[]) => void
|
||||
@@ -21,78 +14,26 @@ interface InputBarProps {
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
/** Human-readable file size. */
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/** Detect if text looks like one or more absolute file paths (one per line). */
|
||||
const FILE_PATH_RE = /^(\/[\w.\-/ ]+(?:\.\w+)?|[A-Z]:\\[\w.\-\\ ]+(?:\.\w+)?)$/
|
||||
|
||||
function extractFilePaths(text: string): string[] {
|
||||
return text.split('\n').map((l) => l.trim()).filter((l) => FILE_PATH_RE.test(l))
|
||||
}
|
||||
|
||||
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']
|
||||
|
||||
export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: InputBarProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const [text, setText] = useState('')
|
||||
const [files, setFiles] = useState<AttachedFile[]>([])
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const dragCounter = useRef(0)
|
||||
|
||||
const addFiles = useCallback((incoming: FileList | File[]) => {
|
||||
const arr = Array.from(incoming)
|
||||
const newFiles: AttachedFile[] = arr.map((f) => ({
|
||||
id: crypto.randomUUID().slice(0, 8),
|
||||
file: f,
|
||||
name: f.name,
|
||||
}))
|
||||
|
||||
// Generate image previews
|
||||
for (const af of newFiles) {
|
||||
if (af.file && IMAGE_TYPES.includes(af.file.type)) {
|
||||
const fileObj = af.file
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
setFiles((prev) => prev.map((f) => f.id === af.id ? { ...f, preview: reader.result as string } : f))
|
||||
}
|
||||
reader.readAsDataURL(fileObj)
|
||||
}
|
||||
}
|
||||
|
||||
setFiles((prev) => [...prev, ...newFiles])
|
||||
}, [])
|
||||
|
||||
/** Add files by filesystem path (desktop paste). */
|
||||
const addLocalPaths = useCallback((paths: string[]) => {
|
||||
const newFiles: AttachedFile[] = paths.map((p) => ({
|
||||
id: crypto.randomUUID().slice(0, 8),
|
||||
localPath: p,
|
||||
name: p.split('/').pop() || p.split('\\').pop() || p,
|
||||
}))
|
||||
setFiles((prev) => [...prev, ...newFiles])
|
||||
}, [])
|
||||
|
||||
const removeFile = useCallback((id: string) => {
|
||||
setFiles((prev) => prev.filter((f) => f.id !== id))
|
||||
}, [])
|
||||
const { files, addFiles, addLocalPaths, removeFile, clearFiles } = useFileAttachments()
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const hasContent = text.trim().length > 0 || files.length > 0
|
||||
if (!hasContent || disabled) return
|
||||
onSend(text.trim(), files.length > 0 ? files : undefined)
|
||||
setText('')
|
||||
setFiles([])
|
||||
clearFiles()
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
}, [text, files, disabled, onSend])
|
||||
}, [text, files, disabled, onSend, clearFiles])
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -108,9 +49,7 @@ export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: I
|
||||
el.style.height = Math.min(el.scrollHeight, 160) + 'px'
|
||||
}
|
||||
|
||||
// --- Paste: detect file paths ---
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
// If clipboard has files (e.g. screenshot paste), handle as file upload
|
||||
const items = e.clipboardData.items
|
||||
const pastedFiles: File[] = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
@@ -124,18 +63,14 @@ export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: I
|
||||
addFiles(pastedFiles)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if pasted text looks like file path(s)
|
||||
const pasted = e.clipboardData.getData('text/plain')
|
||||
const paths = extractFilePaths(pasted)
|
||||
if (paths.length > 0) {
|
||||
e.preventDefault()
|
||||
addLocalPaths(paths)
|
||||
}
|
||||
// Otherwise, let default paste behavior handle it (normal text)
|
||||
}, [addFiles, addLocalPaths])
|
||||
|
||||
// --- Drag & drop ---
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
dragCounter.current++
|
||||
@@ -146,27 +81,19 @@ export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: I
|
||||
dragCounter.current--
|
||||
if (dragCounter.current === 0) setDragging(false)
|
||||
}
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
const handleDragOver = (e: DragEvent) => { e.preventDefault() }
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
dragCounter.current = 0
|
||||
setDragging(false)
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
addFiles(e.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAttachClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
const handleFileChange = () => {
|
||||
const input = fileInputRef.current
|
||||
if (input?.files && input.files.length > 0) {
|
||||
addFiles(input.files)
|
||||
input.value = '' // reset so same file can be re-selected
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,62 +108,22 @@ export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: I
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileChange} />
|
||||
|
||||
{/* Drop overlay */}
|
||||
{dragging && (
|
||||
<div className="mb-2 rounded-xl border-2 border-dashed border-accent/50 bg-accent/5 py-4 text-center text-xs text-accent">
|
||||
{t('dropFilesHere', 'Drop files here')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attached files preview */}
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{files.map((af) => (
|
||||
<div
|
||||
key={af.id}
|
||||
className="group flex items-center gap-1.5 bg-surface-secondary border border-border rounded-lg px-2 py-1 text-xs max-w-[200px]"
|
||||
>
|
||||
{af.preview ? (
|
||||
<img src={af.preview} alt="" className="w-5 h-5 rounded object-cover shrink-0" />
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="truncate text-text-secondary">{af.name}</span>
|
||||
{af.file && <span className="text-text-muted shrink-0">({formatSize(af.file.size)})</span>}
|
||||
<button
|
||||
onClick={() => removeFile(af.id)}
|
||||
className="ml-auto shrink-0 text-text-muted hover:text-error transition-colors opacity-0 group-hover:opacity-100"
|
||||
title={t('remove', 'Remove')}
|
||||
>
|
||||
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AttachmentPreviewStrip files={files} onRemove={removeFile} />
|
||||
|
||||
{/* Input container */}
|
||||
<div className={[
|
||||
'flex items-end gap-0 bg-surface-secondary rounded-2xl border transition-colors',
|
||||
dragging ? 'border-accent/50' : 'border-border focus-within:border-accent/40',
|
||||
].join(' ')}>
|
||||
{/* Attach button */}
|
||||
<button
|
||||
onClick={handleAttachClick}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-3 text-text-muted hover:text-text-secondary transition-colors shrink-0 cursor-pointer"
|
||||
title={t('attachFile')}
|
||||
disabled={disabled}
|
||||
@@ -246,7 +133,6 @@ export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: I
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
@@ -260,7 +146,6 @@ export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: I
|
||||
style={{ maxHeight: 160 }}
|
||||
/>
|
||||
|
||||
{/* Send / Stop button */}
|
||||
<div className="p-2 shrink-0">
|
||||
{isRunning ? (
|
||||
<button
|
||||
@@ -268,12 +153,10 @@ export function InputBar({ onSend, onStop, disabled, isRunning, placeholder }: I
|
||||
className="w-10 h-10 flex items-center justify-center hover:opacity-90 transition-opacity"
|
||||
title={t('stopGeneration')}
|
||||
>
|
||||
{/* Spinning border ring */}
|
||||
<svg className="absolute w-8 h-8 animate-spin" viewBox="0 0 32 32" fill="none" style={{ animationDuration: '1.5s' }}>
|
||||
<circle cx="16" cy="16" r="14" stroke="currentColor" strokeWidth="2" className="text-error/20" />
|
||||
<path d="M16 2 A14 14 0 0 1 30 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-error" />
|
||||
</svg>
|
||||
{/* Center stop icon */}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="relative text-error">
|
||||
<rect x="4" y="4" width="16" height="16" rx="3" />
|
||||
</svg>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Message } from '../../stores/chat-store'
|
||||
import type { ChatMessage as Message } from '../../stores/chat-store'
|
||||
import { formatTimestamp } from '../../lib/format'
|
||||
import { ThinkingBlock } from './ThinkingBlock'
|
||||
import { ToolCallBlock } from './ToolCallBlock'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getWsClient } from '../../lib/ws'
|
||||
import { teamService } from '../../services/team-service'
|
||||
import type { TeamTaskData } from '../../types/team'
|
||||
import { PRIORITY_BADGE } from '../../types/team'
|
||||
|
||||
@@ -55,8 +56,7 @@ export function TaskPanel({ sessionKey, onTaskClick }: TaskPanelProps) {
|
||||
const fetchActiveTasks = useCallback(async () => {
|
||||
if (!sessionKey) return
|
||||
try {
|
||||
const ws = getWsClient()
|
||||
const res = await ws.call('teams.tasks.active-by-session', { sessionKey }) as { tasks: ActiveTaskResponse[] | null }
|
||||
const res = await teamService.activeTasksBySession(sessionKey)
|
||||
setTasks((res.tasks ?? []).map(normalizeActiveTask))
|
||||
} catch { /* session may not have team tasks */ }
|
||||
}, [sessionKey])
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatSize, type AttachedFile } from './use-file-attachments'
|
||||
|
||||
interface AttachmentPreviewStripProps {
|
||||
files: AttachedFile[]
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
export function AttachmentPreviewStrip({ files, onRemove }: AttachmentPreviewStripProps) {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
if (files.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{files.map((af) => (
|
||||
<div
|
||||
key={af.id}
|
||||
className="group flex items-center gap-1.5 bg-surface-secondary border border-border rounded-lg px-2 py-1 text-xs max-w-[200px]"
|
||||
>
|
||||
{af.preview ? (
|
||||
<img src={af.preview} alt="" className="w-5 h-5 rounded object-cover shrink-0" />
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="truncate text-text-secondary">{af.name}</span>
|
||||
{af.file && <span className="text-text-muted shrink-0">({formatSize(af.file.size)})</span>}
|
||||
<button
|
||||
onClick={() => onRemove(af.id)}
|
||||
className="ml-auto shrink-0 text-text-muted hover:text-error transition-colors opacity-0 group-hover:opacity-100"
|
||||
title={t('remove', 'Remove')}
|
||||
>
|
||||
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import { AuthImage, downloadFile } from './AuthImage'
|
||||
|
||||
export function getLanguage(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
const map: Record<string, string> = {
|
||||
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
|
||||
py: 'python', go: 'go', rs: 'rust', java: 'java',
|
||||
c: 'c', cpp: 'cpp', h: 'c', rb: 'ruby', sh: 'bash',
|
||||
yaml: 'yaml', yml: 'yaml', toml: 'toml', json: 'json',
|
||||
xml: 'xml', html: 'html', css: 'css',
|
||||
}
|
||||
return map[ext] ?? 'text'
|
||||
}
|
||||
|
||||
export function isImage(filename: string, mimeType?: string): boolean {
|
||||
if (mimeType?.startsWith('image/')) return true
|
||||
return /\.(jpe?g|png|gif|webp|svg|bmp|ico)$/i.test(filename)
|
||||
}
|
||||
|
||||
export function isVideo(filename: string, mimeType?: string): boolean {
|
||||
if (mimeType?.startsWith('video/')) return true
|
||||
return /\.(mp4|webm|ogg|mov)$/i.test(filename)
|
||||
}
|
||||
|
||||
export function isAudio(filename: string, mimeType?: string): boolean {
|
||||
if (mimeType?.startsWith('audio/')) return true
|
||||
return /\.(mp3|wav|ogg|flac|aac|m4a)$/i.test(filename)
|
||||
}
|
||||
|
||||
export function isMarkdown(filename: string): boolean {
|
||||
return /\.(md|markdown)$/i.test(filename)
|
||||
}
|
||||
|
||||
export function isCode(filename: string): boolean {
|
||||
return /\.(ts|tsx|js|jsx|py|go|rs|java|c|cpp|h|rb|sh|yaml|yml|toml|json|xml|html|css)$/i.test(filename)
|
||||
}
|
||||
|
||||
export function isText(filename: string, mimeType?: string): boolean {
|
||||
if (mimeType?.startsWith('text/')) return true
|
||||
return /\.(txt|log|csv|conf|ini|env)$/i.test(filename)
|
||||
}
|
||||
|
||||
interface FilePreviewRendererProps {
|
||||
url: string
|
||||
filename: string
|
||||
displayName: string
|
||||
mimeType?: string
|
||||
textContent: string | null
|
||||
loadError: boolean
|
||||
needsTextFetch: boolean
|
||||
}
|
||||
|
||||
export function FilePreviewRenderer({
|
||||
url, filename, displayName, mimeType, textContent, loadError, needsTextFetch,
|
||||
}: FilePreviewRendererProps) {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
if (isImage(filename, mimeType)) {
|
||||
return <AuthImage src={url} alt={displayName} className="max-w-full max-h-[70vh] object-contain mx-auto rounded-lg" />
|
||||
}
|
||||
if (isVideo(filename, mimeType)) {
|
||||
return <video src={url} controls className="max-w-full rounded-lg" />
|
||||
}
|
||||
if (isAudio(filename, mimeType)) {
|
||||
return <audio src={url} controls className="w-full" />
|
||||
}
|
||||
if (needsTextFetch) {
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="p-6 flex flex-col items-center gap-3 text-text-secondary">
|
||||
<p className="text-sm">{t('errors.serverError', 'Failed to load file preview')}</p>
|
||||
<button
|
||||
onClick={() => downloadFile(url, displayName)}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-accent/10 px-4 py-2 text-sm text-accent hover:bg-accent/20 transition-colors cursor-pointer"
|
||||
>
|
||||
{t('download', 'Download')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (textContent === null) {
|
||||
return <p className="text-text-muted text-sm p-4">{t('loading')}</p>
|
||||
}
|
||||
if (isMarkdown(filename)) {
|
||||
const baseDir = url.substring(0, url.lastIndexOf('/') + 1)
|
||||
const resolved = textContent.replace(
|
||||
/(!?\[.*?\])\((?!https?:\/\/|\/|#)(.*?)\)/g,
|
||||
(_, prefix, relPath) => `${prefix}(${baseDir}${relPath})`,
|
||||
)
|
||||
return (
|
||||
<div className="p-4 overflow-y-auto max-h-[70vh]">
|
||||
<MarkdownRenderer content={resolved} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isCode(filename)) {
|
||||
return (
|
||||
<div className="overflow-y-auto max-h-[70vh]">
|
||||
<SyntaxHighlighter
|
||||
language={getLanguage(filename)}
|
||||
style={oneDark}
|
||||
customStyle={{ margin: 0, fontSize: '13px', borderRadius: 0 }}
|
||||
>
|
||||
{textContent}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<pre className="p-4 text-xs font-mono text-text-primary overflow-auto max-h-[70vh] whitespace-pre-wrap break-words">
|
||||
{textContent}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="p-6 flex flex-col items-center gap-3 text-text-secondary">
|
||||
<p className="text-sm">{t('selectFileToView')}</p>
|
||||
<button
|
||||
onClick={() => downloadFile(url, displayName)}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-accent/10 px-4 py-2 text-sm text-accent hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
{t('download')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
/** A file queued for upload or a direct filesystem path (desktop). */
|
||||
export interface AttachedFile {
|
||||
id: string
|
||||
/** Browser File object — present for drag/drop and file picker uploads. */
|
||||
file?: File
|
||||
/** Direct filesystem path — present for pasted paths (desktop only, skip HTTP upload). */
|
||||
localPath?: string
|
||||
name: string
|
||||
/** Image thumbnail data URL (only for browser File images). */
|
||||
preview?: string
|
||||
}
|
||||
|
||||
/** Human-readable file size. */
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/** Detect if text looks like one or more absolute file paths (one per line). */
|
||||
const FILE_PATH_RE = /^(\/[\w.\-/ ]+(?:\.\w+)?|[A-Z]:\\[\w.\-\\ ]+(?:\.\w+)?)$/
|
||||
|
||||
export function extractFilePaths(text: string): string[] {
|
||||
return text.split('\n').map((l) => l.trim()).filter((l) => FILE_PATH_RE.test(l))
|
||||
}
|
||||
|
||||
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']
|
||||
|
||||
export function useFileAttachments() {
|
||||
const [files, setFiles] = useState<AttachedFile[]>([])
|
||||
|
||||
const addFiles = useCallback((incoming: FileList | File[]) => {
|
||||
const arr = Array.from(incoming)
|
||||
const newFiles: AttachedFile[] = arr.map((f) => ({
|
||||
id: crypto.randomUUID().slice(0, 8),
|
||||
file: f,
|
||||
name: f.name,
|
||||
}))
|
||||
|
||||
// Generate image previews
|
||||
for (const af of newFiles) {
|
||||
if (af.file && IMAGE_TYPES.includes(af.file.type)) {
|
||||
const fileObj = af.file
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
setFiles((prev) => prev.map((f) => f.id === af.id ? { ...f, preview: reader.result as string } : f))
|
||||
}
|
||||
reader.readAsDataURL(fileObj)
|
||||
}
|
||||
}
|
||||
|
||||
setFiles((prev) => [...prev, ...newFiles])
|
||||
}, [])
|
||||
|
||||
/** Add files by filesystem path (desktop paste). */
|
||||
const addLocalPaths = useCallback((paths: string[]) => {
|
||||
const newFiles: AttachedFile[] = paths.map((p) => ({
|
||||
id: crypto.randomUUID().slice(0, 8),
|
||||
localPath: p,
|
||||
name: p.split('/').pop() || p.split('\\').pop() || p,
|
||||
}))
|
||||
setFiles((prev) => [...prev, ...newFiles])
|
||||
}, [])
|
||||
|
||||
const removeFile = useCallback((id: string) => {
|
||||
setFiles((prev) => prev.filter((f) => f.id !== id))
|
||||
}, [])
|
||||
|
||||
const clearFiles = useCallback(() => setFiles([]), [])
|
||||
|
||||
return { files, addFiles, addLocalPaths, removeFile, clearFiles }
|
||||
}
|
||||
+51
-109
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Combobox } from '../../common/Combobox'
|
||||
import { Switch } from '../../common/Switch'
|
||||
import { useAgentCrud } from '../../../hooks/use-agent-crud'
|
||||
import type { CronSchedule, CronPayload } from '../../../types/cron'
|
||||
|
||||
type ScheduleKind = 'every' | 'cron' | 'at'
|
||||
type EveryUnit = 'seconds' | 'minutes' | 'hours'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
import { Switch } from '../common/Switch'
|
||||
import { useAgentCrud } from '../../hooks/use-agent-crud'
|
||||
import { cronFormSchema, toEveryMs, type CronFormData, type ScheduleKind, type EveryUnit } from '../../schemas/cron.schema'
|
||||
import { slugify } from '../../lib/slug'
|
||||
import type { CronSchedule, CronPayload } from '../../types/cron'
|
||||
|
||||
interface CronFormDialogProps {
|
||||
open: boolean
|
||||
@@ -20,82 +21,47 @@ interface CronFormDialogProps {
|
||||
}) => Promise<void>
|
||||
}
|
||||
|
||||
function slugify(v: string): string {
|
||||
return v.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-/, '')
|
||||
}
|
||||
|
||||
function toEveryMs(value: number, unit: EveryUnit): number {
|
||||
if (unit === 'minutes') return value * 60 * 1000
|
||||
if (unit === 'hours') return value * 3600 * 1000
|
||||
return value * 1000
|
||||
}
|
||||
|
||||
export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogProps) {
|
||||
const { t } = useTranslation('cron')
|
||||
const { agents } = useAgentCrud()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [agentId, setAgentId] = useState('')
|
||||
const [scheduleKind, setScheduleKind] = useState<ScheduleKind>('every')
|
||||
const [everyValue, setEveryValue] = useState(5)
|
||||
const [everyUnit, setEveryUnit] = useState<EveryUnit>('minutes')
|
||||
const [cronExpr, setCronExpr] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [deleteAfterRun, setDeleteAfterRun] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const { register, handleSubmit, watch, setValue, reset, formState: { errors, isSubmitting } } = useForm<CronFormData>({
|
||||
resolver: zodResolver(cronFormSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: { name: '', agentId: '', scheduleKind: 'every', everyValue: 5, everyUnit: 'minutes', cronExpr: '', message: '', deleteAfterRun: false },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setName('')
|
||||
setAgentId('')
|
||||
setScheduleKind('every')
|
||||
setEveryValue(5)
|
||||
setEveryUnit('minutes')
|
||||
setCronExpr('')
|
||||
setMessage('')
|
||||
setDeleteAfterRun(false)
|
||||
setSaving(false)
|
||||
setError('')
|
||||
}, [open])
|
||||
if (open) reset({ name: '', agentId: '', scheduleKind: 'every', everyValue: 5, everyUnit: 'minutes', cronExpr: '', message: '', deleteAfterRun: false })
|
||||
}, [open, reset])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const agentOptions = agents.map((a) => ({
|
||||
value: a.id,
|
||||
label: a.display_name || a.agent_key,
|
||||
}))
|
||||
const scheduleKind = watch('scheduleKind')
|
||||
const everyValue = watch('everyValue')
|
||||
const everyUnit = watch('everyUnit')
|
||||
const cronExpr = watch('cronExpr')
|
||||
|
||||
const canSubmit = name.trim() && message.trim() && !saving &&
|
||||
(scheduleKind === 'at' || scheduleKind === 'every' ? everyValue > 0 : cronExpr.trim())
|
||||
const agentOptions = agents.map((a) => ({ value: a.id, label: a.display_name || a.agent_key }))
|
||||
|
||||
const canSubmit = watch('name').trim() && watch('message').trim()
|
||||
&& (scheduleKind === 'at' || scheduleKind === 'every' ? everyValue > 0 : cronExpr.trim())
|
||||
|
||||
function buildSchedule(): CronSchedule {
|
||||
if (scheduleKind === 'every') {
|
||||
return { kind: 'every', everyMs: toEveryMs(everyValue, everyUnit) }
|
||||
}
|
||||
if (scheduleKind === 'cron') {
|
||||
return { kind: 'cron', expr: cronExpr.trim(), tz: Intl.DateTimeFormat().resolvedOptions().timeZone }
|
||||
}
|
||||
if (scheduleKind === 'every') return { kind: 'every', everyMs: toEveryMs(everyValue, everyUnit) }
|
||||
if (scheduleKind === 'cron') return { kind: 'cron', expr: cronExpr.trim(), tz: Intl.DateTimeFormat().resolvedOptions().timeZone }
|
||||
return { kind: 'at', atMs: Date.now() + 60_000 }
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await onSubmit({
|
||||
name,
|
||||
agentId: agentId || undefined,
|
||||
schedule: buildSchedule(),
|
||||
payload: { kind: 'message', message },
|
||||
deleteAfterRun: scheduleKind === 'at' ? deleteAfterRun : undefined,
|
||||
})
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setError((err as Error).message || 'Failed to create')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
const onValid = async (data: CronFormData) => {
|
||||
await onSubmit({
|
||||
name: data.name,
|
||||
agentId: data.agentId || undefined,
|
||||
schedule: buildSchedule(),
|
||||
payload: { kind: 'message', message: data.message },
|
||||
deleteAfterRun: data.scheduleKind === 'at' ? data.deleteAfterRun : undefined,
|
||||
})
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const SCHEDULE_KINDS: ScheduleKind[] = ['every', 'cron', 'at']
|
||||
@@ -125,24 +91,19 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('create.name')}</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(slugify(e.target.value))}
|
||||
value={watch('name')}
|
||||
onChange={(e) => setValue('name', slugify(e.target.value), { shouldValidate: true })}
|
||||
placeholder={t('create.namePlaceholder')}
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent font-mono"
|
||||
/>
|
||||
<p className="text-[11px] text-text-muted">{t('create.nameHint')}</p>
|
||||
{errors.name && <p className="text-xs text-error">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Agent */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('create.agentId')}</label>
|
||||
<Combobox
|
||||
value={agentId}
|
||||
onChange={setAgentId}
|
||||
options={agentOptions}
|
||||
placeholder={t('create.agentIdPlaceholder')}
|
||||
allowCustom={false}
|
||||
/>
|
||||
<Combobox value={watch('agentId')} onChange={(v) => setValue('agentId', v)} options={agentOptions} placeholder={t('create.agentIdPlaceholder')} allowCustom={false} />
|
||||
</div>
|
||||
|
||||
{/* Schedule type */}
|
||||
@@ -153,7 +114,7 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
onClick={() => setScheduleKind(kind)}
|
||||
onClick={() => setValue('scheduleKind', kind)}
|
||||
className={`border rounded-lg px-3 py-2 text-xs text-center transition-colors ${
|
||||
scheduleKind === kind
|
||||
? 'border-accent bg-accent/10 text-accent font-medium'
|
||||
@@ -172,7 +133,7 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
|
||||
type="number"
|
||||
min={1}
|
||||
value={everyValue}
|
||||
onChange={(e) => setEveryValue(Math.max(1, Number(e.target.value)))}
|
||||
onChange={(e) => setValue('everyValue', Math.max(1, Number(e.target.value)))}
|
||||
className="w-20 bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
@@ -180,7 +141,7 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
|
||||
<button
|
||||
key={u.value}
|
||||
type="button"
|
||||
onClick={() => setEveryUnit(u.value)}
|
||||
onClick={() => setValue('everyUnit', u.value)}
|
||||
className={`border rounded-lg px-2.5 py-1.5 text-xs transition-colors ${
|
||||
everyUnit === u.value
|
||||
? 'border-accent bg-accent/10 text-accent font-medium'
|
||||
@@ -198,8 +159,7 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
|
||||
{scheduleKind === 'cron' && (
|
||||
<div className="space-y-1 pt-1">
|
||||
<input
|
||||
value={cronExpr}
|
||||
onChange={(e) => setCronExpr(e.target.value)}
|
||||
{...register('cronExpr')}
|
||||
placeholder="0 * * * *"
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm font-mono text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
@@ -209,58 +169,40 @@ export function CronFormDialog({ open, onOpenChange, onSubmit }: CronFormDialogP
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Once */}
|
||||
{scheduleKind === 'at' && (
|
||||
<p className="text-xs text-text-muted pt-1">{t('create.onceDesc')}</p>
|
||||
)}
|
||||
{scheduleKind === 'at' && <p className="text-xs text-text-muted pt-1">{t('create.onceDesc')}</p>}
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('create.message')}</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
{...register('message')}
|
||||
placeholder={t('create.messagePlaceholder')}
|
||||
rows={3}
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent resize-none"
|
||||
/>
|
||||
{errors.message && <p className="text-xs text-error">{errors.message.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Delete after run — only for 'at' */}
|
||||
{/* Delete after run */}
|
||||
{scheduleKind === 'at' && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-border p-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-primary">{t('detail.deleteAfterRun')}</p>
|
||||
<p className="text-[11px] text-text-muted">{t('detail.deleteAfterRunDesc')}</p>
|
||||
</div>
|
||||
<Switch checked={deleteAfterRun} onCheckedChange={setDeleteAfterRun} />
|
||||
<Switch checked={watch('deleteAfterRun')} onCheckedChange={(v) => setValue('deleteAfterRun', v)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-5 pb-1">
|
||||
<p className="text-xs text-error">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-5 py-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors"
|
||||
>
|
||||
<button type="button" onClick={() => onOpenChange(false)} className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors">
|
||||
{t('create.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? t('create.creating') : t('create.create')}
|
||||
<button type="button" onClick={handleSubmit(onValid)} disabled={!canSubmit || isSubmitting} className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors">
|
||||
{isSubmitting ? t('create.creating') : t('create.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCron } from '../../hooks/use-cron'
|
||||
import { useAgentCrud } from '../../hooks/use-agent-crud'
|
||||
import { RefreshButton } from '../common/RefreshButton'
|
||||
import { ConfirmDialog } from '../common/ConfirmDialog'
|
||||
import { CronFormDialog } from './CronFormDialog'
|
||||
import { CronRunsDialog } from './CronRunsDialog'
|
||||
import { CronListItem } from './cron-list-item'
|
||||
import type { CronJob } from '../../types/cron'
|
||||
|
||||
export function CronList() {
|
||||
const { t } = useTranslation('cron')
|
||||
const { jobs, loading, fetchJobs, createJob, deleteJob, toggleJob, runJob, fetchRuns } = useCron()
|
||||
const { agents } = useAgentCrud()
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<CronJob | null>(null)
|
||||
const [toggleTarget, setToggleTarget] = useState<CronJob | null>(null)
|
||||
const [runsTarget, setRunsTarget] = useState<CronJob | null>(null)
|
||||
const [runningIds, setRunningIds] = useState<Set<string>>(new Set())
|
||||
|
||||
function agentName(agentId: string): string {
|
||||
if (!agentId) return t('defaultAgent')
|
||||
return agents.find((a) => a.id === agentId)?.display_name
|
||||
|| agents.find((a) => a.id === agentId)?.agent_key
|
||||
|| agentId
|
||||
}
|
||||
|
||||
async function handleRunNow(job: CronJob) {
|
||||
setRunningIds((prev) => new Set(prev).add(job.id))
|
||||
try {
|
||||
await runJob(job.id)
|
||||
} finally {
|
||||
setRunningIds((prev) => { const s = new Set(prev); s.delete(job.id); return s })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleConfirm() {
|
||||
if (!toggleTarget) return
|
||||
await toggleJob(toggleTarget.id)
|
||||
setToggleTarget(null)
|
||||
}
|
||||
|
||||
async function handleDeleteConfirm() {
|
||||
if (!deleteTarget) return
|
||||
await deleteJob(deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary">{t('title')}</h2>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t('description')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setFormOpen(true)}
|
||||
className="bg-accent text-white rounded-lg px-3 py-1.5 text-xs hover:bg-accent-hover transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14" /><path d="M12 5v14" />
|
||||
</svg>
|
||||
{t('newJob')}
|
||||
</button>
|
||||
<RefreshButton onRefresh={fetchJobs} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-12 rounded-lg bg-surface-tertiary/50 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-12">
|
||||
<svg className="h-10 w-10 text-text-muted/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<p className="text-sm text-text-muted">{t('emptyTitle')}</p>
|
||||
<p className="text-xs text-text-muted/70">{t('emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-tertiary/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.name')}</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.schedule')}</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.agent')}</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.enabled')}</th>
|
||||
<th className="px-4 py-2.5 text-right text-xs font-medium text-text-muted">{t('columns.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job) => (
|
||||
<CronListItem
|
||||
key={job.id}
|
||||
job={job}
|
||||
agentName={agentName}
|
||||
isRunning={runningIds.has(job.id)}
|
||||
onRunNow={handleRunNow}
|
||||
onToggle={(j) => setToggleTarget(j)}
|
||||
onDelete={(j) => setDeleteTarget(j)}
|
||||
onViewHistory={(j) => setRunsTarget(j)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CronFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
onSubmit={async (params) => { await createJob(params) }}
|
||||
/>
|
||||
|
||||
{toggleTarget && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
onOpenChange={() => setToggleTarget(null)}
|
||||
title={toggleTarget.enabled ? t('disable.title') : t('enable.title')}
|
||||
description={toggleTarget.enabled
|
||||
? t('disable.description', { name: toggleTarget.name })
|
||||
: t('enable.description', { name: toggleTarget.name })
|
||||
}
|
||||
confirmLabel={toggleTarget.enabled ? t('disable.confirmLabel') : t('enable.confirmLabel')}
|
||||
onConfirm={handleToggleConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
onOpenChange={() => setDeleteTarget(null)}
|
||||
title={t('delete.title')}
|
||||
description={t('delete.description', { name: deleteTarget.name })}
|
||||
confirmLabel={t('delete.confirmLabel')}
|
||||
variant="destructive"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{runsTarget && (
|
||||
<CronRunsDialog
|
||||
open
|
||||
onOpenChange={() => setRunsTarget(null)}
|
||||
job={runsTarget}
|
||||
onFetchRuns={fetchRuns}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { CronJob, CronRunLog } from '../../../types/cron'
|
||||
import type { CronJob, CronRunLog } from '../../types/cron'
|
||||
|
||||
interface CronRunsDialogProps {
|
||||
open: boolean
|
||||
@@ -0,0 +1,127 @@
|
||||
// Individual cron job table row with actions: run history, run now, toggle, delete.
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Switch } from '../common/Switch'
|
||||
import type { CronJob, CronSchedule } from '../../types/cron'
|
||||
|
||||
export function formatSchedule(s: CronSchedule): string {
|
||||
if (s.kind === 'every' && s.everyMs) {
|
||||
const sec = s.everyMs / 1000
|
||||
if (sec < 60) return `every ${sec}s`
|
||||
if (sec < 3600) return `every ${Math.round(sec / 60)}m`
|
||||
return `every ${Math.round(sec / 3600)}h`
|
||||
}
|
||||
if (s.kind === 'cron' && s.expr) return s.expr
|
||||
if (s.kind === 'at' && s.atMs) return `once at ${new Date(s.atMs).toLocaleString()}`
|
||||
return '—'
|
||||
}
|
||||
|
||||
export function statusBadgeClass(status?: string): string {
|
||||
if (!status) return 'bg-surface-tertiary text-text-secondary border border-border'
|
||||
if (status === 'ok' || status === 'success') {
|
||||
return 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
|
||||
}
|
||||
if (status === 'error' || status === 'failed') {
|
||||
return 'bg-red-500/15 text-red-700 border border-red-500/25 dark:text-red-400 dark:bg-red-500/10 dark:border-red-500/20'
|
||||
}
|
||||
if (status === 'running') {
|
||||
return 'bg-blue-500/15 text-blue-700 border border-blue-500/25 dark:text-blue-400 dark:bg-blue-500/10 dark:border-blue-500/20 animate-pulse'
|
||||
}
|
||||
return 'bg-amber-500/15 text-amber-700 border border-amber-500/25 dark:text-amber-400 dark:bg-amber-500/10 dark:border-amber-500/20'
|
||||
}
|
||||
|
||||
interface CronListItemProps {
|
||||
job: CronJob
|
||||
agentName: (agentId: string) => string
|
||||
isRunning: boolean
|
||||
onRunNow: (job: CronJob) => void
|
||||
onToggle: (job: CronJob) => void
|
||||
onDelete: (job: CronJob) => void
|
||||
onViewHistory: (job: CronJob) => void
|
||||
}
|
||||
|
||||
export function CronListItem({
|
||||
job, agentName, isRunning, onRunNow, onToggle, onDelete, onViewHistory,
|
||||
}: CronListItemProps) {
|
||||
const { t } = useTranslation('cron')
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border last:border-0 hover:bg-surface-tertiary/30 transition-colors [&>td]:align-middle">
|
||||
{/* Name */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<div className="min-w-0">
|
||||
<span className="font-mono text-sm text-text-primary">{job.name}</span>
|
||||
{job.state?.lastStatus && (
|
||||
<span className={`ml-2 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${statusBadgeClass(job.state.lastStatus)}`}>
|
||||
{job.state.lastStatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{/* Schedule */}
|
||||
<td className="px-4 py-3 text-xs text-text-muted font-mono">
|
||||
{formatSchedule(job.schedule)}
|
||||
</td>
|
||||
{/* Agent */}
|
||||
<td className="px-4 py-3 text-xs text-text-muted">
|
||||
{agentName(job.agentId)}
|
||||
</td>
|
||||
{/* Enabled badge */}
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
|
||||
job.enabled
|
||||
? 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
|
||||
: 'bg-surface-tertiary text-text-secondary border border-border'
|
||||
}`}>
|
||||
{job.enabled ? t('detail.enabled') : t('detail.disabled')}
|
||||
</span>
|
||||
</td>
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => onViewHistory(job)}
|
||||
className="p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
title={t('runHistory')}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" /><path d="M12 7v5l4 2" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRunNow(job)}
|
||||
disabled={isRunning}
|
||||
className="p-1 text-text-muted hover:text-accent transition-colors disabled:opacity-50"
|
||||
title={t('runNow')}
|
||||
>
|
||||
{isRunning ? (
|
||||
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="6 3 20 12 6 21 6 3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<Switch checked={job.enabled} onCheckedChange={() => onToggle(job)} />
|
||||
<button
|
||||
onClick={() => onDelete(job)}
|
||||
className="p-1 text-text-muted hover:text-error transition-colors"
|
||||
title={t('delete.title')}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useUiStore } from '../../../stores/ui-store'
|
||||
import { useAgentStore } from '../../../stores/agent-store'
|
||||
import { getWsClient } from '../../../lib/ws'
|
||||
import { teamService } from '../../../services/team-service'
|
||||
import { TeamCreateDialog } from '../../teams/TeamCreateDialog'
|
||||
import type { TeamData } from '../../../types/team'
|
||||
|
||||
@@ -18,9 +18,8 @@ export function SidebarTeams() {
|
||||
const agents = useAgentStore((s) => s.agents)
|
||||
|
||||
useEffect(() => {
|
||||
getWsClient().call('teams.list').then((res) => {
|
||||
const r = res as { teams: TeamData[] | null }
|
||||
setTeams(r.teams ?? [])
|
||||
teamService.list().then((res) => {
|
||||
setTeams(res.teams ?? [])
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Switch } from '../common/Switch'
|
||||
import { McpTestResult } from './mcp-test-result'
|
||||
import { McpTransportFields } from './mcp-transport-fields'
|
||||
import { mcpFormSchema, type MCPFormData } from '../../schemas/mcp.schema'
|
||||
import type { MCPServerData, MCPServerInput, MCPTestResult } from '../../types/mcp'
|
||||
|
||||
const TRANSPORTS = ['stdio', 'sse', 'streamable-http'] as const
|
||||
|
||||
function slugify(v: string): string {
|
||||
return v.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-/, '')
|
||||
}
|
||||
|
||||
interface McpFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
server?: MCPServerData | null
|
||||
onSubmit: (data: MCPServerInput) => Promise<unknown>
|
||||
onTest: (data: { transport: string; command?: string; args?: string[]; url?: string; headers?: Record<string, string>; env?: Record<string, string> }) => Promise<MCPTestResult>
|
||||
}
|
||||
|
||||
export function McpFormDialog({ open, onOpenChange, server, onSubmit, onTest }: McpFormDialogProps) {
|
||||
const { t } = useTranslation(['mcp', 'common'])
|
||||
const isEdit = Boolean(server)
|
||||
|
||||
const { register, handleSubmit, watch, setValue, reset, formState: { errors, isSubmitting } } = useForm<MCPFormData>({
|
||||
resolver: zodResolver(mcpFormSchema),
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
// Test connection state (UI-only, not form data)
|
||||
const [testState, setTestState] = useState<'idle' | 'testing' | 'success' | 'error'>('idle')
|
||||
const [testResult, setTestResult] = useState<MCPTestResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (server) {
|
||||
reset({
|
||||
name: server.name,
|
||||
displayName: server.display_name || '',
|
||||
transport: server.transport,
|
||||
command: server.command || '',
|
||||
args: Array.isArray(server.args) ? server.args.join(' ') : '',
|
||||
url: server.url || '',
|
||||
headers: server.headers ?? {},
|
||||
env: server.env ?? {},
|
||||
toolPrefix: server.tool_prefix?.replace(/^mcp_/, '') ?? '',
|
||||
timeoutSec: server.timeout_sec || 30,
|
||||
enabled: server.enabled,
|
||||
})
|
||||
} else {
|
||||
reset({ name: '', displayName: '', transport: 'stdio', command: '', args: '', url: '', headers: {}, env: {}, toolPrefix: '', timeoutSec: 30, enabled: true })
|
||||
}
|
||||
setTestState('idle')
|
||||
setTestResult(null)
|
||||
}, [open, server, reset])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const transport = watch('transport')
|
||||
const headers = watch('headers')
|
||||
const env = watch('env')
|
||||
const name = watch('name')
|
||||
|
||||
function buildInput(data: MCPFormData): MCPServerInput {
|
||||
const input: MCPServerInput = { name: data.name, display_name: data.displayName || undefined, transport: data.transport, timeout_sec: data.timeoutSec, enabled: data.enabled }
|
||||
if (data.transport === 'stdio') {
|
||||
input.command = data.command
|
||||
input.args = data.args.trim() ? data.args.trim().split(/\s+/) : undefined
|
||||
} else {
|
||||
input.url = data.url
|
||||
if (Object.keys(data.headers).length > 0) input.headers = data.headers
|
||||
}
|
||||
if (Object.keys(data.env).length > 0) input.env = data.env
|
||||
if (data.toolPrefix.trim()) input.tool_prefix = data.toolPrefix.trim()
|
||||
return input
|
||||
}
|
||||
|
||||
const onValid = async (data: MCPFormData) => {
|
||||
await onSubmit(buildInput(data))
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
setTestState('testing')
|
||||
setTestResult(null)
|
||||
try {
|
||||
const data: Parameters<typeof onTest>[0] = { transport }
|
||||
if (transport === 'stdio') {
|
||||
data.command = watch('command')
|
||||
const a = watch('args').trim()
|
||||
if (a) data.args = a.split(/\s+/)
|
||||
} else {
|
||||
data.url = watch('url')
|
||||
if (Object.keys(headers).length > 0) data.headers = headers
|
||||
}
|
||||
if (Object.keys(env).length > 0) data.env = env
|
||||
const result = await onTest(data)
|
||||
setTestResult(result)
|
||||
setTestState(result.success ? 'success' : 'error')
|
||||
} catch (err) {
|
||||
setTestResult({ success: false, error: (err as Error).message })
|
||||
setTestState('error')
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = watch('name').trim() && (transport === 'stdio' ? watch('command').trim() : watch('url').trim())
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => onOpenChange(false)} />
|
||||
<div className="relative w-full max-w-lg bg-surface-secondary rounded-xl border border-border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<span className="text-sm font-semibold text-text-primary">{isEdit ? t('form.editTitle') : t('form.createTitle')}</span>
|
||||
<button onClick={() => onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="max-h-[70vh] overflow-y-auto p-5 space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.name')}</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setValue('name', slugify(e.target.value), { shouldValidate: true })}
|
||||
disabled={isEdit}
|
||||
placeholder="my-mcp-server"
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent disabled:opacity-50"
|
||||
/>
|
||||
<p className="text-[11px] text-text-muted">{t('form.nameHint')}</p>
|
||||
{errors.name && <p className="text-xs text-error">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Display Name */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.displayName')}</label>
|
||||
<input {...register('displayName')} placeholder={t('form.displayNamePlaceholder')} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
|
||||
</div>
|
||||
|
||||
{/* Transport */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.transport')}</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{TRANSPORTS.map((tr) => (
|
||||
<button key={tr} type="button" onClick={() => setValue('transport', tr)}
|
||||
className={`border rounded-lg px-3 py-2 text-xs text-center transition-colors ${transport === tr ? 'border-accent bg-accent/10 text-accent font-medium' : 'border-border text-text-secondary hover:bg-surface-tertiary/30'}`}
|
||||
>{tr.toUpperCase()}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<McpTransportFields
|
||||
transport={transport}
|
||||
headers={headers}
|
||||
env={env}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
/>
|
||||
|
||||
{/* Tool Prefix */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.toolPrefix')}</label>
|
||||
<div className="flex items-stretch">
|
||||
<span className="inline-flex items-center bg-surface-tertiary/70 border border-r-0 border-border rounded-l-lg px-2.5 text-base md:text-sm text-text-muted select-none">mcp_</span>
|
||||
<input
|
||||
value={watch('toolPrefix')}
|
||||
onChange={(e) => setValue('toolPrefix', e.target.value.replace(/[^a-z0-9_]/gi, '_').toLowerCase())}
|
||||
placeholder={name.replace(/-/g, '_') || 'auto'}
|
||||
className="flex-1 min-w-0 bg-surface-tertiary border border-border rounded-r-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted">{t('form.toolPrefixHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Timeout */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.timeout')}</label>
|
||||
<input type="number" min={1} {...register('timeoutSec', { valueAsNumber: true })} className="w-24 bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent" />
|
||||
</div>
|
||||
|
||||
{/* Enabled */}
|
||||
<div className="flex items-center justify-between rounded-lg border border-border p-3">
|
||||
<span className="text-xs font-medium text-text-primary">{t('form.enabled')}</span>
|
||||
<Switch checked={watch('enabled')} onCheckedChange={(v) => setValue('enabled', v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-5 py-4 space-y-2">
|
||||
<McpTestResult state={testState} result={testResult} />
|
||||
<div className="flex items-center justify-between">
|
||||
<button type="button" onClick={handleTest} disabled={isSubmitting || testState === 'testing'} className="border border-border rounded-lg px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-tertiary transition-colors disabled:opacity-50 shrink-0">
|
||||
{testState === 'testing' ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||
{t('form.testing')}
|
||||
</span>
|
||||
) : t('form.testConnection')}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={() => onOpenChange(false)} className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors">{t('form.cancel')}</button>
|
||||
<button type="button" onClick={handleSubmit(onValid)} disabled={!canSubmit || isSubmitting} className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors">
|
||||
{isSubmitting ? t('form.saving') : isEdit ? t('form.update') : t('form.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Combobox } from '../../common/Combobox'
|
||||
import type { MCPServerData, MCPAgentGrant } from '../../../types/mcp'
|
||||
import type { AgentData } from '../../../types/agent'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
import type { MCPServerData, MCPAgentGrant } from '../../types/mcp'
|
||||
import type { AgentData } from '../../types/agent'
|
||||
|
||||
interface McpGrantsDialogProps {
|
||||
open: boolean
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMcpServers, MAX_MCP_LITE } from '../../hooks/use-mcp-servers'
|
||||
import { useAgentCrud } from '../../hooks/use-agent-crud'
|
||||
import { McpFormDialog } from './McpFormDialog'
|
||||
import { McpGrantsDialog } from './McpGrantsDialog'
|
||||
import { McpToolsDialog } from './McpToolsDialog'
|
||||
import { ConfirmDeleteDialog } from '../common/ConfirmDeleteDialog'
|
||||
import { RefreshButton } from '../common/RefreshButton'
|
||||
import { McpServerRow } from './mcp-server-row'
|
||||
import type { MCPServerData } from '../../types/mcp'
|
||||
|
||||
export function McpServerList() {
|
||||
const { t } = useTranslation(['mcp', 'common'])
|
||||
const {
|
||||
servers, loading, atLimit,
|
||||
fetchServers, createServer, updateServer, deleteServer,
|
||||
testConnection, reconnectServer, listServerTools,
|
||||
listGrants, grantAgent, revokeAgent,
|
||||
} = useMcpServers()
|
||||
const { agents } = useAgentCrud()
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [editServer, setEditServer] = useState<MCPServerData | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<MCPServerData | null>(null)
|
||||
const [grantsServer, setGrantsServer] = useState<MCPServerData | null>(null)
|
||||
const [toolsServer, setToolsServer] = useState<MCPServerData | null>(null)
|
||||
const [reconnectingId, setReconnectingId] = useState<string | null>(null)
|
||||
|
||||
function openCreate() { setEditServer(null); setFormOpen(true) }
|
||||
function openEdit(s: MCPServerData) { setEditServer(s); setFormOpen(true) }
|
||||
|
||||
async function handleReconnect(id: string) {
|
||||
setReconnectingId(id)
|
||||
try { await reconnectServer(id) } finally { setReconnectingId(null) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary">{t('title')}</h2>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t('description')} (max {MAX_MCP_LITE})</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openCreate}
|
||||
disabled={atLimit}
|
||||
className="bg-accent text-white rounded-lg px-3 py-1.5 text-xs hover:bg-accent-hover disabled:opacity-50 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14" /><path d="M12 5v14" />
|
||||
</svg>
|
||||
{t('addServer')}
|
||||
</button>
|
||||
<RefreshButton onRefresh={fetchServers} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{atLimit && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400">
|
||||
{t('noMatchTitle')} ({MAX_MCP_LITE}). {t('noMatchDescription')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-12 rounded-lg bg-surface-tertiary/50 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-12">
|
||||
<svg className="h-10 w-10 text-text-muted/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22v-5" /><path d="M9 8V2" /><path d="M15 8V2" />
|
||||
<path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z" />
|
||||
</svg>
|
||||
<p className="text-sm text-text-muted">{t('emptyTitle')}</p>
|
||||
<p className="text-xs text-text-muted/70">{t('emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-tertiary/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.name')}</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.transport')}</th>
|
||||
<th className="px-4 py-2.5 text-center text-xs font-medium text-text-muted">{t('columns.tools')}</th>
|
||||
<th className="px-4 py-2.5 text-center text-xs font-medium text-text-muted">{t('columns.agents')}</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.enabled')}</th>
|
||||
<th className="px-4 py-2.5 text-right text-xs font-medium text-text-muted">{t('columns.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{servers.map((s) => (
|
||||
<McpServerRow
|
||||
key={s.id}
|
||||
server={s}
|
||||
reconnectingId={reconnectingId}
|
||||
onReconnect={handleReconnect}
|
||||
onEdit={openEdit}
|
||||
onDelete={(srv) => setDeleteTarget(srv)}
|
||||
onViewTools={(srv) => setToolsServer(srv)}
|
||||
onManageGrants={(srv) => setGrantsServer(srv)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<McpFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
server={editServer}
|
||||
onSubmit={async (data) => {
|
||||
if (editServer) await updateServer(editServer.id, data)
|
||||
else await createServer(data)
|
||||
}}
|
||||
onTest={testConnection}
|
||||
/>
|
||||
|
||||
{deleteTarget && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={() => setDeleteTarget(null)}
|
||||
title="Delete MCP Server"
|
||||
description={`This will permanently delete the server "${deleteTarget.display_name || deleteTarget.name}" and all its agent grants.`}
|
||||
confirmValue={deleteTarget.name}
|
||||
onConfirm={async () => { await deleteServer(deleteTarget.id); setDeleteTarget(null) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{grantsServer && (
|
||||
<McpGrantsDialog
|
||||
open
|
||||
onOpenChange={() => setGrantsServer(null)}
|
||||
server={grantsServer}
|
||||
agents={agents}
|
||||
onLoadGrants={listGrants}
|
||||
onGrant={grantAgent}
|
||||
onRevoke={revokeAgent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toolsServer && (
|
||||
<McpToolsDialog
|
||||
open
|
||||
onOpenChange={() => setToolsServer(null)}
|
||||
server={toolsServer}
|
||||
onLoadTools={listServerTools}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { MCPServerData, MCPToolInfo } from '../../../types/mcp'
|
||||
import type { MCPServerData, MCPToolInfo } from '../../types/mcp'
|
||||
|
||||
interface McpToolsDialogProps {
|
||||
open: boolean
|
||||
@@ -0,0 +1,112 @@
|
||||
// MCP server table row with status badge and action buttons.
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { MCPServerData } from '../../types/mcp'
|
||||
|
||||
interface McpServerRowProps {
|
||||
server: MCPServerData
|
||||
reconnectingId: string | null
|
||||
onReconnect: (id: string) => void
|
||||
onEdit: (server: MCPServerData) => void
|
||||
onDelete: (server: MCPServerData) => void
|
||||
onViewTools: (server: MCPServerData) => void
|
||||
onManageGrants: (server: MCPServerData) => void
|
||||
}
|
||||
|
||||
export function McpServerRow({
|
||||
server: s, reconnectingId, onReconnect, onEdit, onDelete, onViewTools, onManageGrants,
|
||||
}: McpServerRowProps) {
|
||||
const { t } = useTranslation(['mcp', 'common'])
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border last:border-0 hover:bg-surface-tertiary/30 transition-colors [&>td]:align-middle">
|
||||
{/* Name */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22v-5" /><path d="M9 8V2" /><path d="M15 8V2" />
|
||||
<path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z" />
|
||||
</svg>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-text-primary truncate">{s.display_name || s.name}</div>
|
||||
<div className="font-mono text-[11px] text-text-muted bg-surface-tertiary/50 px-1.5 py-0.5 rounded inline-block mt-0.5">
|
||||
mcp_{s.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{/* Transport */}
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
|
||||
s.transport === 'sse'
|
||||
? 'bg-surface-tertiary text-text-secondary'
|
||||
: 'border border-border text-text-muted'
|
||||
}`}>
|
||||
{s.transport.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
{/* Tools */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => onViewTools(s)}
|
||||
className="p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
title={t('viewTools')}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
{/* Agents */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => onManageGrants(s)}
|
||||
className="inline-flex items-center gap-1 text-sm text-text-primary hover:text-accent transition-colors"
|
||||
title={t('manageGrants')}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
{s.agent_count ?? 0}
|
||||
</button>
|
||||
</td>
|
||||
{/* Enabled */}
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
|
||||
s.enabled
|
||||
? 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
|
||||
: 'bg-surface-tertiary text-text-secondary'
|
||||
}`}>
|
||||
{s.enabled ? t('common:enabled') : t('common:disabled')}
|
||||
</span>
|
||||
</td>
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
disabled={reconnectingId === s.id}
|
||||
onClick={() => onReconnect(s.id)}
|
||||
className="p-1 text-text-muted hover:text-text-primary transition-colors disabled:opacity-50"
|
||||
title={t('reconnect')}
|
||||
>
|
||||
<svg className={`h-3.5 w-3.5${reconnectingId === s.id ? ' animate-spin' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => onEdit(s)} className="p-1 text-text-muted hover:text-text-primary transition-colors" title="Edit">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => onDelete(s)} className="p-1 text-text-muted hover:text-error transition-colors" title="Delete">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { MCPTestResult } from '../../types/mcp'
|
||||
|
||||
interface McpTestResultProps {
|
||||
state: 'idle' | 'testing' | 'success' | 'error'
|
||||
result: MCPTestResult | null
|
||||
}
|
||||
|
||||
export function McpTestResult({ state, result }: McpTestResultProps) {
|
||||
const { t } = useTranslation('mcp')
|
||||
|
||||
if (state === 'success' && result) {
|
||||
return (
|
||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
{t('form.toolsFound', { count: result.tool_count })}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'error' && result) {
|
||||
return (
|
||||
<p className="text-[11px] text-error flex items-center gap-1">
|
||||
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
<span className="break-all">{result.error || t('form.errors.connectionFailed')}</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { KeyValueEditor } from '../common/KeyValueEditor'
|
||||
import type { UseFormRegister, UseFormSetValue } from 'react-hook-form'
|
||||
import type { MCPFormData } from '../../schemas/mcp.schema'
|
||||
|
||||
const SENSITIVE_HEADER_KEYS = /(auth|api-key|api_key|bearer|token|secret|password|credential)/i
|
||||
const SENSITIVE_ENV_KEYS = /(key|secret|token|password|credential)/i
|
||||
|
||||
interface McpTransportFieldsProps {
|
||||
transport: string
|
||||
headers: Record<string, string>
|
||||
env: Record<string, string>
|
||||
register: UseFormRegister<MCPFormData>
|
||||
setValue: UseFormSetValue<MCPFormData>
|
||||
}
|
||||
|
||||
export function McpTransportFields({ transport, headers, env, register, setValue }: McpTransportFieldsProps) {
|
||||
const { t } = useTranslation('mcp')
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Stdio fields */}
|
||||
{transport === 'stdio' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.command')}</label>
|
||||
<input {...register('command')} placeholder="npx" className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.args')}</label>
|
||||
<input {...register('args')} placeholder={t('form.argsPlaceholder')} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* SSE/HTTP fields */}
|
||||
{transport !== 'stdio' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.url')}</label>
|
||||
<input {...register('url')} placeholder="http://localhost:3001/sse" className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.headers')}</label>
|
||||
<KeyValueEditor value={headers} onChange={(v) => setValue('headers', v)} sensitivePattern={SENSITIVE_HEADER_KEYS} placeholder={{ key: t('form.headerKeyPlaceholder'), value: t('form.headerValuePlaceholder') }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Env Variables */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.env')}</label>
|
||||
<KeyValueEditor value={env} onChange={(v) => setValue('env', v)} sensitivePattern={SENSITIVE_ENV_KEYS} placeholder={{ key: t('form.envKeyPlaceholder'), value: t('form.envValuePlaceholder') }} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { motion } from 'framer-motion'
|
||||
import { getWsClient } from '../../lib/ws'
|
||||
import { agentService } from '../../services/agent-service'
|
||||
import { SummoningProgressSteps } from './summoning-progress-steps'
|
||||
|
||||
const SUMMONING_FILE_KEYS = [
|
||||
{ name: 'SOUL.md', required: true, labelKey: 'summoning.fileLabelSOUL' },
|
||||
{ name: 'IDENTITY.md', required: true, labelKey: 'summoning.fileLabelIDENTITY' },
|
||||
]
|
||||
const SUMMONING_REQUIRED_FILES = ['SOUL.md', 'IDENTITY.md']
|
||||
|
||||
interface SummoningModalProps {
|
||||
agentId: string
|
||||
@@ -51,8 +50,7 @@ export function SummoningModal({ agentId, agentName, onContinue }: SummoningModa
|
||||
)
|
||||
}
|
||||
if (data.type === 'completed') {
|
||||
const required = SUMMONING_FILE_KEYS.filter((f) => f.required).map((f) => f.name)
|
||||
setGeneratedFiles((prev) => [...new Set([...prev, ...required])])
|
||||
setGeneratedFiles((prev) => [...new Set([...prev, ...SUMMONING_REQUIRED_FILES])])
|
||||
setStatus('completed')
|
||||
}
|
||||
if (data.type === 'failed') {
|
||||
@@ -74,8 +72,7 @@ export function SummoningModal({ agentId, agentName, onContinue }: SummoningModa
|
||||
const handleRetry = async () => {
|
||||
setRetrying(true)
|
||||
try {
|
||||
const ws = getWsClient()
|
||||
await ws.call('agents.resummon', { agentId })
|
||||
await agentService.resummonWs(agentId)
|
||||
setGeneratedFiles([])
|
||||
setStatus('summoning')
|
||||
setErrorMsg('')
|
||||
@@ -159,49 +156,7 @@ export function SummoningModal({ agentId, agentName, onContinue }: SummoningModa
|
||||
</p>
|
||||
|
||||
{/* File progress */}
|
||||
<div className="w-full space-y-2">
|
||||
<AnimatePresence>
|
||||
{SUMMONING_FILE_KEYS.map((file, i) => {
|
||||
const done = generatedFiles.includes(file.name)
|
||||
return (
|
||||
<motion.div
|
||||
key={file.name}
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-1.5"
|
||||
>
|
||||
<motion.div
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs ${
|
||||
done
|
||||
? 'bg-orange-100 text-orange-600 dark:bg-orange-900/40 dark:text-orange-400'
|
||||
: 'bg-surface-tertiary text-text-muted'
|
||||
}`}
|
||||
animate={done ? { scale: [0.8, 1.2, 1] } : {}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{done ? '✓' : i + 1}
|
||||
</motion.div>
|
||||
<div className="flex-1">
|
||||
<span className={`text-sm ${done ? 'text-text-primary font-medium' : 'text-text-secondary'}`}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-text-muted">{t(`desktop:${file.labelKey}`)}</span>
|
||||
</div>
|
||||
{done && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-xs text-orange-600 dark:text-orange-400"
|
||||
>
|
||||
Done
|
||||
</motion.span>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<SummoningProgressSteps generatedFiles={generatedFiles} />
|
||||
|
||||
{status === 'summoning' && (
|
||||
<p className="text-center text-xs text-text-muted tabular-nums">
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
const SUMMONING_FILE_KEYS = [
|
||||
{ name: 'SOUL.md', required: true, labelKey: 'summoning.fileLabelSOUL' },
|
||||
{ name: 'IDENTITY.md', required: true, labelKey: 'summoning.fileLabelIDENTITY' },
|
||||
]
|
||||
|
||||
interface SummoningProgressStepsProps {
|
||||
generatedFiles: string[]
|
||||
}
|
||||
|
||||
export function SummoningProgressSteps({ generatedFiles }: SummoningProgressStepsProps) {
|
||||
const { t } = useTranslation('desktop')
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
<AnimatePresence>
|
||||
{SUMMONING_FILE_KEYS.map((file, i) => {
|
||||
const done = generatedFiles.includes(file.name)
|
||||
return (
|
||||
<motion.div
|
||||
key={file.name}
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-1.5"
|
||||
>
|
||||
<motion.div
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs ${
|
||||
done
|
||||
? 'bg-orange-100 text-orange-600 dark:bg-orange-900/40 dark:text-orange-400'
|
||||
: 'bg-surface-tertiary text-text-muted'
|
||||
}`}
|
||||
animate={done ? { scale: [0.8, 1.2, 1] } : {}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{done ? '✓' : i + 1}
|
||||
</motion.div>
|
||||
<div className="flex-1">
|
||||
<span className={`text-sm ${done ? 'text-text-primary font-medium' : 'text-text-secondary'}`}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-text-muted">{t(`${file.labelKey}`)}</span>
|
||||
</div>
|
||||
{done && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-xs text-orange-600 dark:text-orange-400"
|
||||
>
|
||||
Done
|
||||
</motion.span>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+47
-56
@@ -1,15 +1,17 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Combobox } from '../../common/Combobox'
|
||||
import { Switch } from '../../common/Switch'
|
||||
import { PROVIDER_TYPES } from '../../../constants/providers'
|
||||
import { slugify } from '../../../lib/slug'
|
||||
import type { ProviderData, ProviderInput } from '../../../types/provider'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
import { Switch } from '../common/Switch'
|
||||
import { PROVIDER_TYPES } from '../../constants/providers'
|
||||
import { slugify } from '../../lib/slug'
|
||||
import { providerFormSchema, type ProviderFormData } from '../../schemas/provider.schema'
|
||||
import type { ProviderData, ProviderInput } from '../../types/provider'
|
||||
|
||||
interface ProviderFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** If set, editing this provider. Otherwise creating. */
|
||||
provider?: ProviderData | null
|
||||
onSubmit: (input: ProviderInput) => Promise<void>
|
||||
}
|
||||
@@ -18,59 +20,52 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
|
||||
const isEditing = !!provider
|
||||
const { t } = useTranslation(['providers', 'common'])
|
||||
|
||||
const [providerType, setProviderType] = useState(provider?.provider_type ?? '')
|
||||
const [displayName, setDisplayName] = useState(provider?.display_name ?? '')
|
||||
const [apiBase, setApiBase] = useState(provider?.api_base ?? '')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [enabled, setEnabled] = useState(provider?.enabled ?? true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const { register, handleSubmit, watch, setValue, reset, formState: { errors, isSubmitting } } = useForm<ProviderFormData>({
|
||||
resolver: zodResolver(providerFormSchema),
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
// Reset form when dialog opens/closes or provider changes
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setProviderType(provider?.provider_type ?? '')
|
||||
setDisplayName(provider?.display_name ?? '')
|
||||
setApiBase(provider?.api_base ?? '')
|
||||
setApiKey('')
|
||||
setEnabled(provider?.enabled ?? true)
|
||||
setError('')
|
||||
reset({
|
||||
providerType: provider?.provider_type ?? '',
|
||||
displayName: provider?.display_name ?? '',
|
||||
apiBase: provider?.api_base ?? '',
|
||||
apiKey: '',
|
||||
enabled: provider?.enabled ?? true,
|
||||
})
|
||||
}
|
||||
}, [open, provider])
|
||||
}, [open, provider, reset])
|
||||
|
||||
const providerType = watch('providerType')
|
||||
const displayName = watch('displayName')
|
||||
const apiBase = watch('apiBase')
|
||||
|
||||
// Auto-set api_base from provider type
|
||||
const typeInfo = useMemo(() => PROVIDER_TYPES.find((t) => t.value === providerType), [providerType])
|
||||
useEffect(() => {
|
||||
if (!isEditing && typeInfo?.apiBase && !apiBase) {
|
||||
setApiBase(typeInfo.apiBase)
|
||||
setValue('apiBase', typeInfo.apiBase)
|
||||
}
|
||||
}, [typeInfo, isEditing, apiBase])
|
||||
}, [typeInfo, isEditing, apiBase, setValue])
|
||||
|
||||
const name = useMemo(() => {
|
||||
if (isEditing) return provider!.name
|
||||
return slugify(displayName || providerType) || 'provider'
|
||||
}, [isEditing, provider, displayName, providerType])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const input: ProviderInput = {
|
||||
name,
|
||||
display_name: displayName || undefined,
|
||||
provider_type: providerType,
|
||||
api_base: apiBase || undefined,
|
||||
enabled,
|
||||
}
|
||||
// Only send api_key if user entered a new one
|
||||
if (apiKey) input.api_key = apiKey
|
||||
await onSubmit(input)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save provider')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
const onValid = async (data: ProviderFormData) => {
|
||||
const input: ProviderInput = {
|
||||
name,
|
||||
display_name: data.displayName || undefined,
|
||||
provider_type: data.providerType,
|
||||
api_base: data.apiBase || undefined,
|
||||
enabled: data.enabled,
|
||||
}
|
||||
if (data.apiKey) input.api_key = data.apiKey
|
||||
await onSubmit(input)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
@@ -89,19 +84,19 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
|
||||
<label className="text-xs font-medium text-text-secondary">{t('providers:form.providerType').replace(' *', '')}</label>
|
||||
<Combobox
|
||||
value={providerType}
|
||||
onChange={setProviderType}
|
||||
onChange={(v) => setValue('providerType', v, { shouldValidate: true })}
|
||||
options={typeOptions}
|
||||
placeholder={t('common:selectProvider')}
|
||||
disabled={isEditing}
|
||||
/>
|
||||
{errors.providerType && <p className="text-xs text-error">{errors.providerType.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Display name */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('providers:form.displayName')}</label>
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
{...register('displayName')}
|
||||
placeholder={typeInfo?.label ?? 'My Provider'}
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
@@ -111,8 +106,7 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('providers:form.apiBase')}</label>
|
||||
<input
|
||||
value={apiBase}
|
||||
onChange={(e) => setApiBase(e.target.value)}
|
||||
{...register('apiBase')}
|
||||
placeholder="https://api.example.com"
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
@@ -125,8 +119,7 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
{...register('apiKey')}
|
||||
placeholder={isEditing ? '••••••••' : 'sk-...'}
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
@@ -134,12 +127,10 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
|
||||
|
||||
{/* Enabled toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
<Switch checked={watch('enabled')} onCheckedChange={(v) => setValue('enabled', v)} />
|
||||
<span className="text-xs text-text-secondary">{t('providers:form.enabled')}</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-error">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -148,11 +139,11 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
|
||||
{t('providers:form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !providerType}
|
||||
onClick={handleSubmit(onValid)}
|
||||
disabled={isSubmitting || !providerType}
|
||||
className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? '...' : isEditing ? t('providers:form.save') : t('providers:form.create')}
|
||||
{isSubmitting ? '...' : isEditing ? t('providers:form.save') : t('providers:form.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProviders } from '../../../hooks/use-providers'
|
||||
import { useProviders } from '../../hooks/use-providers'
|
||||
import { ProviderRow } from './ProviderRow'
|
||||
import { ProviderFormDialog } from './ProviderFormDialog'
|
||||
import { ConfirmDialog } from '../../common/ConfirmDialog'
|
||||
import type { ProviderData, ProviderInput } from '../../../types/provider'
|
||||
import { ConfirmDialog } from '../common/ConfirmDialog'
|
||||
import type { ProviderData, ProviderInput } from '../../types/provider'
|
||||
|
||||
export function ProviderList() {
|
||||
const { t } = useTranslation(['providers', 'common'])
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import type { ProviderData } from '../../../types/provider'
|
||||
import { PROVIDER_TYPES } from '../../../constants/providers'
|
||||
import type { ProviderData } from '../../types/provider'
|
||||
import { PROVIDER_TYPES } from '../../constants/providers'
|
||||
|
||||
interface ProviderRowProps {
|
||||
provider: ProviderData
|
||||
@@ -2,15 +2,15 @@ import { useUiStore } from '../../stores/ui-store'
|
||||
import { SettingsTabBar } from './SettingsTabBar'
|
||||
import { AppearanceTab } from './AppearanceTab'
|
||||
import { AboutTab } from './AboutTab'
|
||||
import { ProviderList } from './providers/ProviderList'
|
||||
import { AgentList } from './agents/AgentList'
|
||||
import { McpServerList } from './mcp/McpServerList'
|
||||
import { SkillList } from './skills/SkillList'
|
||||
import { ToolList } from './tools/ToolList'
|
||||
import { CronList } from './cron/CronList'
|
||||
import { TraceList } from './traces/TraceList'
|
||||
import { StorageTab } from './storage/StorageTab'
|
||||
import { ChannelList } from './channels/ChannelList'
|
||||
import { ProviderList } from '../providers/ProviderList'
|
||||
import { AgentList } from '../agents/AgentList'
|
||||
import { McpServerList } from '../mcp/McpServerList'
|
||||
import { SkillList } from '../skills/SkillList'
|
||||
import { ToolList } from '../tools/ToolList'
|
||||
import { CronList } from '../cron/CronList'
|
||||
import { TraceList } from '../traces/TraceList'
|
||||
import { StorageTab } from '../storage/StorageTab'
|
||||
import { ChannelList } from '../channels/ChannelList'
|
||||
|
||||
export function SettingsView() {
|
||||
const settingsTab = useUiStore((s) => s.settingsTab)
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Combobox } from '../../common/Combobox'
|
||||
import { useProviders } from '../../../hooks/use-providers'
|
||||
import { getApiClient } from '../../../lib/api'
|
||||
import { Switch } from '../../common/Switch'
|
||||
import { slugify } from '../../../lib/slug'
|
||||
import type { AgentData, AgentInput } from '../../../types/agent'
|
||||
|
||||
// Preset keys match agents.json presets (foxSpirit, artisan, astrologer)
|
||||
// plus desktop-only extras (researcher, writer, coder) in desktop.json
|
||||
const PRESET_KEYS = [
|
||||
{ key: 'foxSpirit', emoji: '🦊', ns: 'agents', agentKey: 'little-fox' },
|
||||
{ key: 'artisan', emoji: '🎨', ns: 'agents', agentKey: 'artisan' },
|
||||
{ key: 'astrologer', emoji: '🔮', ns: 'agents', agentKey: 'mimi' },
|
||||
{ key: 'researcher', emoji: '🔬', ns: 'desktop', agentKey: 'scholar' },
|
||||
{ key: 'writer', emoji: '✍️', ns: 'desktop', agentKey: 'quill' },
|
||||
{ key: 'coder', emoji: '👨💻', ns: 'desktop', agentKey: 'dev' },
|
||||
]
|
||||
|
||||
interface AgentFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
agent?: AgentData | null
|
||||
onSubmit: (input: AgentInput) => Promise<AgentData | void>
|
||||
}
|
||||
|
||||
export function AgentFormDialog({ open, onOpenChange, agent, onSubmit }: AgentFormDialogProps) {
|
||||
const isEditing = !!agent
|
||||
const { t } = useTranslation(['agents', 'desktop', 'common'])
|
||||
const { providers } = useProviders()
|
||||
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [providerName, setProviderName] = useState('')
|
||||
const [model, setModel] = useState('')
|
||||
const agentType = 'predefined' as const
|
||||
const [description, setDescription] = useState('')
|
||||
const [emoji, setEmoji] = useState('🦊')
|
||||
const [isDefault, setIsDefault] = useState(false)
|
||||
const [models, setModels] = useState<string[]>([])
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [verifyResult, setVerifyResult] = useState<{ valid: boolean; error?: string } | null>(null)
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [selectedPresetKey, setSelectedPresetKey] = useState('')
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setDisplayName(agent?.display_name ?? '')
|
||||
setProviderName(agent?.provider ?? '')
|
||||
setModel(agent?.model ?? '')
|
||||
setDescription((agent?.other_config?.description as string) ?? '')
|
||||
setEmoji((agent?.other_config?.emoji as string) ?? '🦊')
|
||||
setIsDefault(agent?.is_default ?? false)
|
||||
setError('')
|
||||
setSelectedPresetKey('')
|
||||
setAgentKeyOverride('')
|
||||
setVerifyResult(isEditing ? { valid: true } : null) // editing = already verified
|
||||
setModels([])
|
||||
}, [open, agent, isEditing])
|
||||
|
||||
// Selected provider object
|
||||
const selectedProvider = useMemo(
|
||||
() => providers.find((p) => p.name === providerName),
|
||||
[providers, providerName],
|
||||
)
|
||||
|
||||
// Load models when provider changes
|
||||
const loadModels = useCallback(async (providerId: string) => {
|
||||
setModelsLoading(true)
|
||||
try {
|
||||
const res = await getApiClient().get<{ models: Array<{ id: string }> }>(
|
||||
`/v1/providers/${providerId}/models`,
|
||||
)
|
||||
setModels((res.models ?? []).map((m) => m.id))
|
||||
} catch {
|
||||
setModels([])
|
||||
} finally {
|
||||
setModelsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProvider?.id) loadModels(selectedProvider.id)
|
||||
}, [selectedProvider?.id, loadModels])
|
||||
|
||||
// Reset verify when provider or model changes
|
||||
useEffect(() => {
|
||||
if (!isEditing) setVerifyResult(null)
|
||||
}, [providerName, model, isEditing])
|
||||
|
||||
const [agentKeyOverride, setAgentKeyOverride] = useState('')
|
||||
const agentKey = useMemo(() => {
|
||||
if (isEditing) return agent!.agent_key
|
||||
if (agentKeyOverride) return agentKeyOverride
|
||||
// Use preset agentKey if selected, otherwise slugify display name
|
||||
return selectedPresetKey || slugify(displayName) || 'agent'
|
||||
}, [isEditing, agent, agentKeyOverride, selectedPresetKey, displayName])
|
||||
|
||||
const providerOptions = useMemo(
|
||||
() => providers.filter((p) => p.enabled).map((p) => ({
|
||||
value: p.name,
|
||||
label: p.display_name || p.name,
|
||||
})),
|
||||
[providers],
|
||||
)
|
||||
|
||||
const modelOptions = useMemo(
|
||||
() => models.map((m) => ({ value: m, label: m })),
|
||||
[models],
|
||||
)
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!selectedProvider?.id || !model.trim()) return
|
||||
setVerifying(true)
|
||||
try {
|
||||
const res = await getApiClient().post<{ valid: boolean; error?: string }>(
|
||||
`/v1/providers/${selectedProvider.id}/verify`,
|
||||
{ model: model.trim() },
|
||||
)
|
||||
setVerifyResult({ valid: res.valid, error: res.error })
|
||||
} catch (err) {
|
||||
setVerifyResult({ valid: false, error: err instanceof Error ? err.message : 'Verification failed' })
|
||||
} finally {
|
||||
setVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const otherConfig: Record<string, unknown> = {}
|
||||
if (description.trim()) otherConfig.description = description.trim()
|
||||
if (emoji.trim()) otherConfig.emoji = emoji.trim()
|
||||
await onSubmit({
|
||||
agent_key: agentKey,
|
||||
display_name: displayName.trim() || undefined,
|
||||
provider: providerName,
|
||||
model: model.trim(),
|
||||
agent_type: isEditing ? agent!.agent_type : agentType,
|
||||
is_default: isDefault || undefined,
|
||||
other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined,
|
||||
})
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save agent')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const canCreate = isEditing
|
||||
? !!providerName && !!model.trim()
|
||||
: !!displayName.trim() && !!providerName && !!model.trim() && verifyResult?.valid && !!description.trim()
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-surface-secondary border border-border rounded-xl shadow-xl max-w-3xl w-full mx-4 overflow-hidden flex flex-col" style={{ maxHeight: '85vh' }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4 shrink-0">
|
||||
<h3 className="text-sm font-semibold text-text-primary">
|
||||
{isEditing ? t('agents:detail.tabs.agent') + ' — ' + t('common:edit') : t('agents:create.title')}
|
||||
</h3>
|
||||
<button onClick={() => onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body — 2 columns */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-5">
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
{/* Left column — identity & model */}
|
||||
<div className="space-y-4">
|
||||
{/* Display name + emoji */}
|
||||
<div className="flex gap-2">
|
||||
<div className="space-y-1 flex-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('agents:create.displayName').replace(' *', '')}</label>
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder={t('agents:create.displayNamePlaceholder')}
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 w-16">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('agents:identity.emoji')}</label>
|
||||
<input
|
||||
value={emoji}
|
||||
onChange={(e) => setEmoji(e.target.value.slice(0, 2))}
|
||||
maxLength={2}
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary text-center focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent key */}
|
||||
{!isEditing && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">Agent Key</label>
|
||||
<input
|
||||
value={agentKey}
|
||||
onChange={(e) => setAgentKeyOverride(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'))}
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary font-mono focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('common:provider')}</label>
|
||||
<Combobox value={providerName} onChange={setProviderName} options={providerOptions} placeholder={t('agents:create.selectProvider')} />
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('common:model')}</label>
|
||||
<Combobox
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
options={modelOptions}
|
||||
placeholder={modelsLoading ? t('agents:create.loadingModels') : t('agents:create.enterOrSelectModel')}
|
||||
allowCustom
|
||||
/>
|
||||
{verifyResult && !verifyResult.valid && (
|
||||
<p className="text-xs text-error">{verifyResult.error || t('desktop:agent.verifyFailed')}</p>
|
||||
)}
|
||||
{verifyResult?.valid && !isEditing && (
|
||||
<p className="text-xs text-success">{t('desktop:agent.verified')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Default toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={isDefault} onCheckedChange={setIsDefault} />
|
||||
<span className="text-xs text-text-secondary">{t('agents:identity.defaultAgent')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column — personality */}
|
||||
<div className="space-y-1.5 flex flex-col">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('agents:detail.personality')}</label>
|
||||
{!isEditing && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PRESET_KEYS.map((p) => {
|
||||
const label = t(`${p.ns}:presets.${p.key}.label`)
|
||||
const prompt = t(`${p.ns}:presets.${p.key}.prompt`)
|
||||
// Extract display name from prompt (first line: "Name: Xxx.")
|
||||
const nameMatch = prompt.match(/^Name:\s*([^.]+)/)
|
||||
const displayName = nameMatch ? nameMatch[1].trim() : label.replace(/^\S+\s*/, '')
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
onClick={() => { setDescription(prompt); setEmoji(p.emoji); setDisplayName(displayName); setSelectedPresetKey(p.agentKey); setAgentKeyOverride('') }}
|
||||
className={`rounded-full border px-2.5 py-1 text-[11px] transition-colors ${
|
||||
description === prompt
|
||||
? 'border-accent bg-accent/10 text-accent font-medium'
|
||||
: 'border-border text-text-secondary hover:bg-surface-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t('agents:create.descriptionPlaceholder')}
|
||||
className="flex-1 min-h-[200px] w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent resize-y"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="px-5"><p className="text-xs text-error">{error}</p></div>}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-border px-5 py-4 shrink-0">
|
||||
{/* Left — error */}
|
||||
<div>
|
||||
{verifyResult && !verifyResult.valid && (
|
||||
<span className="text-[11px] text-error">{verifyResult.error}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right — cancel + verify + summon/save */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => onOpenChange(false)} className="px-3 py-1.5 text-xs border border-border rounded-lg text-text-secondary hover:bg-surface-tertiary transition-colors">
|
||||
{t('agents:create.cancel')}
|
||||
</button>
|
||||
{!isEditing && selectedProvider?.id && model.trim() && !verifyResult?.valid && (
|
||||
<button
|
||||
onClick={handleVerify}
|
||||
disabled={verifying}
|
||||
className="border border-border rounded-lg px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-tertiary transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
{verifying ? (
|
||||
<>
|
||||
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||
{t('desktop:agent.verifying')}
|
||||
</>
|
||||
) : t('desktop:agent.verifyModel')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!canCreate || loading}
|
||||
className="px-4 py-1.5 text-xs bg-accent text-white rounded-lg font-medium hover:bg-accent-hover transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
{loading ? '...' : isEditing ? t('common:save') : (
|
||||
<>
|
||||
{verifyResult?.valid && (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
||||
)}
|
||||
{t('desktop:agent.summon')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCron } from '../../../hooks/use-cron'
|
||||
import { useAgentCrud } from '../../../hooks/use-agent-crud'
|
||||
import { RefreshButton } from '../../common/RefreshButton'
|
||||
import { Switch } from '../../common/Switch'
|
||||
import { ConfirmDialog } from '../../common/ConfirmDialog'
|
||||
import { CronFormDialog } from './CronFormDialog'
|
||||
import { CronRunsDialog } from './CronRunsDialog'
|
||||
import type { CronJob, CronSchedule } from '../../../types/cron'
|
||||
|
||||
function formatSchedule(s: CronSchedule): string {
|
||||
if (s.kind === 'every' && s.everyMs) {
|
||||
const sec = s.everyMs / 1000
|
||||
if (sec < 60) return `every ${sec}s`
|
||||
if (sec < 3600) return `every ${Math.round(sec / 60)}m`
|
||||
return `every ${Math.round(sec / 3600)}h`
|
||||
}
|
||||
if (s.kind === 'cron' && s.expr) return s.expr
|
||||
if (s.kind === 'at' && s.atMs) return `once at ${new Date(s.atMs).toLocaleString()}`
|
||||
return '—'
|
||||
}
|
||||
|
||||
function statusBadgeClass(status?: string): string {
|
||||
if (!status) return 'bg-surface-tertiary text-text-secondary border border-border'
|
||||
if (status === 'ok' || status === 'success') {
|
||||
return 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
|
||||
}
|
||||
if (status === 'error' || status === 'failed') {
|
||||
return 'bg-red-500/15 text-red-700 border border-red-500/25 dark:text-red-400 dark:bg-red-500/10 dark:border-red-500/20'
|
||||
}
|
||||
if (status === 'running') {
|
||||
return 'bg-blue-500/15 text-blue-700 border border-blue-500/25 dark:text-blue-400 dark:bg-blue-500/10 dark:border-blue-500/20 animate-pulse'
|
||||
}
|
||||
return 'bg-amber-500/15 text-amber-700 border border-amber-500/25 dark:text-amber-400 dark:bg-amber-500/10 dark:border-amber-500/20'
|
||||
}
|
||||
|
||||
export function CronList() {
|
||||
const { t } = useTranslation('cron')
|
||||
const { jobs, loading, fetchJobs, createJob, deleteJob, toggleJob, runJob, fetchRuns } = useCron()
|
||||
const { agents } = useAgentCrud()
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<CronJob | null>(null)
|
||||
const [toggleTarget, setToggleTarget] = useState<CronJob | null>(null)
|
||||
const [runsTarget, setRunsTarget] = useState<CronJob | null>(null)
|
||||
const [runningIds, setRunningIds] = useState<Set<string>>(new Set())
|
||||
|
||||
function agentName(agentId: string): string {
|
||||
if (!agentId) return t('defaultAgent')
|
||||
return agents.find((a) => a.id === agentId)?.display_name
|
||||
|| agents.find((a) => a.id === agentId)?.agent_key
|
||||
|| agentId
|
||||
}
|
||||
|
||||
async function handleRunNow(job: CronJob) {
|
||||
setRunningIds((prev) => new Set(prev).add(job.id))
|
||||
try {
|
||||
await runJob(job.id)
|
||||
} finally {
|
||||
setRunningIds((prev) => { const s = new Set(prev); s.delete(job.id); return s })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleConfirm() {
|
||||
if (!toggleTarget) return
|
||||
await toggleJob(toggleTarget.id)
|
||||
setToggleTarget(null)
|
||||
}
|
||||
|
||||
async function handleDeleteConfirm() {
|
||||
if (!deleteTarget) return
|
||||
await deleteJob(deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary">{t('title')}</h2>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t('description')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setFormOpen(true)}
|
||||
className="bg-accent text-white rounded-lg px-3 py-1.5 text-xs hover:bg-accent-hover transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14" /><path d="M12 5v14" />
|
||||
</svg>
|
||||
{t('newJob')}
|
||||
</button>
|
||||
<RefreshButton onRefresh={fetchJobs} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-12 rounded-lg bg-surface-tertiary/50 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
/* Empty state */
|
||||
<div className="flex flex-col items-center gap-2 py-12">
|
||||
<svg className="h-10 w-10 text-text-muted/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<p className="text-sm text-text-muted">{t('emptyTitle')}</p>
|
||||
<p className="text-xs text-text-muted/70">{t('emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Table */
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full min-w-[600px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-tertiary/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.name')}</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.schedule')}</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.agent')}</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.enabled')}</th>
|
||||
<th className="px-4 py-2.5 text-right text-xs font-medium text-text-muted">{t('columns.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job) => (
|
||||
<tr key={job.id} className="border-b border-border last:border-0 hover:bg-surface-tertiary/30 transition-colors [&>td]:align-middle">
|
||||
{/* Name */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<div className="min-w-0">
|
||||
<span className="font-mono text-sm text-text-primary">{job.name}</span>
|
||||
{job.state?.lastStatus && (
|
||||
<span className={`ml-2 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${statusBadgeClass(job.state.lastStatus)}`}>
|
||||
{job.state.lastStatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{/* Schedule */}
|
||||
<td className="px-4 py-3 text-xs text-text-muted font-mono">
|
||||
{formatSchedule(job.schedule)}
|
||||
</td>
|
||||
{/* Agent */}
|
||||
<td className="px-4 py-3 text-xs text-text-muted">
|
||||
{agentName(job.agentId)}
|
||||
</td>
|
||||
{/* Enabled badge */}
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
|
||||
job.enabled
|
||||
? 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
|
||||
: 'bg-surface-tertiary text-text-secondary border border-border'
|
||||
}`}>
|
||||
{job.enabled ? t('detail.enabled') : t('detail.disabled')}
|
||||
</span>
|
||||
</td>
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{/* Run history */}
|
||||
<button
|
||||
onClick={() => setRunsTarget(job)}
|
||||
className="p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
title={t('runHistory')}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" /><path d="M12 7v5l4 2" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Run now */}
|
||||
<button
|
||||
onClick={() => handleRunNow(job)}
|
||||
disabled={runningIds.has(job.id)}
|
||||
className="p-1 text-text-muted hover:text-accent transition-colors disabled:opacity-50"
|
||||
title={t('runNow')}
|
||||
>
|
||||
{runningIds.has(job.id) ? (
|
||||
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="6 3 20 12 6 21 6 3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{/* Toggle */}
|
||||
<Switch
|
||||
checked={job.enabled}
|
||||
onCheckedChange={() => setToggleTarget(job)}
|
||||
/>
|
||||
{/* Delete */}
|
||||
<button
|
||||
onClick={() => setDeleteTarget(job)}
|
||||
className="p-1 text-text-muted hover:text-error transition-colors"
|
||||
title={t('delete.title')}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create dialog */}
|
||||
<CronFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
onSubmit={async (params) => { await createJob(params) }}
|
||||
/>
|
||||
|
||||
{/* Toggle confirm */}
|
||||
{toggleTarget && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
onOpenChange={() => setToggleTarget(null)}
|
||||
title={toggleTarget.enabled ? t('disable.title') : t('enable.title')}
|
||||
description={toggleTarget.enabled
|
||||
? t('disable.description', { name: toggleTarget.name })
|
||||
: t('enable.description', { name: toggleTarget.name })
|
||||
}
|
||||
confirmLabel={toggleTarget.enabled ? t('disable.confirmLabel') : t('enable.confirmLabel')}
|
||||
onConfirm={handleToggleConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirm */}
|
||||
{deleteTarget && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
onOpenChange={() => setDeleteTarget(null)}
|
||||
title={t('delete.title')}
|
||||
description={t('delete.description', { name: deleteTarget.name })}
|
||||
confirmLabel={t('delete.confirmLabel')}
|
||||
variant="destructive"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Run history */}
|
||||
{runsTarget && (
|
||||
<CronRunsDialog
|
||||
open
|
||||
onOpenChange={() => setRunsTarget(null)}
|
||||
job={runsTarget}
|
||||
onFetchRuns={fetchRuns}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Switch } from '../../common/Switch'
|
||||
import { KeyValueEditor } from '../../common/KeyValueEditor'
|
||||
import type { MCPServerData, MCPServerInput, MCPTestResult } from '../../../types/mcp'
|
||||
|
||||
const SENSITIVE_HEADER_KEYS = /(auth|api-key|api_key|bearer|token|secret|password|credential)/i
|
||||
const SENSITIVE_ENV_KEYS = /(key|secret|token|password|credential)/i
|
||||
|
||||
const TRANSPORTS = ['stdio', 'sse', 'streamable-http'] as const
|
||||
|
||||
interface McpFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
server?: MCPServerData | null
|
||||
onSubmit: (data: MCPServerInput) => Promise<unknown>
|
||||
onTest: (data: { transport: string; command?: string; args?: string[]; url?: string; headers?: Record<string, string>; env?: Record<string, string> }) => Promise<MCPTestResult>
|
||||
}
|
||||
|
||||
function slugify(v: string): string {
|
||||
return v.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-/, '')
|
||||
}
|
||||
|
||||
export function McpFormDialog({ open, onOpenChange, server, onSubmit, onTest }: McpFormDialogProps) {
|
||||
const { t } = useTranslation(['mcp', 'common'])
|
||||
const isEdit = Boolean(server)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [transport, setTransport] = useState<string>('stdio')
|
||||
const [command, setCommand] = useState('')
|
||||
const [args, setArgs] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [headers, setHeaders] = useState<Record<string, string>>({})
|
||||
const [env, setEnv] = useState<Record<string, string>>({})
|
||||
const [toolPrefix, setToolPrefix] = useState('')
|
||||
const [timeoutSec, setTimeoutSec] = useState(30)
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [submitError, setSubmitError] = useState('')
|
||||
const [testState, setTestState] = useState<'idle' | 'testing' | 'success' | 'error'>('idle')
|
||||
const [testResult, setTestResult] = useState<MCPTestResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (server) {
|
||||
setName(server.name)
|
||||
setDisplayName(server.display_name || '')
|
||||
setTransport(server.transport)
|
||||
setCommand(server.command || '')
|
||||
setArgs(Array.isArray(server.args) ? server.args.join(' ') : '')
|
||||
setUrl(server.url || '')
|
||||
setHeaders(server.headers ?? {})
|
||||
setEnv(server.env ?? {})
|
||||
setToolPrefix(server.tool_prefix?.replace(/^mcp_/, '') ?? '')
|
||||
setTimeoutSec(server.timeout_sec || 30)
|
||||
setEnabled(server.enabled)
|
||||
} else {
|
||||
setName('')
|
||||
setDisplayName('')
|
||||
setTransport('stdio')
|
||||
setCommand('')
|
||||
setArgs('')
|
||||
setUrl('')
|
||||
setHeaders({})
|
||||
setEnv({})
|
||||
setToolPrefix('')
|
||||
setTimeoutSec(30)
|
||||
setEnabled(true)
|
||||
}
|
||||
setTestState('idle')
|
||||
setTestResult(null)
|
||||
setSaving(false)
|
||||
setSubmitError('')
|
||||
}, [open, server])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
function buildInput(): MCPServerInput {
|
||||
const input: MCPServerInput = {
|
||||
name,
|
||||
display_name: displayName || undefined,
|
||||
transport,
|
||||
timeout_sec: timeoutSec,
|
||||
enabled,
|
||||
}
|
||||
if (transport === 'stdio') {
|
||||
input.command = command
|
||||
input.args = args.trim() ? args.trim().split(/\s+/) : undefined
|
||||
} else {
|
||||
input.url = url
|
||||
if (Object.keys(headers).length > 0) input.headers = headers
|
||||
}
|
||||
if (Object.keys(env).length > 0) input.env = env
|
||||
if (toolPrefix.trim()) input.tool_prefix = toolPrefix.trim()
|
||||
return input
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSaving(true)
|
||||
setSubmitError('')
|
||||
try {
|
||||
await onSubmit(buildInput())
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setSubmitError((err as Error).message || 'Failed to save')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
setTestState('testing')
|
||||
setTestResult(null)
|
||||
try {
|
||||
const data: Parameters<typeof onTest>[0] = { transport }
|
||||
if (transport === 'stdio') {
|
||||
data.command = command
|
||||
if (args.trim()) data.args = args.trim().split(/\s+/)
|
||||
} else {
|
||||
data.url = url
|
||||
if (Object.keys(headers).length > 0) data.headers = headers
|
||||
}
|
||||
if (Object.keys(env).length > 0) data.env = env
|
||||
const result = await onTest(data)
|
||||
setTestResult(result)
|
||||
setTestState(result.success ? 'success' : 'error')
|
||||
} catch (err) {
|
||||
setTestResult({ success: false, error: (err as Error).message })
|
||||
setTestState('error')
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = name.trim() && (transport === 'stdio' ? command.trim() : url.trim()) && !saving
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => onOpenChange(false)} />
|
||||
<div className="relative w-full max-w-lg bg-surface-secondary rounded-xl border border-border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<span className="text-sm font-semibold text-text-primary">{isEdit ? t('form.editTitle') : t('form.createTitle')}</span>
|
||||
<button onClick={() => onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="max-h-[70vh] overflow-y-auto p-5 space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.name')}</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(slugify(e.target.value))}
|
||||
disabled={isEdit}
|
||||
placeholder="my-mcp-server"
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent disabled:opacity-50"
|
||||
/>
|
||||
<p className="text-[11px] text-text-muted">{t('form.nameHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Display Name */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.displayName')}</label>
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder={t('form.displayNamePlaceholder')}
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Transport */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.transport')}</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{TRANSPORTS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTransport(t)}
|
||||
className={`border rounded-lg px-3 py-2 text-xs text-center transition-colors ${
|
||||
transport === t
|
||||
? 'border-accent bg-accent/10 text-accent font-medium'
|
||||
: 'border-border text-text-secondary hover:bg-surface-tertiary/30'
|
||||
}`}
|
||||
>
|
||||
{t.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stdio fields */}
|
||||
{transport === 'stdio' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.command')}</label>
|
||||
<input
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder="npx"
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.args')}</label>
|
||||
<input
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
placeholder={t('form.argsPlaceholder')}
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* SSE/HTTP fields */}
|
||||
{transport !== 'stdio' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.url')}</label>
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="http://localhost:3001/sse"
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.headers')}</label>
|
||||
<KeyValueEditor
|
||||
value={headers}
|
||||
onChange={setHeaders}
|
||||
sensitivePattern={SENSITIVE_HEADER_KEYS}
|
||||
placeholder={{ key: t('form.headerKeyPlaceholder'), value: t('form.headerValuePlaceholder') }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Env Variables */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.env')}</label>
|
||||
<KeyValueEditor
|
||||
value={env}
|
||||
onChange={setEnv}
|
||||
sensitivePattern={SENSITIVE_ENV_KEYS}
|
||||
placeholder={{ key: t('form.envKeyPlaceholder'), value: t('form.envValuePlaceholder') }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tool Prefix */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.toolPrefix')}</label>
|
||||
<div className="flex items-stretch">
|
||||
<span className="inline-flex items-center bg-surface-tertiary/70 border border-r-0 border-border rounded-l-lg px-2.5 text-base md:text-sm text-text-muted select-none">mcp_</span>
|
||||
<input
|
||||
value={toolPrefix}
|
||||
onChange={(e) => setToolPrefix(e.target.value.replace(/[^a-z0-9_]/gi, '_').toLowerCase())}
|
||||
placeholder={name.replace(/-/g, '_') || 'auto'}
|
||||
className="flex-1 min-w-0 bg-surface-tertiary border border-border rounded-r-lg px-3 py-2 text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted">{t('form.toolPrefixHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Timeout */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('form.timeout')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={timeoutSec}
|
||||
onChange={(e) => setTimeoutSec(Math.max(1, Number(e.target.value)))}
|
||||
className="w-24 bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Enabled */}
|
||||
<div className="flex items-center justify-between rounded-lg border border-border p-3">
|
||||
<span className="text-xs font-medium text-text-primary">{t('form.enabled')}</span>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{submitError && (
|
||||
<div className="px-5"><p className="text-xs text-error">{submitError}</p></div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-5 py-4 space-y-2">
|
||||
{/* Test result (above buttons so it doesn't break layout) */}
|
||||
{testState === 'success' && testResult && (
|
||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
{t('form.toolsFound', { count: testResult.tool_count })}
|
||||
</p>
|
||||
)}
|
||||
{testState === 'error' && testResult && (
|
||||
<p className="text-[11px] text-error flex items-center gap-1">
|
||||
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
<span className="break-all">{testResult.error || t('form.errors.connectionFailed')}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={saving || testState === 'testing'}
|
||||
className="border border-border rounded-lg px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-tertiary transition-colors disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{testState === 'testing' ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||
{t('form.testing')}
|
||||
</span>
|
||||
) : t('form.testConnection')}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors"
|
||||
>
|
||||
{t('form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? t('form.saving') : isEdit ? t('form.update') : t('form.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMcpServers, MAX_MCP_LITE } from '../../../hooks/use-mcp-servers'
|
||||
import { useAgentCrud } from '../../../hooks/use-agent-crud'
|
||||
import { McpFormDialog } from './McpFormDialog'
|
||||
import { McpGrantsDialog } from './McpGrantsDialog'
|
||||
import { McpToolsDialog } from './McpToolsDialog'
|
||||
import { ConfirmDeleteDialog } from '../../common/ConfirmDeleteDialog'
|
||||
import { RefreshButton } from '../../common/RefreshButton'
|
||||
import type { MCPServerData } from '../../../types/mcp'
|
||||
|
||||
export function McpServerList() {
|
||||
const { t } = useTranslation(['mcp', 'common'])
|
||||
const {
|
||||
servers, loading, atLimit,
|
||||
fetchServers, createServer, updateServer, deleteServer,
|
||||
testConnection, reconnectServer, listServerTools,
|
||||
listGrants, grantAgent, revokeAgent,
|
||||
} = useMcpServers()
|
||||
const { agents } = useAgentCrud()
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [editServer, setEditServer] = useState<MCPServerData | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<MCPServerData | null>(null)
|
||||
const [grantsServer, setGrantsServer] = useState<MCPServerData | null>(null)
|
||||
const [toolsServer, setToolsServer] = useState<MCPServerData | null>(null)
|
||||
const [reconnectingId, setReconnectingId] = useState<string | null>(null)
|
||||
|
||||
function openCreate() {
|
||||
setEditServer(null)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(s: MCPServerData) {
|
||||
setEditServer(s)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary">{t('title')}</h2>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t('description')} (max {MAX_MCP_LITE})</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openCreate}
|
||||
disabled={atLimit}
|
||||
className="bg-accent text-white rounded-lg px-3 py-1.5 text-xs hover:bg-accent-hover disabled:opacity-50 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14" /><path d="M12 5v14" />
|
||||
</svg>
|
||||
{t('addServer')}
|
||||
</button>
|
||||
<RefreshButton onRefresh={fetchServers} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{atLimit && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400">
|
||||
{t('noMatchTitle')} ({MAX_MCP_LITE}). {t('noMatchDescription')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-12 rounded-lg bg-surface-tertiary/50 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
/* Empty state */
|
||||
<div className="flex flex-col items-center gap-2 py-12">
|
||||
<svg className="h-10 w-10 text-text-muted/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22v-5" /><path d="M9 8V2" /><path d="M15 8V2" />
|
||||
<path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z" />
|
||||
</svg>
|
||||
<p className="text-sm text-text-muted">{t('emptyTitle')}</p>
|
||||
<p className="text-xs text-text-muted/70">{t('emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Table */
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-tertiary/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.name')}</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.transport')}</th>
|
||||
<th className="px-4 py-2.5 text-center text-xs font-medium text-text-muted">{t('columns.tools')}</th>
|
||||
<th className="px-4 py-2.5 text-center text-xs font-medium text-text-muted">{t('columns.agents')}</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium text-text-muted">{t('columns.enabled')}</th>
|
||||
<th className="px-4 py-2.5 text-right text-xs font-medium text-text-muted">{t('columns.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{servers.map((s) => (
|
||||
<tr key={s.id} className="border-b border-border last:border-0 hover:bg-surface-tertiary/30 transition-colors [&>td]:align-middle">
|
||||
{/* Name */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22v-5" /><path d="M9 8V2" /><path d="M15 8V2" />
|
||||
<path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z" />
|
||||
</svg>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-text-primary truncate">{s.display_name || s.name}</div>
|
||||
<div className="font-mono text-[11px] text-text-muted bg-surface-tertiary/50 px-1.5 py-0.5 rounded inline-block mt-0.5">
|
||||
mcp_{s.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{/* Transport */}
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
|
||||
s.transport === 'sse'
|
||||
? 'bg-surface-tertiary text-text-secondary'
|
||||
: 'border border-border text-text-muted'
|
||||
}`}>
|
||||
{s.transport.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
{/* Tools */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => setToolsServer(s)}
|
||||
className="p-1 text-text-muted hover:text-text-primary transition-colors"
|
||||
title={t('viewTools')}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
{/* Agents */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => setGrantsServer(s)}
|
||||
className="inline-flex items-center gap-1 text-sm text-text-primary hover:text-accent transition-colors"
|
||||
title={t('manageGrants')}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
{s.agent_count ?? 0}
|
||||
</button>
|
||||
</td>
|
||||
{/* Enabled */}
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
|
||||
s.enabled
|
||||
? 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
|
||||
: 'bg-surface-tertiary text-text-secondary'
|
||||
}`}>
|
||||
{s.enabled ? t('common:enabled') : t('common:disabled')}
|
||||
</span>
|
||||
</td>
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
disabled={reconnectingId === s.id}
|
||||
onClick={async () => {
|
||||
setReconnectingId(s.id)
|
||||
try { await reconnectServer(s.id) } finally { setReconnectingId(null) }
|
||||
}}
|
||||
className="p-1 text-text-muted hover:text-text-primary transition-colors disabled:opacity-50"
|
||||
title={t('reconnect')}
|
||||
>
|
||||
<svg className={`h-3.5 w-3.5${reconnectingId === s.id ? ' animate-spin' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => openEdit(s)} className="p-1 text-text-muted hover:text-text-primary transition-colors" title="Edit">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => setDeleteTarget(s)} className="p-1 text-text-muted hover:text-error transition-colors" title="Delete">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<McpFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
server={editServer}
|
||||
onSubmit={async (data) => {
|
||||
if (editServer) {
|
||||
await updateServer(editServer.id, data)
|
||||
} else {
|
||||
await createServer(data)
|
||||
}
|
||||
}}
|
||||
onTest={testConnection}
|
||||
/>
|
||||
|
||||
{deleteTarget && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={() => setDeleteTarget(null)}
|
||||
title="Delete MCP Server"
|
||||
description={`This will permanently delete the server "${deleteTarget.display_name || deleteTarget.name}" and all its agent grants.`}
|
||||
confirmValue={deleteTarget.name}
|
||||
onConfirm={async () => {
|
||||
await deleteServer(deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{grantsServer && (
|
||||
<McpGrantsDialog
|
||||
open
|
||||
onOpenChange={() => setGrantsServer(null)}
|
||||
server={grantsServer}
|
||||
agents={agents}
|
||||
onLoadGrants={listGrants}
|
||||
onGrant={grantAgent}
|
||||
onRevoke={revokeAgent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toolsServer && (
|
||||
<McpToolsDialog
|
||||
open
|
||||
onOpenChange={() => setToolsServer(null)}
|
||||
server={toolsServer}
|
||||
onLoadTools={listServerTools}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Switch } from '../../common/Switch'
|
||||
import { MediaProviderChainForm } from './MediaProviderChainForm'
|
||||
import type { BuiltinToolData } from '../../../types/builtin-tool'
|
||||
|
||||
const MEDIA_TOOLS = new Set([
|
||||
'read_image', 'read_document', 'read_audio', 'read_video',
|
||||
'create_image', 'create_video', 'create_audio',
|
||||
])
|
||||
|
||||
interface ToolSettingsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
tool: BuiltinToolData
|
||||
onSave: (name: string, settings: Record<string, unknown>) => Promise<void>
|
||||
}
|
||||
|
||||
export function ToolSettingsDialog({ open, onOpenChange, tool, onSave }: ToolSettingsDialogProps) {
|
||||
const { t } = useTranslation(['tools', 'common'])
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => onOpenChange(false)} />
|
||||
<div className="relative w-full max-w-2xl mx-4 bg-surface-secondary rounded-xl border border-border overflow-hidden flex flex-col" style={{ maxHeight: '85vh' }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('builtin.settingsDialog.title', { name: tool.display_name })}</h3>
|
||||
<p className="font-mono text-[11px] text-text-muted mt-0.5">{tool.name}</p>
|
||||
</div>
|
||||
<button onClick={() => onOpenChange(false)} className="p-1 text-text-muted hover:text-text-primary transition-colors">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content — route to specialized form or generic JSON */}
|
||||
{tool.name === 'web_fetch' ? (
|
||||
<ExtractorChainForm tool={tool} onSave={onSave} onClose={() => onOpenChange(false)} />
|
||||
) : MEDIA_TOOLS.has(tool.name) ? (
|
||||
<MediaProviderChainForm tool={tool} onSave={onSave} onClose={() => onOpenChange(false)} />
|
||||
) : (
|
||||
<JsonSettingsForm tool={tool} onSave={onSave} onClose={() => onOpenChange(false)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Extractor Chain Form (web_fetch) ─── */
|
||||
|
||||
interface ExtractorEntry {
|
||||
name: string
|
||||
enabled: boolean
|
||||
base_url?: string
|
||||
timeout?: number
|
||||
max_retries?: number
|
||||
}
|
||||
|
||||
const EXTRACTOR_DISPLAY: Record<string, string> = {
|
||||
defuddle: 'Defuddle',
|
||||
'html-to-markdown': 'HTML to Markdown',
|
||||
}
|
||||
|
||||
function ExtractorChainForm({ tool, onSave, onClose }: { tool: BuiltinToolData; onSave: ToolSettingsDialogProps['onSave']; onClose: () => void }) {
|
||||
const { t } = useTranslation(['tools', 'common'])
|
||||
const [extractors, setExtractors] = useState<ExtractorEntry[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const settings = tool.settings as { extractors?: ExtractorEntry[] }
|
||||
setExtractors(settings?.extractors ?? [])
|
||||
setError('')
|
||||
setSaving(false)
|
||||
}, [tool])
|
||||
|
||||
function updateExtractor(index: number, updates: Partial<ExtractorEntry>) {
|
||||
setExtractors((prev) => prev.map((e, i) => i === index ? { ...e, ...updates } : e))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await onSave(tool.name, { extractors })
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError((err as Error).message || 'Failed to save')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-h-[60vh] overflow-y-auto p-5 space-y-3">
|
||||
{extractors.map((ext, i) => (
|
||||
<div key={ext.name} className="rounded-lg border border-border p-3 space-y-3">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-mono text-text-muted bg-surface-tertiary rounded px-1.5 py-0.5">#{i + 1}</span>
|
||||
<span className="text-sm font-medium text-text-primary">{EXTRACTOR_DISPLAY[ext.name] ?? ext.name}</span>
|
||||
</div>
|
||||
<Switch checked={ext.enabled} onCheckedChange={(v) => updateExtractor(i, { enabled: v })} />
|
||||
</div>
|
||||
|
||||
{/* Defuddle-specific fields */}
|
||||
{ext.name === 'defuddle' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('builtin.extractorChain.baseUrl')}</label>
|
||||
<input
|
||||
value={ext.base_url ?? ''}
|
||||
onChange={(e) => updateExtractor(i, { base_url: e.target.value })}
|
||||
placeholder="https://fetch.goclaw.sh/"
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.retries')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={ext.max_retries ?? 2}
|
||||
onChange={(e) => updateExtractor(i, { max_retries: Math.max(0, Number(e.target.value)) })}
|
||||
className="w-20 bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Timeout (show for defuddle or if already set) */}
|
||||
{(ext.name === 'defuddle' || (ext.timeout && ext.timeout > 0)) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('builtin.extractorChain.timeout')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={600}
|
||||
value={ext.timeout ?? 0}
|
||||
onChange={(e) => updateExtractor(i, { timeout: Math.max(0, Number(e.target.value)) })}
|
||||
className="w-20 bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted">0 = {t('common:default')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{extractors.length === 0 && (
|
||||
<p className="text-xs text-text-muted text-center py-4">{t('builtin.mediaChain.noProviders')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{error && <div className="px-5"><p className="text-xs text-error">{error}</p></div>}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-4">
|
||||
<button type="button" onClick={onClose} className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors">
|
||||
{t('builtin.settingsDialog.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={handleSave} disabled={saving} className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors">
|
||||
{saving ? t('builtin.settingsDialog.saving') : t('builtin.settingsDialog.save')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Generic JSON Settings Form (fallback) ─── */
|
||||
|
||||
function JsonSettingsForm({ tool, onSave, onClose }: { tool: BuiltinToolData; onSave: ToolSettingsDialogProps['onSave']; onClose: () => void }) {
|
||||
const { t } = useTranslation(['tools', 'common'])
|
||||
const [value, setValue] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setValue(JSON.stringify(tool.settings, null, 2))
|
||||
setError('')
|
||||
setSaving(false)
|
||||
}, [tool])
|
||||
|
||||
function validate(json: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
setError(t('builtin.jsonDialog.invalidJson'))
|
||||
return null
|
||||
}
|
||||
setError('')
|
||||
return parsed as Record<string, unknown>
|
||||
} catch {
|
||||
setError(t('builtin.jsonDialog.invalidJson'))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function handleFormat() {
|
||||
const parsed = validate(value)
|
||||
if (parsed) setValue(JSON.stringify(parsed, null, 2))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const parsed = validate(value)
|
||||
if (!parsed) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(tool.name, parsed)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError((err as Error).message || t('builtin.jsonDialog.invalidJson'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-5">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => { setValue(e.target.value); setError('') }}
|
||||
spellCheck={false}
|
||||
className="w-full h-64 bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent resize-y"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-1 min-h-[20px]">
|
||||
{error ? <span className="text-xs text-error">{error}</span> : <span />}
|
||||
<button type="button" onClick={handleFormat} className="text-[11px] text-accent hover:text-accent-hover transition-colors">
|
||||
{t('builtin.jsonDialog.formatJson')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-4">
|
||||
<button type="button" onClick={onClose} className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors">
|
||||
{t('builtin.jsonDialog.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={handleSave} disabled={!!error || saving} className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors">
|
||||
{saving ? t('builtin.jsonDialog.saving') : t('builtin.jsonDialog.save')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+9
-98
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSkills, MAX_SKILLS_LITE } from '../../../hooks/use-skills'
|
||||
import type { RuntimeStatus } from '../../../hooks/use-skills'
|
||||
import { Switch } from '../../common/Switch'
|
||||
import { RefreshButton } from '../../common/RefreshButton'
|
||||
import { ConfirmDeleteDialog } from '../../common/ConfirmDeleteDialog'
|
||||
import type { SkillInfo } from '../../../types/skill'
|
||||
import { useSkills, MAX_SKILLS_LITE } from '../../hooks/use-skills'
|
||||
import type { RuntimeStatus } from '../../hooks/use-skills'
|
||||
import { RefreshButton } from '../common/RefreshButton'
|
||||
import { ConfirmDeleteDialog } from '../common/ConfirmDeleteDialog'
|
||||
import { SkillCard } from './skill-card'
|
||||
import type { SkillInfo } from '../../types/skill'
|
||||
|
||||
export function SkillList() {
|
||||
const { t } = useTranslation(['skills', 'common'])
|
||||
@@ -75,14 +75,9 @@ export function SkillList() {
|
||||
</div>
|
||||
|
||||
{atLimit && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400">
|
||||
{t('deps.runtimeMissing')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<p className="text-xs text-error">{uploadError}</p>
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400">{t('deps.runtimeMissing')}</p>
|
||||
)}
|
||||
{uploadError && <p className="text-xs text-error">{uploadError}</p>}
|
||||
|
||||
{/* Runtime status */}
|
||||
{runtimes && !runtimes.ready && (
|
||||
@@ -101,7 +96,6 @@ export function SkillList() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
@@ -129,7 +123,7 @@ export function SkillList() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{skills.map((skill) => (
|
||||
<SkillRow
|
||||
<SkillCard
|
||||
key={skill.id ?? skill.name}
|
||||
skill={skill}
|
||||
onToggle={toggleSkill}
|
||||
@@ -141,7 +135,6 @@ export function SkillList() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{deleteTarget && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
@@ -158,85 +151,3 @@ export function SkillList() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* --- SkillRow --- */
|
||||
|
||||
function SkillRow({ skill, onToggle, onDelete }: {
|
||||
skill: SkillInfo
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('skills')
|
||||
const isArchived = skill.status === 'archived'
|
||||
const isDisabled = !skill.enabled || isArchived
|
||||
const hasMissing = (skill.missing_deps?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<tr className={`border-b border-border last:border-0 hover:bg-surface-tertiary/30 transition-colors ${isDisabled ? 'opacity-60' : ''}`}>
|
||||
{/* Name */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" />
|
||||
</svg>
|
||||
<span className="font-medium text-text-primary">{skill.name}</span>
|
||||
{skill.is_system && (
|
||||
<span className="rounded-full px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 border border-blue-500/20 dark:text-blue-400 dark:bg-blue-500/5 dark:border-blue-500/15">
|
||||
{t('system')}
|
||||
</span>
|
||||
)}
|
||||
{(skill.version ?? 0) > 0 && (
|
||||
<span className="text-xs text-text-muted">v{skill.version}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Description */}
|
||||
<td className="px-4 py-3 max-w-xs truncate text-text-muted">
|
||||
{skill.description || '—'}
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-medium border ${
|
||||
isArchived
|
||||
? 'bg-amber-500/10 text-amber-600 border-amber-500/20 dark:text-amber-400 dark:bg-amber-500/5 dark:border-amber-500/15'
|
||||
: 'bg-emerald-500/15 text-emerald-700 border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
|
||||
}`}>
|
||||
{isArchived ? t('deps.statusArchived') : t('deps.statusActive')}
|
||||
</span>
|
||||
{hasMissing && (
|
||||
<p className="text-[10px] text-amber-600 dark:text-amber-400 mt-0.5">
|
||||
{skill.missing_deps!.slice(0, 3).join(', ')}
|
||||
{skill.missing_deps!.length > 3 && ` +${skill.missing_deps!.length - 3}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{/* Delete (non-system only) */}
|
||||
{!skill.is_system && skill.id && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1 text-text-muted hover:text-error transition-colors"
|
||||
title={t('delete.title')}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<Switch
|
||||
checked={skill.enabled !== false}
|
||||
onCheckedChange={(v) => skill.id && onToggle(skill.id, v)}
|
||||
disabled={!skill.id}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Skill table row with status badge, toggle switch, and delete action.
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Switch } from '../common/Switch'
|
||||
import type { SkillInfo } from '../../types/skill'
|
||||
|
||||
interface SkillCardProps {
|
||||
skill: SkillInfo
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export function SkillCard({ skill, onToggle, onDelete }: SkillCardProps) {
|
||||
const { t } = useTranslation('skills')
|
||||
const isArchived = skill.status === 'archived'
|
||||
const isDisabled = !skill.enabled || isArchived
|
||||
const hasMissing = (skill.missing_deps?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<tr className={`border-b border-border last:border-0 hover:bg-surface-tertiary/30 transition-colors ${isDisabled ? 'opacity-60' : ''}`}>
|
||||
{/* Name */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" />
|
||||
</svg>
|
||||
<span className="font-medium text-text-primary">{skill.name}</span>
|
||||
{skill.is_system && (
|
||||
<span className="rounded-full px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 border border-blue-500/20 dark:text-blue-400 dark:bg-blue-500/5 dark:border-blue-500/15">
|
||||
{t('system')}
|
||||
</span>
|
||||
)}
|
||||
{(skill.version ?? 0) > 0 && (
|
||||
<span className="text-xs text-text-muted">v{skill.version}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Description */}
|
||||
<td className="px-4 py-3 max-w-xs truncate text-text-muted">
|
||||
{skill.description || '—'}
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-medium border ${
|
||||
isArchived
|
||||
? 'bg-amber-500/10 text-amber-600 border-amber-500/20 dark:text-amber-400 dark:bg-amber-500/5 dark:border-amber-500/15'
|
||||
: 'bg-emerald-500/15 text-emerald-700 border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
|
||||
}`}>
|
||||
{isArchived ? t('deps.statusArchived') : t('deps.statusActive')}
|
||||
</span>
|
||||
{hasMissing && (
|
||||
<p className="text-[10px] text-amber-600 dark:text-amber-400 mt-0.5">
|
||||
{skill.missing_deps!.slice(0, 3).join(', ')}
|
||||
{skill.missing_deps!.length > 3 && ` +${skill.missing_deps!.length - 3}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{!skill.is_system && skill.id && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1 text-text-muted hover:text-error transition-colors"
|
||||
title={t('delete.title')}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<Switch
|
||||
checked={skill.enabled !== false}
|
||||
onCheckedChange={(v) => skill.id && onToggle(skill.id, v)}
|
||||
disabled={!skill.id}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -5,8 +5,8 @@ import { useState, useRef, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileTreePanel } from './StorageFileTree'
|
||||
import { FileContentPanel } from './StorageFileViewer'
|
||||
import { formatSize } from '../../../lib/file-helpers'
|
||||
import type { TreeNode } from '../../../lib/file-helpers'
|
||||
import { formatSize } from '../../lib/file-helpers'
|
||||
import type { TreeNode } from '../../lib/file-helpers'
|
||||
|
||||
interface StorageFileBrowserProps {
|
||||
tree: TreeNode[]
|
||||
@@ -0,0 +1,88 @@
|
||||
// Recursive file tree with drag-and-drop move support for storage management.
|
||||
// Uses @dnd-kit/core for DnD — mirrors web UI file-tree.tsx adapted for desktop styling.
|
||||
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DndContext, DragOverlay } from '@dnd-kit/core'
|
||||
import type { TreeNode } from '../../lib/file-helpers'
|
||||
import { TreeItem, RootDropZone, DragPreview, SpinnerIcon } from './file-tree-node'
|
||||
import { useFileTreeDnd } from './use-file-tree-dnd'
|
||||
|
||||
interface FileTreePanelProps {
|
||||
tree: TreeNode[]
|
||||
filesLoading: boolean
|
||||
activePath: string | null
|
||||
onSelect: (path: string) => void
|
||||
onDelete?: (path: string, isDir: boolean) => void
|
||||
onLoadMore?: (path: string) => void
|
||||
onMove?: (fromPath: string, toFolder: string) => void
|
||||
showSize?: boolean
|
||||
}
|
||||
|
||||
export function FileTreePanel({
|
||||
tree, filesLoading, activePath, onSelect, onDelete, onLoadMore, onMove, showSize,
|
||||
}: FileTreePanelProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const dndEnabled = !!onMove
|
||||
|
||||
const {
|
||||
sensors, activeNode, autoExpandPath,
|
||||
handleDragStart, handleDragOver, handleDragEnd, handleDragCancel,
|
||||
} = useFileTreeDnd({ tree, onMove })
|
||||
|
||||
if (filesLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<SpinnerIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tree.length === 0) {
|
||||
return <p className="px-3 py-4 text-xs text-text-muted">{t('noFiles')}</p>
|
||||
}
|
||||
|
||||
const treeContent = (
|
||||
<div className="flex-1 min-h-0">
|
||||
{dndEnabled ? (
|
||||
<RootDropZone>
|
||||
{tree.map((node) => (
|
||||
<TreeItem
|
||||
key={node.path} node={node} depth={0} activePath={activePath}
|
||||
onSelect={onSelect} onDelete={onDelete} onLoadMore={onLoadMore}
|
||||
dndEnabled showSize={showSize} autoExpandPath={autoExpandPath}
|
||||
/>
|
||||
))}
|
||||
</RootDropZone>
|
||||
) : (
|
||||
tree.map((node) => (
|
||||
<TreeItem
|
||||
key={node.path} node={node} depth={0} activePath={activePath}
|
||||
onSelect={onSelect} onDelete={onDelete} onLoadMore={onLoadMore}
|
||||
dndEnabled={false} showSize={showSize} autoExpandPath={null}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!dndEnabled) return treeContent
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
{treeContent}
|
||||
{createPortal(
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeNode ? <DragPreview name={activeNode.name} isDir={activeNode.isDir} /> : null}
|
||||
</DragOverlay>,
|
||||
document.body,
|
||||
)}
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Multi-format file viewer: loading state + empty state wrapper.
|
||||
// Rendering logic delegated to file-viewer-content.tsx.
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileContentBody } from './file-viewer-content'
|
||||
|
||||
export function FileContentPanel({
|
||||
fileContent, contentLoading, fetchBlob, onDownload,
|
||||
}: {
|
||||
fileContent: { content: string; path: string; size: number } | null
|
||||
contentLoading: boolean
|
||||
fetchBlob?: (path: string) => Promise<Blob>
|
||||
onDownload?: (path: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
if (contentLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<svg className="h-5 w-5 animate-spin text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (fileContent) {
|
||||
return (
|
||||
<FileContentBody
|
||||
path={fileContent.path}
|
||||
content={fileContent.content}
|
||||
size={fileContent.size}
|
||||
fetchBlob={fetchBlob}
|
||||
onDownload={onDownload ? () => onDownload(fileContent.path) : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-xs text-text-muted">
|
||||
{t('selectFileToView')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+6
-6
@@ -3,12 +3,12 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStorage, useStorageSize } from '../../../hooks/use-storage'
|
||||
import { buildTree, mergeSubtree, setNodeLoading, formatSize, isTextFile } from '../../../lib/file-helpers'
|
||||
import { getApiClient } from '../../../lib/api'
|
||||
import { wails } from '../../../lib/wails'
|
||||
import { ConfirmDialog } from '../../common/ConfirmDialog'
|
||||
import { RefreshButton } from '../../common/RefreshButton'
|
||||
import { useStorage, useStorageSize } from '../../hooks/use-storage'
|
||||
import { buildTree, mergeSubtree, setNodeLoading, formatSize, isTextFile } from '../../lib/file-helpers'
|
||||
import { getApiClient } from '../../lib/api'
|
||||
import { wails } from '../../lib/wails'
|
||||
import { ConfirmDialog } from '../common/ConfirmDialog'
|
||||
import { RefreshButton } from '../common/RefreshButton'
|
||||
import { StorageFileBrowser } from './StorageFileBrowser'
|
||||
import { StorageUploadDialog } from './StorageUploadDialog'
|
||||
|
||||
+10
-84
@@ -3,21 +3,13 @@
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { UploadProgressList, type FileEntry, type FileStatus } from './upload-progress-list'
|
||||
|
||||
const BLOCKED_EXTENSIONS = new Set([
|
||||
'.exe', '.sh', '.bat', '.cmd', '.ps1', '.com', '.msi', '.scr',
|
||||
])
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
|
||||
|
||||
type FileStatus = 'ready' | 'uploading' | 'success' | 'error'
|
||||
|
||||
interface FileEntry {
|
||||
id: string
|
||||
file: File
|
||||
status: FileStatus
|
||||
error?: string
|
||||
}
|
||||
|
||||
let idCounter = 0
|
||||
function uniqueId(): string {
|
||||
return `upload-${++idCounter}-${Date.now()}`
|
||||
@@ -49,12 +41,12 @@ export function StorageUploadDialog({
|
||||
const newEntries: FileEntry[] = fresh.map((f) => {
|
||||
const ext = '.' + f.name.split('.').pop()?.toLowerCase()
|
||||
if (BLOCKED_EXTENSIONS.has(ext)) {
|
||||
return { id: uniqueId(), file: f, status: 'error' as const, error: t('upload.blockedType', { ext }) }
|
||||
return { id: uniqueId(), file: f, status: 'error' as FileStatus, error: t('upload.blockedType', { ext }) }
|
||||
}
|
||||
if (f.size > MAX_FILE_SIZE) {
|
||||
return { id: uniqueId(), file: f, status: 'error' as const, error: t('upload.tooLarge') }
|
||||
return { id: uniqueId(), file: f, status: 'error' as FileStatus, error: t('upload.tooLarge') }
|
||||
}
|
||||
return { id: uniqueId(), file: f, status: 'ready' as const }
|
||||
return { id: uniqueId(), file: f, status: 'ready' as FileStatus }
|
||||
})
|
||||
setEntries((prev) => [...prev, ...newEntries])
|
||||
}
|
||||
@@ -69,15 +61,15 @@ export function StorageUploadDialog({
|
||||
setUploading(true)
|
||||
|
||||
for (const entry of readyEntries) {
|
||||
setEntries((prev) => prev.map((e) => (e.id === entry.id ? { ...e, status: 'uploading' } : e)))
|
||||
setEntries((prev) => prev.map((e) => (e.id === entry.id ? { ...e, status: 'uploading' as FileStatus } : e)))
|
||||
try {
|
||||
await onUpload(entry.file)
|
||||
setEntries((prev) => prev.map((e) => (e.id === entry.id ? { ...e, status: 'success' } : e)))
|
||||
setEntries((prev) => prev.map((e) => (e.id === entry.id ? { ...e, status: 'success' as FileStatus } : e)))
|
||||
} catch (err) {
|
||||
setEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.id === entry.id
|
||||
? { ...e, status: 'error', error: err instanceof Error ? err.message : t('upload.failed') }
|
||||
? { ...e, status: 'error' as FileStatus, error: err instanceof Error ? err.message : t('upload.failed') }
|
||||
: e,
|
||||
),
|
||||
)
|
||||
@@ -135,7 +127,6 @@ export function StorageUploadDialog({
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Upload icon */}
|
||||
<svg className="h-8 w-8 text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
@@ -145,48 +136,12 @@ export function StorageUploadDialog({
|
||||
{dragging ? t('upload.dropHere') : t('upload.dropOrClick')}
|
||||
</p>
|
||||
<p className="text-[10px] text-text-muted/60">{t('upload.maxSize')}</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<input ref={inputRef} type="file" multiple className="hidden" onChange={handleInputChange} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File list */}
|
||||
{entries.length > 0 && (
|
||||
<div className="flex flex-col gap-1 overflow-y-auto flex-1 min-h-0">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-xs">
|
||||
<StatusIcon status={entry.status} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium text-text-primary">{entry.file.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-text-muted">
|
||||
{(entry.file.size / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</div>
|
||||
{entry.status === 'error' && (
|
||||
<p className="text-[10px] text-error truncate">{entry.error}</p>
|
||||
)}
|
||||
</div>
|
||||
{!uploading && entry.status !== 'uploading' && entry.status !== 'success' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); removeEntry(entry.id) }}
|
||||
className="shrink-0 rounded-sm p-1 text-text-muted hover:text-text-primary"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* File progress list */}
|
||||
<UploadProgressList entries={entries} uploading={uploading} onRemove={removeEntry} />
|
||||
|
||||
{/* Summary */}
|
||||
{entries.length > 0 && !done && !uploading && (
|
||||
@@ -232,32 +187,3 @@ export function StorageUploadDialog({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: FileStatus }) {
|
||||
switch (status) {
|
||||
case 'uploading':
|
||||
return (
|
||||
<svg className="h-4 w-4 shrink-0 animate-spin text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
)
|
||||
case 'ready':
|
||||
return (
|
||||
<svg className="h-4 w-4 shrink-0 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
)
|
||||
case 'success':
|
||||
return (
|
||||
<svg className="h-4 w-4 shrink-0 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
)
|
||||
case 'error':
|
||||
return (
|
||||
<svg className="h-4 w-4 shrink-0 text-error" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
+14
-164
@@ -1,34 +1,22 @@
|
||||
// Recursive file tree with drag-and-drop move support for storage management.
|
||||
// Uses @dnd-kit/core for DnD — mirrors web UI file-tree.tsx adapted for desktop styling.
|
||||
// Individual tree node component with icon rendering and DnD wrappers.
|
||||
// Used by StorageFileTree as the recursive building block.
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
useDroppable,
|
||||
useDraggable,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import { useDraggable, useDroppable } from '@dnd-kit/core'
|
||||
import {
|
||||
extOf,
|
||||
CODE_EXTENSIONS,
|
||||
IMAGE_EXTENSIONS,
|
||||
formatSize,
|
||||
type TreeNode,
|
||||
} from '../../../lib/file-helpers'
|
||||
} from '../../lib/file-helpers'
|
||||
|
||||
// --- Inline SVG icons (desktop convention: no lucide-react) ---
|
||||
|
||||
const cls = 'h-4 w-4 shrink-0'
|
||||
|
||||
function ChevronRightIcon({ className }: { className?: string }) {
|
||||
export function ChevronRightIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? 'h-3 w-3 shrink-0'} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
@@ -36,7 +24,7 @@ function ChevronRightIcon({ className }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function FolderIcon({ open }: { open?: boolean }) {
|
||||
export function FolderIcon({ open }: { open?: boolean }) {
|
||||
if (open) {
|
||||
return (
|
||||
<svg className={`${cls} text-yellow-600`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -52,7 +40,7 @@ function FolderIcon({ open }: { open?: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function FileIcon({ name }: { name: string }) {
|
||||
export function FileIcon({ name }: { name: string }) {
|
||||
const ext = extOf(name)
|
||||
let color = 'text-text-muted'
|
||||
if (ext === 'md' || ext === 'mdx') color = 'text-blue-500'
|
||||
@@ -71,7 +59,7 @@ function FileIcon({ name }: { name: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SpinnerIcon() {
|
||||
export function SpinnerIcon() {
|
||||
return (
|
||||
<svg className="h-3 w-3 shrink-0 animate-spin text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
@@ -90,7 +78,7 @@ function TrashIcon() {
|
||||
|
||||
// --- DnD wrappers ---
|
||||
|
||||
function DraggableItem({ id, enabled, children }: { id: string; enabled: boolean; children: React.ReactNode }) {
|
||||
export function DraggableItem({ id, enabled, children }: { id: string; enabled: boolean; children: React.ReactNode }) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id, disabled: !enabled })
|
||||
return (
|
||||
<div ref={setNodeRef} {...(enabled ? { ...listeners, ...attributes } : {})} className={isDragging ? 'opacity-40' : ''}>
|
||||
@@ -99,19 +87,17 @@ function DraggableItem({ id, enabled, children }: { id: string; enabled: boolean
|
||||
)
|
||||
}
|
||||
|
||||
function DroppableFolder({ id, enabled, children }: { id: string; enabled: boolean; children: (props: { isDropTarget: boolean }) => React.ReactNode }) {
|
||||
export function DroppableFolder({ id, enabled, children }: { id: string; enabled: boolean; children: (props: { isDropTarget: boolean }) => React.ReactNode }) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id, disabled: !enabled })
|
||||
return <div ref={setNodeRef}>{children({ isDropTarget: isOver })}</div>
|
||||
}
|
||||
|
||||
function RootDropZone({ children }: { children: React.ReactNode }) {
|
||||
export function RootDropZone({ children }: { children: React.ReactNode }) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: '__root__' })
|
||||
return <div ref={setNodeRef} className={`min-h-full ${isOver ? 'bg-accent/5' : ''}`}>{children}</div>
|
||||
}
|
||||
|
||||
// --- DragPreview ---
|
||||
|
||||
function DragPreview({ name, isDir }: { name: string; isDir: boolean }) {
|
||||
export function DragPreview({ name, isDir }: { name: string; isDir: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-surface-secondary border border-border px-2.5 py-1.5 text-xs shadow-lg">
|
||||
{isDir ? <FolderIcon /> : <FileIcon name={name} />}
|
||||
@@ -122,7 +108,7 @@ function DragPreview({ name, isDir }: { name: string; isDir: boolean }) {
|
||||
|
||||
// --- TreeItem ---
|
||||
|
||||
function TreeItem({
|
||||
export function TreeItem({
|
||||
node, depth, activePath, onSelect, onDelete, onLoadMore, dndEnabled, autoExpandPath, showSize,
|
||||
}: {
|
||||
node: TreeNode; depth: number; activePath: string | null
|
||||
@@ -133,7 +119,6 @@ function TreeItem({
|
||||
const [expanded, setExpanded] = useState(depth === 0)
|
||||
const isActive = activePath === node.path
|
||||
|
||||
// Auto-expand folder when hovered during drag
|
||||
useEffect(() => {
|
||||
if (autoExpandPath === node.path && node.isDir && !expanded) {
|
||||
setExpanded(true)
|
||||
@@ -238,138 +223,3 @@ function TreeItem({
|
||||
}
|
||||
return fileContent
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function findNode(tree: TreeNode[], path: string): TreeNode | undefined {
|
||||
for (const node of tree) {
|
||||
if (node.path === path) return node
|
||||
if (node.children.length > 0) {
|
||||
const found = findNode(node.children, path)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// --- FileTreePanel (main export) ---
|
||||
|
||||
interface FileTreePanelProps {
|
||||
tree: TreeNode[]
|
||||
filesLoading: boolean
|
||||
activePath: string | null
|
||||
onSelect: (path: string) => void
|
||||
onDelete?: (path: string, isDir: boolean) => void
|
||||
onLoadMore?: (path: string) => void
|
||||
onMove?: (fromPath: string, toFolder: string) => void
|
||||
showSize?: boolean
|
||||
}
|
||||
|
||||
export function FileTreePanel({
|
||||
tree, filesLoading, activePath, onSelect, onDelete, onLoadMore, onMove, showSize,
|
||||
}: FileTreePanelProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const dndEnabled = !!onMove
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const [autoExpandPath, setAutoExpandPath] = useState<string | null>(null)
|
||||
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
)
|
||||
|
||||
const handleDragStart = useCallback((e: DragStartEvent) => {
|
||||
setActiveId(String(e.active.id))
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: DragOverEvent) => {
|
||||
const overId = e.over?.id ? String(e.over.id) : null
|
||||
if (autoExpandTimerRef.current) clearTimeout(autoExpandTimerRef.current)
|
||||
if (overId && overId !== '__root__') {
|
||||
autoExpandTimerRef.current = setTimeout(() => setAutoExpandPath(overId), 800)
|
||||
} else {
|
||||
setAutoExpandPath(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragEnd = useCallback((e: DragEndEvent) => {
|
||||
if (autoExpandTimerRef.current) clearTimeout(autoExpandTimerRef.current)
|
||||
setActiveId(null)
|
||||
setAutoExpandPath(null)
|
||||
|
||||
const fromId = String(e.active.id)
|
||||
const overId = e.over?.id ? String(e.over.id) : null
|
||||
if (!overId || !onMove) return
|
||||
|
||||
const toFolder = overId === '__root__' ? '' : overId
|
||||
if (fromId !== toFolder) onMove(fromId, toFolder)
|
||||
}, [onMove])
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
if (autoExpandTimerRef.current) clearTimeout(autoExpandTimerRef.current)
|
||||
setActiveId(null)
|
||||
setAutoExpandPath(null)
|
||||
}, [])
|
||||
|
||||
const activeNode = useMemo(
|
||||
() => (activeId ? findNode(tree, activeId) : undefined),
|
||||
[activeId, tree],
|
||||
)
|
||||
|
||||
if (filesLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<SpinnerIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tree.length === 0) {
|
||||
return <p className="px-3 py-4 text-xs text-text-muted">{t('noFiles')}</p>
|
||||
}
|
||||
|
||||
const treeContent = (
|
||||
<div className="flex-1 min-h-0">
|
||||
{dndEnabled ? (
|
||||
<RootDropZone>
|
||||
{tree.map((node) => (
|
||||
<TreeItem
|
||||
key={node.path} node={node} depth={0} activePath={activePath}
|
||||
onSelect={onSelect} onDelete={onDelete} onLoadMore={onLoadMore}
|
||||
dndEnabled showSize={showSize} autoExpandPath={autoExpandPath}
|
||||
/>
|
||||
))}
|
||||
</RootDropZone>
|
||||
) : (
|
||||
tree.map((node) => (
|
||||
<TreeItem
|
||||
key={node.path} node={node} depth={0} activePath={activePath}
|
||||
onSelect={onSelect} onDelete={onDelete} onLoadMore={onLoadMore}
|
||||
dndEnabled={false} showSize={showSize} autoExpandPath={null}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!dndEnabled) return treeContent
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
{treeContent}
|
||||
{createPortal(
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeNode ? <DragPreview name={activeNode.name} isDir={activeNode.isDir} /> : null}
|
||||
</DragOverlay>,
|
||||
document.body,
|
||||
)}
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
+4
-55
@@ -1,17 +1,15 @@
|
||||
// Multi-format file viewer: code (syntax highlight), markdown, CSV table, images, unsupported.
|
||||
// Uses react-syntax-highlighter (Prism + oneDark) matching desktop CodeBlock pattern.
|
||||
// File content renderers: code (syntax highlight), markdown, CSV table, images, unsupported.
|
||||
// Extracted from StorageFileViewer for focused rendering logic.
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
import { MarkdownRenderer } from '../../chat/MarkdownRenderer'
|
||||
import { MarkdownRenderer } from '../chat/MarkdownRenderer'
|
||||
import {
|
||||
extOf, langFor, stripFrontmatter, formatSize,
|
||||
isImageFile, isTextFile, CODE_EXTENSIONS,
|
||||
} from '../../../lib/file-helpers'
|
||||
|
||||
// --- Code viewer with syntax highlighting + copy ---
|
||||
} from '../../lib/file-helpers'
|
||||
|
||||
function CodeViewer({ content, language }: { content: string; language: string }) {
|
||||
const { t } = useTranslation('common')
|
||||
@@ -48,8 +46,6 @@ function CodeViewer({ content, language }: { content: string; language: string }
|
||||
)
|
||||
}
|
||||
|
||||
// --- CSV viewer as table ---
|
||||
|
||||
function CsvViewer({ content }: { content: string }) {
|
||||
const { t } = useTranslation('common')
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -116,8 +112,6 @@ function CsvViewer({ content }: { content: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// --- Image viewer (blob fetch → object URL) ---
|
||||
|
||||
function ImageViewer({ path, fetchBlob }: { path: string; fetchBlob: (path: string) => Promise<Blob> }) {
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -170,8 +164,6 @@ function ImageViewer({ path, fetchBlob }: { path: string; fetchBlob: (path: stri
|
||||
)
|
||||
}
|
||||
|
||||
// --- Unsupported file type ---
|
||||
|
||||
function UnsupportedViewer({ path, size, onDownload }: { path: string; size: number; onDownload?: () => void }) {
|
||||
const { t } = useTranslation('storage')
|
||||
const fileName = path.split('/').pop() ?? path
|
||||
@@ -198,8 +190,6 @@ function UnsupportedViewer({ path, size, onDownload }: { path: string; size: num
|
||||
)
|
||||
}
|
||||
|
||||
// --- FileContentBody (renders by type) ---
|
||||
|
||||
export function FileContentBody({
|
||||
path, content, size, fetchBlob, onDownload,
|
||||
}: {
|
||||
@@ -226,44 +216,3 @@ export function FileContentBody({
|
||||
|
||||
return <UnsupportedViewer path={path} size={size ?? 0} onDownload={onDownload} />
|
||||
}
|
||||
|
||||
// --- FileContentPanel (loading state + empty state wrapper) ---
|
||||
|
||||
export function FileContentPanel({
|
||||
fileContent, contentLoading, fetchBlob, onDownload,
|
||||
}: {
|
||||
fileContent: { content: string; path: string; size: number } | null
|
||||
contentLoading: boolean
|
||||
fetchBlob?: (path: string) => Promise<Blob>
|
||||
onDownload?: (path: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
if (contentLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<svg className="h-5 w-5 animate-spin text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (fileContent) {
|
||||
return (
|
||||
<FileContentBody
|
||||
path={fileContent.path}
|
||||
content={fileContent.content}
|
||||
size={fileContent.size}
|
||||
fetchBlob={fetchBlob}
|
||||
onDownload={onDownload ? () => onDownload(fileContent.path) : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-xs text-text-muted">
|
||||
{t('selectFileToView')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// File upload progress list — renders per-file status icons and entry rows.
|
||||
// Extracted from StorageUploadDialog for focused display logic.
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type FileStatus = 'ready' | 'uploading' | 'success' | 'error'
|
||||
|
||||
export interface FileEntry {
|
||||
id: string
|
||||
file: File
|
||||
status: FileStatus
|
||||
error?: string
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: FileStatus }) {
|
||||
switch (status) {
|
||||
case 'uploading':
|
||||
return (
|
||||
<svg className="h-4 w-4 shrink-0 animate-spin text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
)
|
||||
case 'ready':
|
||||
return (
|
||||
<svg className="h-4 w-4 shrink-0 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
)
|
||||
case 'success':
|
||||
return (
|
||||
<svg className="h-4 w-4 shrink-0 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
)
|
||||
case 'error':
|
||||
return (
|
||||
<svg className="h-4 w-4 shrink-0 text-error" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface UploadProgressListProps {
|
||||
entries: FileEntry[]
|
||||
uploading: boolean
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
export function UploadProgressList({ entries, uploading, onRemove }: UploadProgressListProps) {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
if (entries.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 overflow-y-auto flex-1 min-h-0">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-xs">
|
||||
<StatusIcon status={entry.status} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium text-text-primary">{entry.file.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-text-muted">
|
||||
{(entry.file.size / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
</div>
|
||||
{entry.status === 'error' && (
|
||||
<p className="text-[10px] text-error truncate">{entry.error}</p>
|
||||
)}
|
||||
</div>
|
||||
{!uploading && entry.status !== 'uploading' && entry.status !== 'success' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(entry.id) }}
|
||||
className="shrink-0 rounded-sm p-1 text-text-muted hover:text-text-primary"
|
||||
title={t('remove', 'Remove')}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Drag-and-drop hook for the storage file tree.
|
||||
// Encapsulates @dnd-kit sensor setup, auto-expand-on-hover logic, and move dispatch.
|
||||
|
||||
import { useState, useCallback, useRef, useMemo } from 'react'
|
||||
import {
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import type { TreeNode } from '../../lib/file-helpers'
|
||||
|
||||
function findNode(tree: TreeNode[], path: string): TreeNode | undefined {
|
||||
for (const node of tree) {
|
||||
if (node.path === path) return node
|
||||
if (node.children.length > 0) {
|
||||
const found = findNode(node.children, path)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface UseFileTreeDndOptions {
|
||||
tree: TreeNode[]
|
||||
onMove?: (fromPath: string, toFolder: string) => void
|
||||
}
|
||||
|
||||
export function useFileTreeDnd({ tree, onMove }: UseFileTreeDndOptions) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const [autoExpandPath, setAutoExpandPath] = useState<string | null>(null)
|
||||
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
)
|
||||
|
||||
const handleDragStart = useCallback((e: DragStartEvent) => {
|
||||
setActiveId(String(e.active.id))
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: DragOverEvent) => {
|
||||
const overId = e.over?.id ? String(e.over.id) : null
|
||||
if (autoExpandTimerRef.current) clearTimeout(autoExpandTimerRef.current)
|
||||
if (overId && overId !== '__root__') {
|
||||
autoExpandTimerRef.current = setTimeout(() => setAutoExpandPath(overId), 800)
|
||||
} else {
|
||||
setAutoExpandPath(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragEnd = useCallback((e: DragEndEvent) => {
|
||||
if (autoExpandTimerRef.current) clearTimeout(autoExpandTimerRef.current)
|
||||
setActiveId(null)
|
||||
setAutoExpandPath(null)
|
||||
|
||||
const fromId = String(e.active.id)
|
||||
const overId = e.over?.id ? String(e.over.id) : null
|
||||
if (!overId || !onMove) return
|
||||
|
||||
const toFolder = overId === '__root__' ? '' : overId
|
||||
if (fromId !== toFolder) onMove(fromId, toFolder)
|
||||
}, [onMove])
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
if (autoExpandTimerRef.current) clearTimeout(autoExpandTimerRef.current)
|
||||
setActiveId(null)
|
||||
setAutoExpandPath(null)
|
||||
}, [])
|
||||
|
||||
const activeNode = useMemo(
|
||||
() => (activeId ? findNode(tree, activeId) : undefined),
|
||||
[activeId, tree],
|
||||
)
|
||||
|
||||
return {
|
||||
sensors,
|
||||
activeId,
|
||||
activeNode,
|
||||
autoExpandPath,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
handleDragCancel,
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MarkdownRenderer } from '../chat/MarkdownRenderer'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
import { ConfirmDialog } from '../common/ConfirmDialog'
|
||||
import { IconClose, IconChevronDown, IconDocument, IconCheckCircle, IconTrash } from '../common/Icons'
|
||||
import { getApiClient } from '../../lib/api'
|
||||
import { STATUS_BADGE, PRIORITY_BADGE, isTaskLocked, TERMINAL_STATUSES } from '../../types/team'
|
||||
import { IconClose, IconTrash } from '../common/Icons'
|
||||
import { TERMINAL_STATUSES } from '../../types/team'
|
||||
import { TaskDetailBody } from './task-detail-meta'
|
||||
import type { TeamTaskData, TeamMemberData, TeamTaskAttachment } from '../../types/team'
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
@@ -17,54 +16,11 @@ interface TaskDetailModalProps {
|
||||
onFetchDetail?: (teamId: string, taskId: string) => Promise<{ task: TeamTaskData; attachments: TeamTaskAttachment[] } | null>
|
||||
}
|
||||
|
||||
/** Metadata label + value pair */
|
||||
function MetaItem({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs text-text-muted mb-0.5">{label}</dt>
|
||||
<dd className="text-sm font-medium text-text-primary">{children}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Collapsible bordered section — matches web UI TaskDetailContent pattern */
|
||||
function CollapsibleSection({ title, icon, defaultOpen = true, children }: {
|
||||
title: string; icon: React.ReactNode; defaultOpen?: boolean; children: React.ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div className="rounded-lg border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2 px-4 py-3 text-sm font-medium text-text-muted hover:text-text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
<IconChevronDown className={`ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Format bytes to human-readable size */
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function TaskDetailModal({ task, members, onClose, onAssign, onDelete, onFetchDetail }: TaskDetailModalProps) {
|
||||
const { t } = useTranslation('teams')
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [attachments, setAttachments] = useState<TeamTaskAttachment[]>([])
|
||||
|
||||
// Fetch full detail (with attachments) on mount
|
||||
useEffect(() => {
|
||||
if (!onFetchDetail) return
|
||||
onFetchDetail(task.team_id, task.id).then((res) => {
|
||||
@@ -72,9 +28,6 @@ export function TaskDetailModal({ task, members, onClose, onAssign, onDelete, on
|
||||
})
|
||||
}, [task.id, task.team_id, onFetchDetail])
|
||||
|
||||
const prio = PRIORITY_BADGE[task.priority] ?? PRIORITY_BADGE[3]
|
||||
const statusCls = STATUS_BADGE[task.status] ?? ''
|
||||
const locked = isTaskLocked(task)
|
||||
const isTerminal = TERMINAL_STATUSES.has(task.status)
|
||||
const member = task.owner_agent_id ? members.find((m) => m.agent_id === task.owner_agent_id) : undefined
|
||||
|
||||
@@ -98,26 +51,11 @@ export function TaskDetailModal({ task, members, onClose, onAssign, onDelete, on
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-surface-primary border border-border rounded-xl shadow-xl w-[95vw] max-w-4xl max-h-[85vh] flex flex-col mx-4"
|
||||
>
|
||||
{/* ── Header: badges + title ── */}
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-5 pb-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{task.identifier && (
|
||||
<span className="text-xs font-mono text-text-muted bg-surface-tertiary px-2 py-0.5 rounded border border-border">
|
||||
{task.identifier}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded capitalize ${statusCls}`}>
|
||||
{task.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
{locked && (
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded bg-green-500/15 text-green-600 dark:text-green-400 animate-pulse">
|
||||
Running
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-text-primary leading-snug sm:text-lg">{task.subject}</h3>
|
||||
<h3 className="text-base font-semibold text-text-primary leading-snug sm:text-lg mt-1">{task.subject}</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-text-muted hover:text-text-primary p-1.5 cursor-pointer shrink-0 rounded-lg hover:bg-surface-tertiary">
|
||||
<IconClose />
|
||||
@@ -125,137 +63,12 @@ export function TaskDetailModal({ task, members, onClose, onAssign, onDelete, on
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Scrollable body ── */}
|
||||
{/* Scrollable body */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain space-y-4 px-6 py-4">
|
||||
|
||||
{/* Progress bar */}
|
||||
{task.progress_percent != null && task.progress_percent > 0 && !isTerminal && (() => {
|
||||
const pct = Math.min(100, Math.max(0, task.progress_percent))
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-text-muted">
|
||||
<span>{t('progress', 'Progress')}</span>
|
||||
<span>{pct}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-surface-tertiary overflow-hidden">
|
||||
<div className="h-2 rounded-full bg-accent transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
{task.progress_step && <p className="text-xs text-text-muted">{task.progress_step}</p>}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Metadata grid */}
|
||||
<dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-3 rounded-lg bg-surface-tertiary/30 p-4">
|
||||
<MetaItem label={t('priority', 'Priority')}>
|
||||
<span className={`inline-flex items-center gap-1.5 capitalize`}>
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${prio.cls}`}>{prio.label}</span>
|
||||
</span>
|
||||
</MetaItem>
|
||||
<MetaItem label={t('owner', 'Owner')}>
|
||||
{member ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
{member.emoji && <span className="text-base">{member.emoji}</span>}
|
||||
{member.display_name || member.agent_key}
|
||||
</span>
|
||||
) : task.owner_agent_key || '—'}
|
||||
</MetaItem>
|
||||
{task.task_type && task.task_type !== 'general' && (
|
||||
<MetaItem label={t('type', 'Type')}>
|
||||
<span className="text-xs bg-surface-tertiary border border-border px-2 py-0.5 rounded">{task.task_type}</span>
|
||||
</MetaItem>
|
||||
)}
|
||||
{task.created_at && (
|
||||
<MetaItem label={t('created', 'Created')}>
|
||||
{new Date(task.created_at).toLocaleString()}
|
||||
</MetaItem>
|
||||
)}
|
||||
{task.updated_at && (
|
||||
<MetaItem label={t('updated', 'Updated')}>
|
||||
{new Date(task.updated_at).toLocaleString()}
|
||||
</MetaItem>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{/* Blocked by */}
|
||||
{task.blocked_by && task.blocked_by.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-text-muted">{t('blockedBy', 'Blocked by')}</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{task.blocked_by.map((id) => (
|
||||
<span key={id} className="text-xs font-mono bg-amber-500/15 text-amber-600 dark:text-amber-400 px-2 py-0.5 rounded border border-amber-500/20">
|
||||
{id.slice(0, 8)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description section */}
|
||||
{task.description && (
|
||||
<CollapsibleSection
|
||||
title={t('description', 'Description')}
|
||||
icon={<IconDocument />}
|
||||
>
|
||||
<div className="text-sm text-text-secondary prose prose-sm dark:prose-invert max-w-none max-h-60 overflow-y-auto">
|
||||
<MarkdownRenderer content={task.description} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Result section */}
|
||||
{task.result && (
|
||||
<CollapsibleSection
|
||||
title={t('result', 'Result')}
|
||||
icon={<IconCheckCircle />}
|
||||
>
|
||||
<div className="text-sm text-text-secondary prose prose-sm dark:prose-invert max-w-none max-h-[40vh] overflow-y-auto">
|
||||
<MarkdownRenderer content={task.result} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Attachments section */}
|
||||
{attachments.length > 0 && (
|
||||
<CollapsibleSection
|
||||
title={`${t('attachments', 'Attachments')} (${attachments.length})`}
|
||||
icon={<IconDocument />}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{attachments.map((a) => {
|
||||
const fileName = a.path?.split('/').pop() || 'file'
|
||||
const baseUrl = getApiClient()?.getBaseUrl() || ''
|
||||
const fullUrl = a.download_url?.startsWith('http') ? a.download_url : `${baseUrl}${a.download_url}`
|
||||
return (
|
||||
<div key={a.id} className="flex items-center gap-3 rounded-lg border border-border bg-surface-tertiary/30 p-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent/10">
|
||||
<IconDocument className="text-accent" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium text-text-primary">{fileName}</p>
|
||||
{a.file_size > 0 && (
|
||||
<p className="text-xs text-text-muted">{formatFileSize(a.file_size)}</p>
|
||||
)}
|
||||
</div>
|
||||
{a.download_url && (
|
||||
<a
|
||||
href={fullUrl}
|
||||
download
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0 text-xs text-accent hover:text-accent/80 px-3 py-1.5 rounded-lg border border-accent/30 hover:bg-accent/10 transition-colors cursor-pointer"
|
||||
>
|
||||
{t('download', 'Download')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
<TaskDetailBody task={task} members={members} attachments={attachments} />
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-3 px-6 py-3 border-t border-border shrink-0">
|
||||
{!isTerminal && (
|
||||
<div className="max-w-[240px]">
|
||||
|
||||
@@ -7,7 +7,8 @@ import { IconChevronLeft, IconGear, IconChevronDown, IconPlus, IconChat } from '
|
||||
import { KanbanColumn } from './KanbanColumn'
|
||||
import { TaskDetailModal } from './TaskDetailModal'
|
||||
import { TeamSettingsModal } from './TeamSettingsModal'
|
||||
import { KANBAN_STATUSES, TERMINAL_STATUSES, groupByStatus } from '../../types/team'
|
||||
import { TeamTaskListView } from './team-task-list-view'
|
||||
import { KANBAN_STATUSES, groupByStatus } from '../../types/team'
|
||||
import type { TeamTaskData } from '../../types/team'
|
||||
|
||||
type ViewMode = 'kanban' | 'list'
|
||||
@@ -34,17 +35,12 @@ export function TeamBoard() {
|
||||
const team = teams.find((t) => t.id === activeTeamId)
|
||||
|
||||
useEffect(() => { fetchTeams() }, [fetchTeams])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTeamId) fetchTasks(activeTeamId, statusFilter || undefined)
|
||||
}, [activeTeamId, statusFilter, fetchTasks])
|
||||
|
||||
const grouped = useMemo(() => groupByStatus(tasks), [tasks])
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (activeTeamId) fetchTasks(activeTeamId, statusFilter || undefined)
|
||||
}
|
||||
|
||||
// Keep selected task in sync with tasks
|
||||
useEffect(() => {
|
||||
if (selectedTask) {
|
||||
@@ -76,18 +72,10 @@ export function TeamBoard() {
|
||||
<button onClick={closeSettings} className="text-text-muted hover:text-text-primary cursor-pointer" title="Back to chat">
|
||||
<IconChevronLeft size={16} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-text-primary truncate">
|
||||
{team?.name || t('team', 'Team')}
|
||||
</h2>
|
||||
{/* Gear — team settings */}
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="text-text-muted hover:text-text-primary cursor-pointer p-1 rounded hover:bg-surface-tertiary"
|
||||
title={t('teamSettings', 'Team Settings')}
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-text-primary truncate">{team?.name || t('team', 'Team')}</h2>
|
||||
<button onClick={() => setSettingsOpen(true)} className="text-text-muted hover:text-text-primary cursor-pointer p-1 rounded hover:bg-surface-tertiary" title={t('teamSettings', 'Team Settings')}>
|
||||
<IconGear />
|
||||
</button>
|
||||
</div>
|
||||
@@ -95,26 +83,17 @@ export function TeamBoard() {
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setViewMode('kanban')}
|
||||
className={`text-[10px] px-2 py-1 rounded cursor-pointer ${viewMode === 'kanban' ? 'bg-surface-primary text-text-primary shadow-sm' : 'text-text-muted'}`}
|
||||
>
|
||||
<button onClick={() => setViewMode('kanban')} className={`text-[10px] px-2 py-1 rounded cursor-pointer ${viewMode === 'kanban' ? 'bg-surface-primary text-text-primary shadow-sm' : 'text-text-muted'}`}>
|
||||
{t('kanban', 'Kanban')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`text-[10px] px-2 py-1 rounded cursor-pointer ${viewMode === 'list' ? 'bg-surface-primary text-text-primary shadow-sm' : 'text-text-muted'}`}
|
||||
>
|
||||
<button onClick={() => setViewMode('list')} className={`text-[10px] px-2 py-1 rounded cursor-pointer ${viewMode === 'list' ? 'bg-surface-primary text-text-primary shadow-sm' : 'text-text-muted'}`}>
|
||||
{t('list', 'List')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Custom status filter dropdown */}
|
||||
{/* Status filter */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setFilterOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-[11px] bg-surface-tertiary border border-border rounded-lg px-2.5 py-1.5 text-text-secondary hover:border-accent/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<button onClick={() => setFilterOpen((v) => !v)} className="flex items-center gap-1.5 text-[11px] bg-surface-tertiary border border-border rounded-lg px-2.5 py-1.5 text-text-secondary hover:border-accent/30 transition-colors cursor-pointer">
|
||||
<span>{t(currentFilterLabel)}</span>
|
||||
<IconChevronDown size={10} />
|
||||
</button>
|
||||
@@ -123,13 +102,8 @@ export function TeamBoard() {
|
||||
<div className="fixed inset-0 z-40" onClick={() => setFilterOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 bg-surface-primary border border-border rounded-lg shadow-lg py-1 min-w-[120px]">
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => { setStatusFilter(opt.value); setFilterOpen(false) }}
|
||||
className={[
|
||||
'w-full text-left px-3 py-1.5 text-[11px] transition-colors cursor-pointer',
|
||||
statusFilter === opt.value ? 'text-accent bg-accent/10' : 'text-text-secondary hover:bg-surface-tertiary',
|
||||
].join(' ')}
|
||||
<button key={opt.value} onClick={() => { setStatusFilter(opt.value); setFilterOpen(false) }}
|
||||
className={['w-full text-left px-3 py-1.5 text-[11px] transition-colors cursor-pointer', statusFilter === opt.value ? 'text-accent bg-accent/10' : 'text-text-secondary hover:bg-surface-tertiary'].join(' ')}
|
||||
>
|
||||
{t(opt.label)}
|
||||
</button>
|
||||
@@ -139,14 +113,9 @@ export function TeamBoard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RefreshButton onRefresh={async () => { handleRefresh() }} />
|
||||
<RefreshButton onRefresh={async () => { if (activeTeamId) fetchTasks(activeTeamId, statusFilter || undefined) }} />
|
||||
|
||||
{/* "+" info button */}
|
||||
<button
|
||||
onClick={() => setInfoOpen(true)}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-lg bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer transition-colors"
|
||||
title={t('createTask', 'Create Task')}
|
||||
>
|
||||
<button onClick={() => setInfoOpen(true)} className="flex items-center justify-center w-7 h-7 rounded-lg bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer transition-colors" title={t('createTask', 'Create Task')}>
|
||||
<IconPlus />
|
||||
</button>
|
||||
</div>
|
||||
@@ -156,111 +125,30 @@ export function TeamBoard() {
|
||||
<div className="flex-1 overflow-x-auto overflow-y-hidden p-3">
|
||||
<div className="flex gap-3 h-full">
|
||||
{KANBAN_STATUSES.map((status) => (
|
||||
<KanbanColumn
|
||||
key={status}
|
||||
status={status}
|
||||
tasks={grouped.get(status) ?? []}
|
||||
members={members}
|
||||
onTaskClick={setSelectedTask}
|
||||
/>
|
||||
<KanbanColumn key={status} status={status} tasks={grouped.get(status) ?? []} members={members} onTaskClick={setSelectedTask} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* List view */
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center gap-2 mb-2 px-2">
|
||||
<span className="text-xs text-text-muted">{selected.size} selected</span>
|
||||
<button onClick={handleBulkDelete} className="text-xs text-error hover:underline cursor-pointer">
|
||||
{t('deleteSelected', 'Delete selected')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="text-text-muted text-left border-b border-border">
|
||||
<th className="pb-2 pr-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.size > 0 && selected.size === tasks.filter((t) => TERMINAL_STATUSES.has(t.status)).length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelected(new Set(tasks.filter((t) => TERMINAL_STATUSES.has(t.status)).map((t) => t.id)))
|
||||
} else {
|
||||
setSelected(new Set())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
<th className="pb-2 pr-2 font-medium">{t('id', 'ID')}</th>
|
||||
<th className="pb-2 pr-2 font-medium">{t('subject', 'Subject')}</th>
|
||||
<th className="pb-2 pr-2 font-medium">{t('status', 'Status')}</th>
|
||||
<th className="pb-2 pr-2 font-medium">{t('owner', 'Owner')}</th>
|
||||
<th className="pb-2 font-medium">{t('priority', 'Priority')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task) => {
|
||||
const member = task.owner_agent_id ? members.find((m) => m.agent_id === task.owner_agent_id) : undefined
|
||||
const canSelect = TERMINAL_STATUSES.has(task.status)
|
||||
return (
|
||||
<tr
|
||||
key={task.id}
|
||||
onClick={() => setSelectedTask(task)}
|
||||
className="border-b border-border/50 hover:bg-surface-tertiary/50 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="py-2 pr-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!canSelect}
|
||||
checked={selected.has(task.id)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(selected)
|
||||
e.target.checked ? next.add(task.id) : next.delete(task.id)
|
||||
setSelected(next)
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2 font-mono text-text-muted">{task.identifier || task.task_number}</td>
|
||||
<td className="py-2 pr-2 text-text-primary truncate max-w-[250px]">{task.subject}</td>
|
||||
<td className="py-2 pr-2">
|
||||
<span className="capitalize text-text-secondary">{task.status.replace('_', ' ')}</span>
|
||||
</td>
|
||||
<td className="py-2 pr-2 text-text-secondary">{member?.display_name || task.owner_agent_key || '—'}</td>
|
||||
<td className="py-2 text-text-secondary">P-{task.priority}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{tasks.length === 0 && !loading && (
|
||||
<div className="text-center py-8 text-text-muted text-xs">{t('noTasks', 'No tasks yet')}</div>
|
||||
)}
|
||||
</div>
|
||||
<TeamTaskListView
|
||||
tasks={tasks}
|
||||
members={members}
|
||||
loading={loading}
|
||||
selected={selected}
|
||||
onSelectChange={setSelected}
|
||||
onTaskClick={setSelectedTask}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Task detail modal */}
|
||||
{selectedTask && (
|
||||
<TaskDetailModal
|
||||
task={selectedTask}
|
||||
members={members}
|
||||
onClose={() => setSelectedTask(null)}
|
||||
onAssign={assignTask}
|
||||
onDelete={deleteTask}
|
||||
onFetchDetail={fetchTaskDetail}
|
||||
/>
|
||||
<TaskDetailModal task={selectedTask} members={members} onClose={() => setSelectedTask(null)} onAssign={assignTask} onDelete={deleteTask} onFetchDetail={fetchTaskDetail} />
|
||||
)}
|
||||
|
||||
{/* Team settings modal */}
|
||||
{settingsOpen && activeTeamId && (
|
||||
<TeamSettingsModal
|
||||
teamId={activeTeamId}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSaved={() => { fetchTeams(); if (activeTeamId) fetchTasks(activeTeamId, statusFilter || undefined) }}
|
||||
/>
|
||||
<TeamSettingsModal teamId={activeTeamId} onClose={() => setSettingsOpen(false)} onSaved={() => { fetchTeams(); if (activeTeamId) fetchTasks(activeTeamId, statusFilter || undefined) }} />
|
||||
)}
|
||||
|
||||
{/* Task create info modal */}
|
||||
@@ -276,16 +164,12 @@ export function TeamBoard() {
|
||||
leader: team?.lead_display_name || team?.lead_agent_key || 'the leader',
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setInfoOpen(false)}
|
||||
className="mt-2 px-4 py-1.5 text-xs font-medium bg-surface-tertiary text-text-primary rounded-lg hover:bg-surface-tertiary/80 cursor-pointer"
|
||||
>
|
||||
<button onClick={() => setInfoOpen(false)} className="mt-2 px-4 py-1.5 text-xs font-medium bg-surface-tertiary text-text-primary rounded-lg hover:bg-surface-tertiary/80 cursor-pointer">
|
||||
{t('settings.ok', 'OK')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getWsClient } from '../../lib/ws'
|
||||
import { teamService } from '../../services/team-service'
|
||||
import { toast } from '../../stores/toast-store'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
import { IconClose, IconCheck } from '../common/Icons'
|
||||
@@ -34,12 +34,11 @@ export function TeamCreateDialog({ agents, onClose, onCreated }: TeamCreateDialo
|
||||
if (!name.trim() || !lead) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const ws = getWsClient()
|
||||
const res = await ws.call('teams.create', {
|
||||
const res = await teamService.create({
|
||||
name: name.trim(),
|
||||
lead,
|
||||
members: memberKeys.filter((k) => k !== lead),
|
||||
}) as { team: TeamData }
|
||||
})
|
||||
toast.success(t('teamCreated', 'Team created'), name.trim())
|
||||
onCreated(res.team)
|
||||
onClose()
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
import { IconClose, IconPlus, IconUser, IconSpinner } from '../common/Icons'
|
||||
import { IconClose, IconSpinner } from '../common/Icons'
|
||||
import { useAgents } from '../../hooks/use-agents'
|
||||
import { useTeamManage } from '../../hooks/use-team-manage'
|
||||
import { TeamMemberList } from './team-member-list'
|
||||
import { teamSettingsSchema, type TeamSettingsFormData } from '../../schemas/team-settings.schema'
|
||||
import type { TeamData, TeamMemberData, TeamNotifyConfig } from '../../types/team'
|
||||
|
||||
interface TeamSettingsModalProps {
|
||||
teamId: string
|
||||
onClose: () => void
|
||||
/** Called after successful save so parent can refresh */
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
lead: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
|
||||
reviewer: 'bg-orange-500/15 text-orange-600 dark:text-orange-400',
|
||||
member: 'bg-surface-tertiary text-text-muted',
|
||||
}
|
||||
|
||||
const NOTIFY_KEYS = ['dispatched', 'progress', 'failed', 'completed', 'new_task'] as const
|
||||
type NotifyKey = typeof NOTIFY_KEYS[number]
|
||||
|
||||
@@ -31,70 +27,54 @@ export function TeamSettingsModal({ teamId, onClose, onSaved }: TeamSettingsModa
|
||||
const [members, setMembers] = useState<TeamMemberData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Editable fields
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
// Notification settings
|
||||
const [notify, setNotify] = useState<Record<NotifyKey, boolean>>({
|
||||
dispatched: true, progress: true, failed: true, completed: true, new_task: true,
|
||||
})
|
||||
const [notifyMode, setNotifyMode] = useState<'direct' | 'leader'>('direct')
|
||||
|
||||
// Add member
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [addAgent, setAddAgent] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [removing, setRemoving] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Load team detail (once on mount)
|
||||
const { watch, setValue, reset, handleSubmit, formState: { isSubmitting } } = useForm<TeamSettingsFormData>({
|
||||
resolver: zodResolver(teamSettingsSchema),
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
refreshAgents()
|
||||
fetchTeamDetail(teamId).then((res) => {
|
||||
if (!res) return
|
||||
setTeam(res.team)
|
||||
setMembers(res.members ?? [])
|
||||
setName(res.team.name)
|
||||
setDescription(res.team.description ?? '')
|
||||
// Parse notification settings
|
||||
const settings = (res.team.settings ?? {}) as Record<string, unknown>
|
||||
const n = (settings.notifications ?? {}) as Record<string, unknown>
|
||||
setNotify({
|
||||
dispatched: n.dispatched !== false,
|
||||
progress: n.progress !== false,
|
||||
failed: n.failed !== false,
|
||||
completed: n.completed !== false,
|
||||
new_task: n.new_task !== false,
|
||||
reset({
|
||||
name: res.team.name,
|
||||
description: res.team.description ?? '',
|
||||
notify: {
|
||||
dispatched: n.dispatched !== false,
|
||||
progress: n.progress !== false,
|
||||
failed: n.failed !== false,
|
||||
completed: n.completed !== false,
|
||||
new_task: n.new_task !== false,
|
||||
},
|
||||
notifyMode: (n.mode as 'direct' | 'leader') || 'direct',
|
||||
})
|
||||
setNotifyMode((n.mode as 'direct' | 'leader') || 'direct')
|
||||
setLoading(false)
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [teamId])
|
||||
|
||||
const leadMember = members.find((m) => m.role === 'lead')
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...members].sort((a, b) => {
|
||||
if (a.role === 'lead' && b.role !== 'lead') return -1
|
||||
if (b.role === 'lead' && a.role !== 'lead') return 1
|
||||
return (a.display_name || a.agent_key || '').localeCompare(b.display_name || b.agent_key || '')
|
||||
}),
|
||||
[members],
|
||||
)
|
||||
const sorted = useMemo(() => [...members].sort((a, b) => {
|
||||
if (a.role === 'lead' && b.role !== 'lead') return -1
|
||||
if (b.role === 'lead' && a.role !== 'lead') return 1
|
||||
return (a.display_name || a.agent_key || '').localeCompare(b.display_name || b.agent_key || '')
|
||||
}), [members])
|
||||
|
||||
const memberIds = useMemo(() => new Set(members.map((m) => m.agent_id)), [members])
|
||||
const availableAgents = useMemo(
|
||||
() => agents
|
||||
.filter((a) => !memberIds.has(a.id))
|
||||
.map((a) => ({ value: a.id, label: `${a.emoji || ''} ${a.name}`.trim() })),
|
||||
[agents, memberIds],
|
||||
)
|
||||
const availableAgents = useMemo(() => agents.filter((a) => !memberIds.has(a.id)).map((a) => ({ value: a.id, label: `${a.emoji || ''} ${a.name}`.trim() })), [agents, memberIds])
|
||||
|
||||
const toggleNotify = useCallback((key: NotifyKey) => {
|
||||
setNotify((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}, [])
|
||||
setValue(`notify.${key}`, !watch(`notify.${key}`))
|
||||
}, [setValue, watch])
|
||||
|
||||
const handleAddMember = async () => {
|
||||
if (!addAgent) return
|
||||
@@ -116,18 +96,15 @@ export function TeamSettingsModal({ teamId, onClose, onSaved }: TeamSettingsModa
|
||||
} catch { /* toast in hook */ } finally { setRemoving(null) }
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const notifications: TeamNotifyConfig = { ...notify, mode: notifyMode }
|
||||
const settings = { ...(team?.settings ?? {}), notifications } as Record<string, unknown>
|
||||
await updateTeam(teamId, {
|
||||
name: name !== team?.name ? name : undefined,
|
||||
description: description !== (team?.description ?? '') ? description : undefined,
|
||||
settings,
|
||||
})
|
||||
onSaved?.()
|
||||
} catch { /* toast in hook */ } finally { setSaving(false) }
|
||||
const onValid = async (data: TeamSettingsFormData) => {
|
||||
const notifications: TeamNotifyConfig = { ...data.notify, mode: data.notifyMode }
|
||||
const settings = { ...(team?.settings ?? {}), notifications } as Record<string, unknown>
|
||||
await updateTeam(teamId, {
|
||||
name: data.name !== team?.name ? data.name : undefined,
|
||||
description: data.description !== (team?.description ?? '') ? data.description : undefined,
|
||||
settings,
|
||||
})
|
||||
onSaved?.()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -140,178 +117,85 @@ export function TeamSettingsModal({ teamId, onClose, onSaved }: TeamSettingsModa
|
||||
)
|
||||
}
|
||||
|
||||
const notifyMode = watch('notifyMode')
|
||||
const notify = watch('notify')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-surface-primary border border-border rounded-xl shadow-xl w-[95vw] max-w-2xl max-h-[85vh] flex flex-col mx-4"
|
||||
>
|
||||
<div onClick={(e) => e.stopPropagation()} className="bg-surface-primary border border-border rounded-xl shadow-xl w-[95vw] max-w-2xl max-h-[85vh] flex flex-col mx-4">
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-5 pb-4 border-b border-border shrink-0 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-text-primary">{t('teamSettings', 'Team Settings')}</h2>
|
||||
<button onClick={onClose} className="text-text-muted hover:text-text-primary p-1.5 cursor-pointer rounded-lg hover:bg-surface-tertiary">
|
||||
<IconClose />
|
||||
</button>
|
||||
<button onClick={onClose} className="text-text-muted hover:text-text-primary p-1.5 cursor-pointer rounded-lg hover:bg-surface-tertiary"><IconClose /></button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain space-y-5 px-6 py-4">
|
||||
|
||||
{/* ── Section 1: Team Info ── */}
|
||||
{/* Team Info */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-text-primary">{t('settings.teamInfo', 'Team Info')}</h3>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs text-text-muted">{t('teamName', 'Team name')}</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent/50"
|
||||
/>
|
||||
<input value={watch('name')} onChange={(e) => setValue('name', e.target.value, { shouldValidate: true })} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent/50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs text-text-muted">{t('description', 'Description')}</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent/50 resize-none"
|
||||
/>
|
||||
<textarea value={watch('description')} onChange={(e) => setValue('description', e.target.value)} rows={2} className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent/50 resize-none" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-xs text-text-muted">{t('status', 'Status')}</span>
|
||||
<p className="mt-0.5 font-medium capitalize text-text-primary">{team?.status || 'active'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-text-muted">{t('leadAgent', 'Lead agent')}</span>
|
||||
<p className="mt-0.5 font-medium text-text-primary">
|
||||
{leadMember?.emoji && <span className="mr-1">{leadMember.emoji}</span>}
|
||||
{leadMember?.display_name || leadMember?.agent_key || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div><span className="text-xs text-text-muted">{t('status', 'Status')}</span><p className="mt-0.5 font-medium capitalize text-text-primary">{team?.status || 'active'}</p></div>
|
||||
<div><span className="text-xs text-text-muted">{t('leadAgent', 'Lead agent')}</span><p className="mt-0.5 font-medium text-text-primary">{leadMember?.emoji && <span className="mr-1">{leadMember.emoji}</span>}{leadMember?.display_name || leadMember?.agent_key || '—'}</p></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Section 2: Members ── */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-text-primary">{t('members', 'Members')} ({members.length})</h3>
|
||||
<button
|
||||
onClick={() => setShowAdd(!showAdd)}
|
||||
className="text-xs text-accent hover:text-accent/80 cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<IconPlus size={12} />
|
||||
{t('settings.addMember', 'Add member')}
|
||||
</button>
|
||||
</div>
|
||||
{/* Members */}
|
||||
<TeamMemberList
|
||||
members={members}
|
||||
sorted={sorted}
|
||||
availableAgents={availableAgents}
|
||||
showAdd={showAdd}
|
||||
addAgent={addAgent}
|
||||
adding={adding}
|
||||
removing={removing}
|
||||
onToggleAdd={() => setShowAdd(!showAdd)}
|
||||
onAddAgentChange={setAddAgent}
|
||||
onAddMember={handleAddMember}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
/>
|
||||
|
||||
{showAdd && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Combobox
|
||||
value={addAgent}
|
||||
onChange={setAddAgent}
|
||||
options={availableAgents}
|
||||
placeholder={t('settings.searchAgent', 'Search agent...')}
|
||||
allowCustom={false}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddMember}
|
||||
disabled={!addAgent || adding}
|
||||
className="shrink-0 px-3 py-1.5 text-xs font-medium bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{adding ? '...' : t('settings.add', 'Add')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border divide-y divide-border max-h-[200px] overflow-y-auto">
|
||||
{sorted.map((m) => (
|
||||
<div key={m.agent_id} className="group flex items-center gap-3 px-3 py-2.5 hover:bg-surface-tertiary/50">
|
||||
{m.emoji ? (
|
||||
<span className="text-base shrink-0">{m.emoji}</span>
|
||||
) : (
|
||||
<IconUser className="text-text-muted shrink-0" />
|
||||
)}
|
||||
<span className="text-sm text-text-primary truncate flex-1">{m.display_name || m.agent_key || m.agent_id.slice(0, 8)}</span>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${ROLE_COLORS[m.role] ?? ''}`}>
|
||||
{m.role}
|
||||
</span>
|
||||
{m.role !== 'lead' && members.filter((x) => x.role !== 'lead').length > 1 && (
|
||||
<button
|
||||
onClick={() => handleRemoveMember(m.agent_id)}
|
||||
disabled={removing === m.agent_id}
|
||||
className="opacity-0 group-hover:opacity-100 text-text-muted hover:text-error cursor-pointer transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{removing === m.agent_id ? (
|
||||
<IconSpinner size={14} className="border-text-muted" />
|
||||
) : (
|
||||
<IconClose size={14} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<div className="px-3 py-4 text-center text-xs text-text-muted">{t('settings.noMembers', 'No members')}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Section 3: Notifications ── */}
|
||||
{/* Notifications */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-text-primary">{t('settings.notifications', 'Notifications')}</h3>
|
||||
<div className="space-y-2">
|
||||
{NOTIFY_KEYS.map((key) => (
|
||||
<div key={key} className="flex items-center justify-between py-1.5">
|
||||
<span className="text-sm text-text-secondary">{t(`settings.notify_${key}`, key)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleNotify(key)}
|
||||
className={`relative w-9 h-5 rounded-full transition-colors cursor-pointer ${notify[key] ? 'bg-accent' : 'bg-surface-tertiary border border-border'}`}
|
||||
>
|
||||
<button type="button" onClick={() => toggleNotify(key)} className={`relative w-9 h-5 rounded-full transition-colors cursor-pointer ${notify[key] ? 'bg-accent' : 'bg-surface-tertiary border border-border'}`}>
|
||||
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${notify[key] ? 'translate-x-4' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Notification mode */}
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<span className="text-xs text-text-muted">{t('settings.notifyMode', 'Notification mode')}</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['direct', 'leader'] as const).map((mode) => (
|
||||
<button
|
||||
type="button"
|
||||
key={mode}
|
||||
onClick={() => setNotifyMode(mode)}
|
||||
className={`text-left rounded-lg border p-3 transition-colors cursor-pointer ${
|
||||
notifyMode === mode
|
||||
? 'border-accent bg-accent/5'
|
||||
: 'border-border hover:border-accent/30'
|
||||
}`}
|
||||
>
|
||||
<button type="button" key={mode} onClick={() => setValue('notifyMode', mode)}
|
||||
className={`text-left rounded-lg border p-3 transition-colors cursor-pointer ${notifyMode === mode ? 'border-accent bg-accent/5' : 'border-border hover:border-accent/30'}`}>
|
||||
<div className="text-sm font-medium text-text-primary">{t(`settings.notifyMode_${mode}`, mode)}</div>
|
||||
<div className="text-[11px] text-text-muted mt-0.5">{t(`settings.notifyMode_${mode}_desc`)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{notifyMode === 'leader' && (
|
||||
<p className="text-xs text-amber-500">{t('settings.notifyModeLeaderWarn', 'Only the lead agent will receive notifications.')}</p>
|
||||
)}
|
||||
{notifyMode === 'leader' && <p className="text-xs text-amber-500">{t('settings.notifyModeLeaderWarn', 'Only the lead agent will receive notifications.')}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-border shrink-0 flex justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name.trim()}
|
||||
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
{saving && <IconSpinner size={14} className="border-white" />}
|
||||
<button onClick={handleSubmit(onValid)} disabled={isSubmitting || !watch('name').trim()} className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 cursor-pointer flex items-center gap-2">
|
||||
{isSubmitting && <IconSpinner size={14} className="border-white" />}
|
||||
{t('settings.save', 'Save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MarkdownRenderer } from '../chat/MarkdownRenderer'
|
||||
import { IconChevronDown, IconDocument, IconCheckCircle } from '../common/Icons'
|
||||
import { getApiClient } from '../../lib/api'
|
||||
import { STATUS_BADGE, PRIORITY_BADGE, isTaskLocked, TERMINAL_STATUSES } from '../../types/team'
|
||||
import type { TeamTaskData, TeamMemberData, TeamTaskAttachment } from '../../types/team'
|
||||
|
||||
/** Human-readable file size */
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/** Metadata label + value pair */
|
||||
export function MetaItem({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs text-text-muted mb-0.5">{label}</dt>
|
||||
<dd className="text-sm font-medium text-text-primary">{children}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Collapsible bordered section */
|
||||
export function CollapsibleSection({ title, icon, defaultOpen = true, children }: {
|
||||
title: string; icon: React.ReactNode; defaultOpen?: boolean; children: React.ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div className="rounded-lg border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2 px-4 py-3 text-sm font-medium text-text-muted hover:text-text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
<IconChevronDown className={`ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TaskDetailBodyProps {
|
||||
task: TeamTaskData
|
||||
members: TeamMemberData[]
|
||||
attachments: TeamTaskAttachment[]
|
||||
}
|
||||
|
||||
export function TaskDetailBody({ task, members, attachments }: TaskDetailBodyProps) {
|
||||
const { t } = useTranslation('teams')
|
||||
|
||||
const prio = PRIORITY_BADGE[task.priority] ?? PRIORITY_BADGE[3]
|
||||
const statusCls = STATUS_BADGE[task.status] ?? ''
|
||||
const locked = isTaskLocked(task)
|
||||
const isTerminal = TERMINAL_STATUSES.has(task.status)
|
||||
const member = task.owner_agent_id ? members.find((m) => m.agent_id === task.owner_agent_id) : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Progress bar */}
|
||||
{task.progress_percent != null && task.progress_percent > 0 && !isTerminal && (() => {
|
||||
const pct = Math.min(100, Math.max(0, task.progress_percent))
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-text-muted">
|
||||
<span>{t('progress', 'Progress')}</span>
|
||||
<span>{pct}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-surface-tertiary overflow-hidden">
|
||||
<div className="h-2 rounded-full bg-accent transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
{task.progress_step && <p className="text-xs text-text-muted">{task.progress_step}</p>}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Status badges row */}
|
||||
<div className="flex items-center gap-2">
|
||||
{task.identifier && (
|
||||
<span className="text-xs font-mono text-text-muted bg-surface-tertiary px-2 py-0.5 rounded border border-border">
|
||||
{task.identifier}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded capitalize ${statusCls}`}>
|
||||
{task.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
{locked && (
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded bg-green-500/15 text-green-600 dark:text-green-400 animate-pulse">
|
||||
Running
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata grid */}
|
||||
<dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-3 rounded-lg bg-surface-tertiary/30 p-4">
|
||||
<MetaItem label={t('priority', 'Priority')}>
|
||||
<span className="inline-flex items-center gap-1.5 capitalize">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${prio.cls}`}>{prio.label}</span>
|
||||
</span>
|
||||
</MetaItem>
|
||||
<MetaItem label={t('owner', 'Owner')}>
|
||||
{member ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
{member.emoji && <span className="text-base">{member.emoji}</span>}
|
||||
{member.display_name || member.agent_key}
|
||||
</span>
|
||||
) : task.owner_agent_key || '—'}
|
||||
</MetaItem>
|
||||
{task.task_type && task.task_type !== 'general' && (
|
||||
<MetaItem label={t('type', 'Type')}>
|
||||
<span className="text-xs bg-surface-tertiary border border-border px-2 py-0.5 rounded">{task.task_type}</span>
|
||||
</MetaItem>
|
||||
)}
|
||||
{task.created_at && (
|
||||
<MetaItem label={t('created', 'Created')}>{new Date(task.created_at).toLocaleString()}</MetaItem>
|
||||
)}
|
||||
{task.updated_at && (
|
||||
<MetaItem label={t('updated', 'Updated')}>{new Date(task.updated_at).toLocaleString()}</MetaItem>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{/* Blocked by */}
|
||||
{task.blocked_by && task.blocked_by.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-text-muted">{t('blockedBy', 'Blocked by')}</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{task.blocked_by.map((id) => (
|
||||
<span key={id} className="text-xs font-mono bg-amber-500/15 text-amber-600 dark:text-amber-400 px-2 py-0.5 rounded border border-amber-500/20">
|
||||
{id.slice(0, 8)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{task.description && (
|
||||
<CollapsibleSection title={t('description', 'Description')} icon={<IconDocument />}>
|
||||
<div className="text-sm text-text-secondary prose prose-sm dark:prose-invert max-w-none max-h-60 overflow-y-auto">
|
||||
<MarkdownRenderer content={task.description} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{task.result && (
|
||||
<CollapsibleSection title={t('result', 'Result')} icon={<IconCheckCircle />}>
|
||||
<div className="text-sm text-text-secondary prose prose-sm dark:prose-invert max-w-none max-h-[40vh] overflow-y-auto">
|
||||
<MarkdownRenderer content={task.result} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Attachments */}
|
||||
{attachments.length > 0 && (
|
||||
<CollapsibleSection title={`${t('attachments', 'Attachments')} (${attachments.length})`} icon={<IconDocument />}>
|
||||
<div className="space-y-2">
|
||||
{attachments.map((a) => {
|
||||
const fileName = a.path?.split('/').pop() || 'file'
|
||||
const baseUrl = getApiClient()?.getBaseUrl() || ''
|
||||
const fullUrl = a.download_url?.startsWith('http') ? a.download_url : `${baseUrl}${a.download_url}`
|
||||
return (
|
||||
<div key={a.id} className="flex items-center gap-3 rounded-lg border border-border bg-surface-tertiary/30 p-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent/10">
|
||||
<IconDocument className="text-accent" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium text-text-primary">{fileName}</p>
|
||||
{a.file_size > 0 && <p className="text-xs text-text-muted">{formatFileSize(a.file_size)}</p>}
|
||||
</div>
|
||||
{a.download_url && (
|
||||
<a
|
||||
href={fullUrl}
|
||||
download
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0 text-xs text-accent hover:text-accent/80 px-3 py-1.5 rounded-lg border border-accent/30 hover:bg-accent/10 transition-colors cursor-pointer"
|
||||
>
|
||||
{t('download', 'Download')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
import { IconClose, IconPlus, IconUser, IconSpinner } from '../common/Icons'
|
||||
import type { TeamMemberData } from '../../types/team'
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
lead: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
|
||||
reviewer: 'bg-orange-500/15 text-orange-600 dark:text-orange-400',
|
||||
member: 'bg-surface-tertiary text-text-muted',
|
||||
}
|
||||
|
||||
interface TeamMemberListProps {
|
||||
members: TeamMemberData[]
|
||||
sorted: TeamMemberData[]
|
||||
availableAgents: { value: string; label: string }[]
|
||||
showAdd: boolean
|
||||
addAgent: string
|
||||
adding: boolean
|
||||
removing: string | null
|
||||
onToggleAdd: () => void
|
||||
onAddAgentChange: (id: string) => void
|
||||
onAddMember: () => void
|
||||
onRemoveMember: (agentId: string) => void
|
||||
}
|
||||
|
||||
export function TeamMemberList({
|
||||
members, sorted, availableAgents,
|
||||
showAdd, addAgent, adding, removing,
|
||||
onToggleAdd, onAddAgentChange, onAddMember, onRemoveMember,
|
||||
}: TeamMemberListProps) {
|
||||
const { t } = useTranslation('teams')
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-text-primary">{t('members', 'Members')} ({members.length})</h3>
|
||||
<button onClick={onToggleAdd} className="text-xs text-accent hover:text-accent/80 cursor-pointer flex items-center gap-1">
|
||||
<IconPlus size={12} />{t('settings.addMember', 'Add member')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Combobox value={addAgent} onChange={onAddAgentChange} options={availableAgents} placeholder={t('settings.searchAgent', 'Search agent...')} allowCustom={false} />
|
||||
</div>
|
||||
<button
|
||||
onClick={onAddMember}
|
||||
disabled={!addAgent || adding}
|
||||
className="shrink-0 px-3 py-1.5 text-xs font-medium bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{adding ? '...' : t('settings.add', 'Add')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border divide-y divide-border max-h-[200px] overflow-y-auto">
|
||||
{sorted.map((m) => (
|
||||
<div key={m.agent_id} className="group flex items-center gap-3 px-3 py-2.5 hover:bg-surface-tertiary/50">
|
||||
{m.emoji ? <span className="text-base shrink-0">{m.emoji}</span> : <IconUser className="text-text-muted shrink-0" />}
|
||||
<span className="text-sm text-text-primary truncate flex-1">{m.display_name || m.agent_key || m.agent_id.slice(0, 8)}</span>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${ROLE_COLORS[m.role] ?? ''}`}>{m.role}</span>
|
||||
{m.role !== 'lead' && members.filter((x) => x.role !== 'lead').length > 1 && (
|
||||
<button
|
||||
onClick={() => onRemoveMember(m.agent_id)}
|
||||
disabled={removing === m.agent_id}
|
||||
className="opacity-0 group-hover:opacity-100 text-text-muted hover:text-error cursor-pointer transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{removing === m.agent_id ? <IconSpinner size={14} className="border-text-muted" /> : <IconClose size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<div className="px-3 py-4 text-center text-xs text-text-muted">{t('settings.noMembers', 'No members')}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TERMINAL_STATUSES } from '../../types/team'
|
||||
import type { TeamTaskData, TeamMemberData } from '../../types/team'
|
||||
|
||||
interface TeamTaskListViewProps {
|
||||
tasks: TeamTaskData[]
|
||||
members: TeamMemberData[]
|
||||
loading: boolean
|
||||
selected: Set<string>
|
||||
onSelectChange: (next: Set<string>) => void
|
||||
onTaskClick: (task: TeamTaskData) => void
|
||||
onBulkDelete: () => void
|
||||
}
|
||||
|
||||
export function TeamTaskListView({
|
||||
tasks, members, loading, selected, onSelectChange, onTaskClick, onBulkDelete,
|
||||
}: TeamTaskListViewProps) {
|
||||
const { t } = useTranslation('teams')
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center gap-2 mb-2 px-2">
|
||||
<span className="text-xs text-text-muted">{selected.size} selected</span>
|
||||
<button onClick={onBulkDelete} className="text-xs text-error hover:underline cursor-pointer">
|
||||
{t('deleteSelected', 'Delete selected')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="text-text-muted text-left border-b border-border">
|
||||
<th className="pb-2 pr-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.size > 0 && selected.size === tasks.filter((t) => TERMINAL_STATUSES.has(t.status)).length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
onSelectChange(new Set(tasks.filter((t) => TERMINAL_STATUSES.has(t.status)).map((t) => t.id)))
|
||||
} else {
|
||||
onSelectChange(new Set())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
<th className="pb-2 pr-2 font-medium">{t('id', 'ID')}</th>
|
||||
<th className="pb-2 pr-2 font-medium">{t('subject', 'Subject')}</th>
|
||||
<th className="pb-2 pr-2 font-medium">{t('status', 'Status')}</th>
|
||||
<th className="pb-2 pr-2 font-medium">{t('owner', 'Owner')}</th>
|
||||
<th className="pb-2 font-medium">{t('priority', 'Priority')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task) => {
|
||||
const member = task.owner_agent_id ? members.find((m) => m.agent_id === task.owner_agent_id) : undefined
|
||||
const canSelect = TERMINAL_STATUSES.has(task.status)
|
||||
return (
|
||||
<tr
|
||||
key={task.id}
|
||||
onClick={() => onTaskClick(task)}
|
||||
className="border-b border-border/50 hover:bg-surface-tertiary/50 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="py-2 pr-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!canSelect}
|
||||
checked={selected.has(task.id)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(selected)
|
||||
e.target.checked ? next.add(task.id) : next.delete(task.id)
|
||||
onSelectChange(next)
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2 font-mono text-text-muted">{task.identifier || task.task_number}</td>
|
||||
<td className="py-2 pr-2 text-text-primary truncate max-w-[250px]">{task.subject}</td>
|
||||
<td className="py-2 pr-2">
|
||||
<span className="capitalize text-text-secondary">{task.status.replace('_', ' ')}</span>
|
||||
</td>
|
||||
<td className="py-2 pr-2 text-text-secondary">{member?.display_name || task.owner_agent_key || '—'}</td>
|
||||
<td className="py-2 text-text-secondary">P-{task.priority}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{tasks.length === 0 && !loading && (
|
||||
<div className="text-center py-8 text-text-muted text-xs">{t('noTasks', 'No tasks yet')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+4
-151
@@ -12,25 +12,12 @@ import {
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Switch } from '../../common/Switch'
|
||||
import { Combobox } from '../../common/Combobox'
|
||||
import { getApiClient } from '../../../lib/api'
|
||||
import type { BuiltinToolData } from '../../../types/builtin-tool'
|
||||
|
||||
interface ProviderEntry {
|
||||
id: string
|
||||
provider: string
|
||||
model: string
|
||||
enabled: boolean
|
||||
timeout: number
|
||||
max_retries: number
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
import { getApiClient } from '../../lib/api'
|
||||
import { SortableProviderCard, type ProviderEntry } from './sortable-provider-card'
|
||||
import type { BuiltinToolData } from '../../types/builtin-tool'
|
||||
|
||||
interface ModelInfo {
|
||||
id: string
|
||||
@@ -43,124 +30,6 @@ interface MediaProviderChainFormProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/* ─── Sortable Card ─── */
|
||||
|
||||
function SortableProviderCard({
|
||||
entry,
|
||||
index,
|
||||
providerOptions,
|
||||
modelOptions,
|
||||
modelLoading,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
onProviderChange,
|
||||
}: {
|
||||
entry: ProviderEntry
|
||||
index: number
|
||||
providerOptions: { value: string; label: string }[]
|
||||
modelOptions: { value: string; label: string }[]
|
||||
modelLoading: boolean
|
||||
onUpdate: (id: string, updates: Partial<ProviderEntry>) => void
|
||||
onRemove: (id: string) => void
|
||||
onProviderChange: (id: string, providerName: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation(['tools', 'common'])
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="rounded-lg border border-border p-3 space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Drag handle */}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-grab text-text-muted hover:text-text-primary shrink-0 touch-none"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="9" cy="6" r="1.5" /><circle cx="15" cy="6" r="1.5" />
|
||||
<circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" />
|
||||
<circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-[11px] font-mono text-text-muted bg-surface-tertiary rounded px-1.5 py-0.5">#{index + 1}</span>
|
||||
<span className="text-sm font-medium text-text-primary">{entry.provider || t('builtin.mediaChain.newProvider')}</span>
|
||||
{entry.model && <span className="text-xs text-text-muted">/ {entry.model}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={entry.enabled} onCheckedChange={(v) => onUpdate(entry.id, { enabled: v })} />
|
||||
<button onClick={() => onRemove(entry.id)} className="p-1 text-text-muted hover:text-error transition-colors">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider + Model */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.provider')}</label>
|
||||
<Combobox
|
||||
value={entry.provider}
|
||||
onChange={(v) => onProviderChange(entry.id, v)}
|
||||
options={providerOptions}
|
||||
placeholder={t('builtin.mediaChain.selectProvider')}
|
||||
allowCustom
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.model')}</label>
|
||||
<Combobox
|
||||
value={entry.model}
|
||||
onChange={(v) => onUpdate(entry.id, { model: v })}
|
||||
options={modelOptions}
|
||||
placeholder={t('builtin.mediaChain.selectModel')}
|
||||
loading={modelLoading}
|
||||
allowCustom
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeout + Retries */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.timeout')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={600}
|
||||
value={entry.timeout}
|
||||
onChange={(e) => onUpdate(entry.id, { timeout: Math.max(1, Number(e.target.value)) })}
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.retries')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={entry.max_retries}
|
||||
onChange={(e) => onUpdate(entry.id, { max_retries: Math.max(0, Number(e.target.value)) })}
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Main Form ─── */
|
||||
|
||||
export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderChainFormProps) {
|
||||
const { t } = useTranslation(['tools', 'common'])
|
||||
const [chain, setChain] = useState<ProviderEntry[]>([])
|
||||
@@ -169,11 +38,8 @@ export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderC
|
||||
|
||||
const [dbProviders, setDbProviders] = useState<{ id: string; name: string }[]>([])
|
||||
const [modelsByProvider, setModelsByProvider] = useState<Record<string, ModelInfo[]>>({})
|
||||
|
||||
// Track which providers we've already started loading models for
|
||||
const loadingRef = useRef<Set<string>>(new Set())
|
||||
|
||||
// Parse initial chain entries with stable IDs
|
||||
useEffect(() => {
|
||||
const settings = tool.settings as { providers?: Omit<ProviderEntry, 'id'>[] }
|
||||
const entries = (settings?.providers ?? []).map((p) => ({
|
||||
@@ -188,7 +54,6 @@ export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderC
|
||||
setSaving(false)
|
||||
loadingRef.current.clear()
|
||||
|
||||
// Fetch providers list
|
||||
getApiClient().get<{ providers: { id: string; name: string; display_name?: string }[] | null }>('/v1/providers')
|
||||
.then((res) => {
|
||||
setDbProviders((res.providers ?? []).map((p) => ({ id: p.id, name: p.name })))
|
||||
@@ -215,7 +80,6 @@ export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderC
|
||||
const res = await getApiClient().get<{ models?: ModelInfo[] }>(`/v1/providers/${prov.id}/models`)
|
||||
setModelsByProvider((prev) => ({ ...prev, [providerName]: res.models ?? [] }))
|
||||
} catch {
|
||||
// Mark as loaded with empty list so we don't retry — user can still type manually
|
||||
setModelsByProvider((prev) => ({ ...prev, [providerName]: [] }))
|
||||
}
|
||||
}
|
||||
@@ -234,21 +98,13 @@ export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderC
|
||||
}, [])
|
||||
|
||||
function addEntry() {
|
||||
setChain((prev) => [...prev, {
|
||||
id: crypto.randomUUID(),
|
||||
provider: '',
|
||||
model: '',
|
||||
enabled: true,
|
||||
timeout: 120,
|
||||
max_retries: 2,
|
||||
}])
|
||||
setChain((prev) => [...prev, { id: crypto.randomUUID(), provider: '', model: '', enabled: true, timeout: 120, max_retries: 2 }])
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
// Strip internal `id` field before saving
|
||||
const serialized = chain.map(({ id: _id, ...rest }) => rest)
|
||||
await onSave(tool.name, { providers: serialized })
|
||||
onClose()
|
||||
@@ -259,7 +115,6 @@ export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderC
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-and-drop
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
@@ -277,8 +132,6 @@ export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderC
|
||||
}, [])
|
||||
|
||||
const providerOptions = dbProviders.map((p) => ({ value: p.name, label: p.name }))
|
||||
|
||||
// Disable save if any entry is missing provider or model
|
||||
const hasIncomplete = chain.some((e) => !e.provider || !e.model)
|
||||
|
||||
return (
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBuiltinTools } from '../../../hooks/use-builtin-tools'
|
||||
import { Switch } from '../../common/Switch'
|
||||
import { RefreshButton } from '../../common/RefreshButton'
|
||||
import { useBuiltinTools } from '../../hooks/use-builtin-tools'
|
||||
import { Switch } from '../common/Switch'
|
||||
import { RefreshButton } from '../common/RefreshButton'
|
||||
import { ToolSettingsDialog } from './ToolSettingsDialog'
|
||||
import type { BuiltinToolData } from '../../../types/builtin-tool'
|
||||
import type { BuiltinToolData } from '../../types/builtin-tool'
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
'filesystem', 'runtime', 'web', 'memory', 'media', 'browser',
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MediaProviderChainForm } from './MediaProviderChainForm'
|
||||
import { ExtractorChainForm } from './extractor-chain-form'
|
||||
import { JsonSettingsForm } from './json-settings-form'
|
||||
import type { BuiltinToolData } from '../../types/builtin-tool'
|
||||
|
||||
const MEDIA_TOOLS = new Set([
|
||||
'read_image', 'read_document', 'read_audio', 'read_video',
|
||||
'create_image', 'create_video', 'create_audio',
|
||||
])
|
||||
|
||||
interface ToolSettingsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
tool: BuiltinToolData
|
||||
onSave: (name: string, settings: Record<string, unknown>) => Promise<void>
|
||||
}
|
||||
|
||||
export function ToolSettingsDialog({ open, onOpenChange, tool, onSave }: ToolSettingsDialogProps) {
|
||||
const { t } = useTranslation(['tools', 'common'])
|
||||
if (!open) return null
|
||||
|
||||
const handleClose = () => onOpenChange(false)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={handleClose} />
|
||||
<div className="relative w-full max-w-2xl mx-4 bg-surface-secondary rounded-xl border border-border overflow-hidden flex flex-col" style={{ maxHeight: '85vh' }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('builtin.settingsDialog.title', { name: tool.display_name })}</h3>
|
||||
<p className="font-mono text-[11px] text-text-muted mt-0.5">{tool.name}</p>
|
||||
</div>
|
||||
<button onClick={handleClose} className="p-1 text-text-muted hover:text-text-primary transition-colors">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Route to specialized form */}
|
||||
{tool.name === 'web_fetch' ? (
|
||||
<ExtractorChainForm tool={tool} onSave={onSave} onClose={handleClose} />
|
||||
) : MEDIA_TOOLS.has(tool.name) ? (
|
||||
<MediaProviderChainForm tool={tool} onSave={onSave} onClose={handleClose} />
|
||||
) : (
|
||||
<JsonSettingsForm tool={tool} onSave={onSave} onClose={handleClose} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Switch } from '../common/Switch'
|
||||
import type { BuiltinToolData } from '../../types/builtin-tool'
|
||||
|
||||
interface ExtractorEntry {
|
||||
name: string
|
||||
enabled: boolean
|
||||
base_url?: string
|
||||
timeout?: number
|
||||
max_retries?: number
|
||||
}
|
||||
|
||||
const EXTRACTOR_DISPLAY: Record<string, string> = {
|
||||
defuddle: 'Defuddle',
|
||||
'html-to-markdown': 'HTML to Markdown',
|
||||
}
|
||||
|
||||
interface ExtractorChainFormProps {
|
||||
tool: BuiltinToolData
|
||||
onSave: (name: string, settings: Record<string, unknown>) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ExtractorChainForm({ tool, onSave, onClose }: ExtractorChainFormProps) {
|
||||
const { t } = useTranslation(['tools', 'common'])
|
||||
const [extractors, setExtractors] = useState<ExtractorEntry[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const settings = tool.settings as { extractors?: ExtractorEntry[] }
|
||||
setExtractors(settings?.extractors ?? [])
|
||||
setError('')
|
||||
setSaving(false)
|
||||
}, [tool])
|
||||
|
||||
function updateExtractor(index: number, updates: Partial<ExtractorEntry>) {
|
||||
setExtractors((prev) => prev.map((e, i) => i === index ? { ...e, ...updates } : e))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
await onSave(tool.name, { extractors })
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError((err as Error).message || 'Failed to save')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-h-[60vh] overflow-y-auto p-5 space-y-3">
|
||||
{extractors.map((ext, i) => (
|
||||
<div key={ext.name} className="rounded-lg border border-border p-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-mono text-text-muted bg-surface-tertiary rounded px-1.5 py-0.5">#{i + 1}</span>
|
||||
<span className="text-sm font-medium text-text-primary">{EXTRACTOR_DISPLAY[ext.name] ?? ext.name}</span>
|
||||
</div>
|
||||
<Switch checked={ext.enabled} onCheckedChange={(v) => updateExtractor(i, { enabled: v })} />
|
||||
</div>
|
||||
|
||||
{ext.name === 'defuddle' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('builtin.extractorChain.baseUrl')}</label>
|
||||
<input
|
||||
value={ext.base_url ?? ''}
|
||||
onChange={(e) => updateExtractor(i, { base_url: e.target.value })}
|
||||
placeholder="https://fetch.goclaw.sh/"
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.retries')}</label>
|
||||
<input
|
||||
type="number" min={0} max={10}
|
||||
value={ext.max_retries ?? 2}
|
||||
onChange={(e) => updateExtractor(i, { max_retries: Math.max(0, Number(e.target.value)) })}
|
||||
className="w-20 bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(ext.name === 'defuddle' || (ext.timeout && ext.timeout > 0)) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('builtin.extractorChain.timeout')}</label>
|
||||
<input
|
||||
type="number" min={0} max={600}
|
||||
value={ext.timeout ?? 0}
|
||||
onChange={(e) => updateExtractor(i, { timeout: Math.max(0, Number(e.target.value)) })}
|
||||
className="w-20 bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted">0 = {t('common:default')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{extractors.length === 0 && (
|
||||
<p className="text-xs text-text-muted text-center py-4">{t('builtin.mediaChain.noProviders')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="px-5"><p className="text-xs text-error">{error}</p></div>}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-4">
|
||||
<button type="button" onClick={onClose} className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors">
|
||||
{t('builtin.settingsDialog.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={handleSave} disabled={saving} className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors">
|
||||
{saving ? t('builtin.settingsDialog.saving') : t('builtin.settingsDialog.save')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BuiltinToolData } from '../../types/builtin-tool'
|
||||
|
||||
interface JsonSettingsFormProps {
|
||||
tool: BuiltinToolData
|
||||
onSave: (name: string, settings: Record<string, unknown>) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function JsonSettingsForm({ tool, onSave, onClose }: JsonSettingsFormProps) {
|
||||
const { t } = useTranslation(['tools', 'common'])
|
||||
const [value, setValue] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setValue(JSON.stringify(tool.settings, null, 2))
|
||||
setError('')
|
||||
setSaving(false)
|
||||
}, [tool])
|
||||
|
||||
function validate(json: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
setError(t('builtin.jsonDialog.invalidJson'))
|
||||
return null
|
||||
}
|
||||
setError('')
|
||||
return parsed as Record<string, unknown>
|
||||
} catch {
|
||||
setError(t('builtin.jsonDialog.invalidJson'))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function handleFormat() {
|
||||
const parsed = validate(value)
|
||||
if (parsed) setValue(JSON.stringify(parsed, null, 2))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const parsed = validate(value)
|
||||
if (!parsed) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(tool.name, parsed)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError((err as Error).message || t('builtin.jsonDialog.invalidJson'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-5">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => { setValue(e.target.value); setError('') }}
|
||||
spellCheck={false}
|
||||
className="w-full h-64 bg-surface-tertiary border border-border rounded-lg px-3 py-2 font-mono text-base md:text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent resize-y"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-1 min-h-[20px]">
|
||||
{error ? <span className="text-xs text-error">{error}</span> : <span />}
|
||||
<button type="button" onClick={handleFormat} className="text-[11px] text-accent hover:text-accent-hover transition-colors">
|
||||
{t('builtin.jsonDialog.formatJson')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-4">
|
||||
<button type="button" onClick={onClose} className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors">
|
||||
{t('builtin.jsonDialog.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={handleSave} disabled={!!error || saving} className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors">
|
||||
{saving ? t('builtin.jsonDialog.saving') : t('builtin.jsonDialog.save')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Switch } from '../common/Switch'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
|
||||
export interface ProviderEntry {
|
||||
id: string
|
||||
provider: string
|
||||
model: string
|
||||
enabled: boolean
|
||||
timeout: number
|
||||
max_retries: number
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface SortableProviderCardProps {
|
||||
entry: ProviderEntry
|
||||
index: number
|
||||
providerOptions: { value: string; label: string }[]
|
||||
modelOptions: { value: string; label: string }[]
|
||||
modelLoading: boolean
|
||||
onUpdate: (id: string, updates: Partial<ProviderEntry>) => void
|
||||
onRemove: (id: string) => void
|
||||
onProviderChange: (id: string, providerName: string) => void
|
||||
}
|
||||
|
||||
export function SortableProviderCard({
|
||||
entry, index, providerOptions, modelOptions, modelLoading,
|
||||
onUpdate, onRemove, onProviderChange,
|
||||
}: SortableProviderCardProps) {
|
||||
const { t } = useTranslation(['tools', 'common'])
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="rounded-lg border border-border p-3 space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" className="cursor-grab text-text-muted hover:text-text-primary shrink-0 touch-none" {...attributes} {...listeners}>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="9" cy="6" r="1.5" /><circle cx="15" cy="6" r="1.5" />
|
||||
<circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" />
|
||||
<circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-[11px] font-mono text-text-muted bg-surface-tertiary rounded px-1.5 py-0.5">#{index + 1}</span>
|
||||
<span className="text-sm font-medium text-text-primary">{entry.provider || t('builtin.mediaChain.newProvider')}</span>
|
||||
{entry.model && <span className="text-xs text-text-muted">/ {entry.model}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={entry.enabled} onCheckedChange={(v) => onUpdate(entry.id, { enabled: v })} />
|
||||
<button onClick={() => onRemove(entry.id)} className="p-1 text-text-muted hover:text-error transition-colors">
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider + Model */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.provider')}</label>
|
||||
<Combobox value={entry.provider} onChange={(v) => onProviderChange(entry.id, v)} options={providerOptions} placeholder={t('builtin.mediaChain.selectProvider')} allowCustom />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.model')}</label>
|
||||
<Combobox value={entry.model} onChange={(v) => onUpdate(entry.id, { model: v })} options={modelOptions} placeholder={t('builtin.mediaChain.selectModel')} loading={modelLoading} allowCustom />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeout + Retries */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.timeout')}</label>
|
||||
<input type="number" min={1} max={600} value={entry.timeout}
|
||||
onChange={(e) => onUpdate(entry.id, { timeout: Math.max(1, Number(e.target.value)) })}
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.retries')}</label>
|
||||
<input type="number" min={0} max={10} value={entry.max_retries}
|
||||
onChange={(e) => onUpdate(entry.id, { max_retries: Math.max(0, Number(e.target.value)) })}
|
||||
className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+17
-183
@@ -1,190 +1,18 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '../../../stores/toast-store'
|
||||
import { fetchTraceDetail } from '../../../hooks/use-traces'
|
||||
import { getApiClient, isApiClientReady } from '../../../lib/api'
|
||||
import { DownloadURL } from '../../../../wailsjs/go/main/App'
|
||||
import type { TraceData, SpanData } from '../../../types/trace'
|
||||
import { toast } from '../../stores/toast-store'
|
||||
import { fetchTraceDetail } from '../../hooks/use-traces'
|
||||
import { getApiClient, isApiClientReady } from '../../lib/api'
|
||||
import { DownloadURL } from '../../../wailsjs/go/main/App'
|
||||
import type { TraceData, SpanData } from '../../types/trace'
|
||||
import { formatDuration, formatTokens, statusClass } from './trace-detail-formatters'
|
||||
import { buildSpanTree, SpanRow } from './trace-span-tree'
|
||||
|
||||
interface Props {
|
||||
traceId: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
import { MarkdownRenderer } from '../../chat/MarkdownRenderer'
|
||||
|
||||
function TraceContentPreview({ text }: { text?: string }) {
|
||||
if (!text) return null
|
||||
const trimmed = text.trim()
|
||||
// Auto-format JSON into a markdown code block
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(trimmed), null, 2)
|
||||
return (
|
||||
<div className="max-h-[40vh] overflow-y-auto overflow-x-hidden">
|
||||
<MarkdownRenderer content={'```json\n' + formatted + '\n```'} />
|
||||
</div>
|
||||
)
|
||||
} catch { /* not valid JSON, fall through */ }
|
||||
}
|
||||
// Render as markdown (handles code blocks, headings, lists, etc.)
|
||||
return (
|
||||
<div className="max-h-[40vh] overflow-y-auto overflow-x-hidden">
|
||||
<MarkdownRenderer content={text} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | undefined | null, startTime?: string, endTime?: string): string {
|
||||
// Fallback: compute from start/end times if ms is missing
|
||||
if (ms == null || isNaN(ms) || ms === 0) {
|
||||
if (startTime && endTime) {
|
||||
const computed = new Date(endTime).getTime() - new Date(startTime).getTime()
|
||||
if (!isNaN(computed) && computed > 0) ms = computed
|
||||
else return '—'
|
||||
} else {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
const min = Math.floor(ms / 60000)
|
||||
const sec = Math.round((ms % 60000) / 1000)
|
||||
return `${min}m ${sec}s`
|
||||
}
|
||||
|
||||
function formatTokens(count: number | null | undefined): string {
|
||||
if (count == null) return '0'
|
||||
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`
|
||||
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
function statusClass(status: string): string {
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'completed' || s === 'ok' || s === 'success') {
|
||||
return 'bg-emerald-500/15 text-emerald-700 border-emerald-500/25 dark:text-emerald-400'
|
||||
}
|
||||
if (s === 'error' || s === 'failed') {
|
||||
return 'bg-red-500/15 text-red-700 border-red-500/25 dark:text-red-400'
|
||||
}
|
||||
return 'bg-blue-500/15 text-blue-700 border-blue-500/25 dark:text-blue-400'
|
||||
}
|
||||
|
||||
function spanTypeIcon(spanType: string): string {
|
||||
switch (spanType) {
|
||||
case 'llm_call': return '🤖'
|
||||
case 'tool_call': return '🔧'
|
||||
case 'agent': return '👤'
|
||||
case 'embedding': return '📊'
|
||||
default: return '📌'
|
||||
}
|
||||
}
|
||||
|
||||
// Build span tree from flat list using parent_span_id
|
||||
interface SpanNode { span: SpanData; children: SpanNode[]; depth: number }
|
||||
|
||||
function buildSpanTree(spans: SpanData[]): SpanNode[] {
|
||||
const byId = new Map<string, SpanNode>()
|
||||
const roots: SpanNode[] = []
|
||||
for (const span of spans) {
|
||||
byId.set(span.id, { span, children: [], depth: 0 })
|
||||
}
|
||||
for (const span of spans) {
|
||||
const node = byId.get(span.id)!
|
||||
const parentId = span.parent_span_id
|
||||
if (parentId && byId.has(parentId)) {
|
||||
const parent = byId.get(parentId)!
|
||||
node.depth = parent.depth + 1
|
||||
parent.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
// Flatten tree in DFS order
|
||||
const flat: SpanNode[] = []
|
||||
function walk(nodes: SpanNode[]) {
|
||||
for (const n of nodes) { flat.push(n); walk(n.children) }
|
||||
}
|
||||
walk(roots)
|
||||
return flat
|
||||
}
|
||||
|
||||
function SpanRow({ node, expanded, onToggle }: { node: SpanNode; expanded: boolean; onToggle: () => void }) {
|
||||
const { t } = useTranslation('traces')
|
||||
const { span, depth } = node
|
||||
const hasTokens = (span.input_tokens ?? 0) > 0 || (span.output_tokens ?? 0) > 0
|
||||
const subtitle = span.span_type === 'llm_call'
|
||||
? [span.model, span.provider].filter(Boolean).join(' / ')
|
||||
: span.span_type === 'tool_call' ? span.tool_name : undefined
|
||||
const cacheRead = (span.metadata?.cache_read_tokens as number) ?? 0
|
||||
const cacheCreate = (span.metadata?.cache_creation_tokens as number) ?? 0
|
||||
const thinkingTokens = (span.metadata?.thinking_tokens as number) ?? 0
|
||||
// Expandable only if there's content to show
|
||||
const hasExpandContent = !!span.input_preview || !!span.output_preview || !!span.error || cacheCreate > 0
|
||||
|
||||
return (
|
||||
<div className="border-b border-border last:border-0">
|
||||
{/* Row */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasExpandContent ? onToggle : undefined}
|
||||
className={`w-full flex items-center gap-1.5 px-3 py-2 text-left transition-colors ${hasExpandContent ? 'hover:bg-surface-tertiary/30 cursor-pointer' : ''}`}
|
||||
style={{ paddingLeft: `${12 + depth * 16}px` }}
|
||||
>
|
||||
{/* Toggle arrow — only visible if expandable */}
|
||||
<svg
|
||||
className={`h-3 w-3 shrink-0 text-text-muted transition-transform ${expanded ? 'rotate-90' : ''} ${!hasExpandContent ? 'invisible' : ''}`}
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
<span className="text-sm shrink-0">{spanTypeIcon(span.span_type)}</span>
|
||||
<span className="text-xs font-medium text-text-primary truncate min-w-0 flex-1">{span.name}</span>
|
||||
{subtitle && <span className="text-[10px] text-text-muted truncate max-w-[120px] shrink-0">{subtitle}</span>}
|
||||
<div className="flex items-center gap-2 shrink-0 text-[11px] text-text-muted ml-auto">
|
||||
{hasTokens && (
|
||||
<span className="font-mono">
|
||||
{formatTokens(span.input_tokens)}/{formatTokens(span.output_tokens)}
|
||||
{cacheRead > 0 && <span className="ml-1 text-emerald-600 dark:text-emerald-400">({formatTokens(cacheRead)} cached)</span>}
|
||||
{thinkingTokens > 0 && <span className="ml-1 text-orange-600 dark:text-orange-400">({formatTokens(thinkingTokens)} thinking)</span>}
|
||||
</span>
|
||||
)}
|
||||
<span>{formatDuration(span.duration_ms, span.start_time, span.end_time)}</span>
|
||||
<span className={`rounded-full px-1.5 py-0.5 border text-[10px] font-medium ${statusClass(span.status)}`}>
|
||||
{span.status}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded details — only input/output/error, no duplicate metadata */}
|
||||
{expanded && hasExpandContent && (
|
||||
<div className="px-4 pb-3 space-y-2" style={{ paddingLeft: `${28 + depth * 16}px` }}>
|
||||
{/* Cache breakdown (only shows extra info not in collapsed row) */}
|
||||
{cacheCreate > 0 && (
|
||||
<div className="text-[11px]">
|
||||
<span className="text-yellow-600 dark:text-yellow-400">+{formatTokens(cacheCreate)} cache write</span>
|
||||
</div>
|
||||
)}
|
||||
{span.error && <p className="text-[11px] text-red-600 dark:text-red-400">{span.error}</p>}
|
||||
{span.input_preview && (
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-text-secondary mb-1">{t('detail.input')}</p>
|
||||
<TraceContentPreview text={span.input_preview} />
|
||||
</div>
|
||||
)}
|
||||
{span.output_preview && (
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-text-secondary mb-1">{t('detail.output')}</p>
|
||||
<TraceContentPreview text={span.output_preview} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TraceDetailDialog({ traceId, onClose }: Props) {
|
||||
const { t } = useTranslation('traces')
|
||||
const [trace, setTrace] = useState<TraceData | null>(null)
|
||||
@@ -263,7 +91,6 @@ export function TraceDetailDialog({ traceId, onClose }: Props) {
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{trace && (
|
||||
<>
|
||||
{/* Copy trace ID */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={`p-1.5 transition-colors cursor-pointer ${copied ? 'text-emerald-500' : 'text-text-muted hover:text-text-primary'}`}
|
||||
@@ -279,7 +106,6 @@ export function TraceDetailDialog({ traceId, onClose }: Props) {
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{/* Export trace */}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="p-1.5 text-text-muted hover:text-text-primary transition-colors cursor-pointer"
|
||||
@@ -351,7 +177,11 @@ export function TraceDetailDialog({ traceId, onClose }: Props) {
|
||||
</svg>
|
||||
{t('detail.input')}
|
||||
</button>
|
||||
{inputOpen && <div className="mt-1.5"><TraceContentPreview text={trace.input_preview} /></div>}
|
||||
{inputOpen && (
|
||||
<div className="mt-1.5 max-h-[40vh] overflow-y-auto overflow-x-hidden">
|
||||
<pre className="text-xs text-text-primary whitespace-pre-wrap">{trace.input_preview}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{trace.output_preview && (
|
||||
@@ -365,7 +195,11 @@ export function TraceDetailDialog({ traceId, onClose }: Props) {
|
||||
</svg>
|
||||
{t('detail.output')}
|
||||
</button>
|
||||
{outputOpen && <div className="mt-1.5"><TraceContentPreview text={trace.output_preview} /></div>}
|
||||
{outputOpen && (
|
||||
<div className="mt-1.5 max-h-[40vh] overflow-y-auto overflow-x-hidden">
|
||||
<pre className="text-xs text-text-primary whitespace-pre-wrap">{trace.output_preview}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
+6
-113
@@ -1,116 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTraces } from '../../../hooks/use-traces'
|
||||
import { useAgentCrud } from '../../../hooks/use-agent-crud'
|
||||
import { RefreshButton } from '../../common/RefreshButton'
|
||||
import { Combobox } from '../../common/Combobox'
|
||||
import { useTraces } from '../../hooks/use-traces'
|
||||
import { useAgentCrud } from '../../hooks/use-agent-crud'
|
||||
import { RefreshButton } from '../common/RefreshButton'
|
||||
import { Combobox } from '../common/Combobox'
|
||||
import { TraceDetailDialog } from './TraceDetailDialog'
|
||||
import type { TraceData } from '../../../types/trace'
|
||||
|
||||
function formatDuration(ms: number | undefined | null, startTime?: string, endTime?: string): string {
|
||||
if (ms == null || isNaN(ms) || ms === 0) {
|
||||
if (startTime && endTime) {
|
||||
const computed = new Date(endTime).getTime() - new Date(startTime).getTime()
|
||||
if (!isNaN(computed) && computed > 0) ms = computed
|
||||
else return '—'
|
||||
} else {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
const min = Math.floor(ms / 60000)
|
||||
const sec = Math.round((ms % 60000) / 1000)
|
||||
return `${min}m ${sec}s`
|
||||
}
|
||||
|
||||
function formatTokens(count: number | null | undefined): string {
|
||||
if (count == null) return '0'
|
||||
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`
|
||||
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
if (diff < 60000) return 'just now'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
|
||||
return `${Math.floor(diff / 86400000)}d ago`
|
||||
}
|
||||
|
||||
function statusClass(status: string): string {
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'completed' || s === 'ok' || s === 'success') {
|
||||
return 'bg-emerald-500/15 text-emerald-700 border-emerald-500/25 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20'
|
||||
}
|
||||
if (s === 'error' || s === 'failed') {
|
||||
return 'bg-red-500/15 text-red-700 border-red-500/25 dark:text-red-400 dark:bg-red-500/10 dark:border-red-500/20'
|
||||
}
|
||||
return 'bg-blue-500/15 text-blue-700 border-blue-500/25 dark:text-blue-400 dark:bg-blue-500/10 dark:border-blue-500/20'
|
||||
}
|
||||
|
||||
function TraceRow({ trace, onClick }: { trace: TraceData; onClick: () => void }) {
|
||||
const { t } = useTranslation('traces')
|
||||
const cacheRead = trace.metadata?.total_cache_read_tokens as number | undefined
|
||||
const nameDisplay = trace.name.length > 30 ? trace.name.slice(0, 30) + '…' : trace.name
|
||||
|
||||
return (
|
||||
<tr
|
||||
onClick={onClick}
|
||||
className="border-b border-border last:border-0 hover:bg-surface-tertiary/30 transition-colors cursor-pointer [&>td]:align-middle"
|
||||
>
|
||||
{/* Name */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-sm text-text-primary truncate">{nameDisplay || t('unnamed')}</span>
|
||||
{trace.parent_trace_id && (
|
||||
<span title="Delegated" className="text-text-muted text-xs shrink-0">🔀</span>
|
||||
)}
|
||||
{trace.channel && (
|
||||
<span className="rounded-full px-1.5 py-0.5 text-[10px] bg-surface-tertiary text-text-secondary border border-border shrink-0">
|
||||
{trace.channel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium border ${statusClass(trace.status)}`}>
|
||||
{trace.status}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Duration */}
|
||||
<td className="px-4 py-3 text-xs text-text-muted">
|
||||
{formatDuration(trace.duration_ms, trace.start_time, trace.end_time)}
|
||||
</td>
|
||||
|
||||
{/* Tokens */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-mono text-xs text-text-primary">
|
||||
{formatTokens(trace.total_input_tokens)} / {formatTokens(trace.total_output_tokens)}
|
||||
</div>
|
||||
{(cacheRead ?? 0) > 0 && (
|
||||
<div className="text-[11px] text-emerald-600 dark:text-emerald-400">
|
||||
{formatTokens(cacheRead)} {t('cached')}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Spans */}
|
||||
<td className="px-4 py-3 text-xs text-text-muted text-center">
|
||||
{trace.span_count}
|
||||
</td>
|
||||
|
||||
{/* Time */}
|
||||
<td className="px-4 py-3 text-xs text-text-muted">
|
||||
{formatRelativeTime(trace.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
import { TraceListRow } from './trace-list-row'
|
||||
|
||||
export function TraceList() {
|
||||
const { t } = useTranslation('traces')
|
||||
@@ -178,7 +73,7 @@ export function TraceList() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{traces.map((trace) => (
|
||||
<TraceRow
|
||||
<TraceListRow
|
||||
key={trace.id}
|
||||
trace={trace}
|
||||
onClick={() => setSelectedTraceId(trace.id)}
|
||||
@@ -188,7 +83,6 @@ export function TraceList() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{!allLoaded && (
|
||||
<div className="flex justify-center pt-1">
|
||||
<button
|
||||
@@ -212,7 +106,6 @@ export function TraceList() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Detail dialog */}
|
||||
{selectedTraceId && (
|
||||
<TraceDetailDialog
|
||||
traceId={selectedTraceId}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Pure formatting utilities for trace detail display.
|
||||
|
||||
export function formatDuration(ms: number | undefined | null, startTime?: string, endTime?: string): string {
|
||||
// Fallback: compute from start/end times if ms is missing
|
||||
if (ms == null || isNaN(ms) || ms === 0) {
|
||||
if (startTime && endTime) {
|
||||
const computed = new Date(endTime).getTime() - new Date(startTime).getTime()
|
||||
if (!isNaN(computed) && computed > 0) ms = computed
|
||||
else return '—'
|
||||
} else {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
const min = Math.floor(ms / 60000)
|
||||
const sec = Math.round((ms % 60000) / 1000)
|
||||
return `${min}m ${sec}s`
|
||||
}
|
||||
|
||||
export function formatTokens(count: number | null | undefined): string {
|
||||
if (count == null) return '0'
|
||||
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`
|
||||
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
export function statusClass(status: string): string {
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'completed' || s === 'ok' || s === 'success') {
|
||||
return 'bg-emerald-500/15 text-emerald-700 border-emerald-500/25 dark:text-emerald-400'
|
||||
}
|
||||
if (s === 'error' || s === 'failed') {
|
||||
return 'bg-red-500/15 text-red-700 border-red-500/25 dark:text-red-400'
|
||||
}
|
||||
return 'bg-blue-500/15 text-blue-700 border-blue-500/25 dark:text-blue-400'
|
||||
}
|
||||
|
||||
export function spanTypeIcon(spanType: string): string {
|
||||
switch (spanType) {
|
||||
case 'llm_call': return '🤖'
|
||||
case 'tool_call': return '🔧'
|
||||
case 'agent': return '👤'
|
||||
case 'embedding': return '📊'
|
||||
default: return '📌'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Trace table row with status badge, duration, token counts, and relative time.
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { TraceData } from '../../types/trace'
|
||||
import { formatDuration, formatTokens, statusClass } from './trace-detail-formatters'
|
||||
|
||||
export function formatRelativeTime(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
if (diff < 60000) return 'just now'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
|
||||
return `${Math.floor(diff / 86400000)}d ago`
|
||||
}
|
||||
|
||||
interface TraceListRowProps {
|
||||
trace: TraceData
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export function TraceListRow({ trace, onClick }: TraceListRowProps) {
|
||||
const { t } = useTranslation('traces')
|
||||
const cacheRead = trace.metadata?.total_cache_read_tokens as number | undefined
|
||||
const nameDisplay = trace.name.length > 30 ? trace.name.slice(0, 30) + '…' : trace.name
|
||||
|
||||
return (
|
||||
<tr
|
||||
onClick={onClick}
|
||||
className="border-b border-border last:border-0 hover:bg-surface-tertiary/30 transition-colors cursor-pointer [&>td]:align-middle"
|
||||
>
|
||||
{/* Name */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-sm text-text-primary truncate">{nameDisplay || t('unnamed')}</span>
|
||||
{trace.parent_trace_id && (
|
||||
<span title="Delegated" className="text-text-muted text-xs shrink-0">🔀</span>
|
||||
)}
|
||||
{trace.channel && (
|
||||
<span className="rounded-full px-1.5 py-0.5 text-[10px] bg-surface-tertiary text-text-secondary border border-border shrink-0">
|
||||
{trace.channel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium border ${statusClass(trace.status)}`}>
|
||||
{trace.status}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Duration */}
|
||||
<td className="px-4 py-3 text-xs text-text-muted">
|
||||
{formatDuration(trace.duration_ms, trace.start_time, trace.end_time)}
|
||||
</td>
|
||||
|
||||
{/* Tokens */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-mono text-xs text-text-primary">
|
||||
{formatTokens(trace.total_input_tokens)} / {formatTokens(trace.total_output_tokens)}
|
||||
</div>
|
||||
{(cacheRead ?? 0) > 0 && (
|
||||
<div className="text-[11px] text-emerald-600 dark:text-emerald-400">
|
||||
{formatTokens(cacheRead)} {t('cached')}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Spans */}
|
||||
<td className="px-4 py-3 text-xs text-text-muted text-center">
|
||||
{trace.span_count}
|
||||
</td>
|
||||
|
||||
{/* Time */}
|
||||
<td className="px-4 py-3 text-xs text-text-muted">
|
||||
{formatRelativeTime(trace.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Span tree rendering — builds and displays hierarchical trace spans.
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MarkdownRenderer } from '../chat/MarkdownRenderer'
|
||||
import type { SpanData } from '../../types/trace'
|
||||
import { formatDuration, formatTokens, statusClass, spanTypeIcon } from './trace-detail-formatters'
|
||||
|
||||
// Build span tree from flat list using parent_span_id
|
||||
export interface SpanNode { span: SpanData; children: SpanNode[]; depth: number }
|
||||
|
||||
export function buildSpanTree(spans: SpanData[]): SpanNode[] {
|
||||
const byId = new Map<string, SpanNode>()
|
||||
const roots: SpanNode[] = []
|
||||
for (const span of spans) {
|
||||
byId.set(span.id, { span, children: [], depth: 0 })
|
||||
}
|
||||
for (const span of spans) {
|
||||
const node = byId.get(span.id)!
|
||||
const parentId = span.parent_span_id
|
||||
if (parentId && byId.has(parentId)) {
|
||||
const parent = byId.get(parentId)!
|
||||
node.depth = parent.depth + 1
|
||||
parent.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
// Flatten tree in DFS order
|
||||
const flat: SpanNode[] = []
|
||||
function walk(nodes: SpanNode[]) {
|
||||
for (const n of nodes) { flat.push(n); walk(n.children) }
|
||||
}
|
||||
walk(roots)
|
||||
return flat
|
||||
}
|
||||
|
||||
function TraceContentPreview({ text }: { text?: string }) {
|
||||
if (!text) return null
|
||||
const trimmed = text.trim()
|
||||
// Auto-format JSON into a markdown code block
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(trimmed), null, 2)
|
||||
return (
|
||||
<div className="max-h-[40vh] overflow-y-auto overflow-x-hidden">
|
||||
<MarkdownRenderer content={'```json\n' + formatted + '\n```'} />
|
||||
</div>
|
||||
)
|
||||
} catch { /* not valid JSON, fall through */ }
|
||||
}
|
||||
return (
|
||||
<div className="max-h-[40vh] overflow-y-auto overflow-x-hidden">
|
||||
<MarkdownRenderer content={text} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SpanRow({ node, expanded, onToggle }: { node: SpanNode; expanded: boolean; onToggle: () => void }) {
|
||||
const { t } = useTranslation('traces')
|
||||
const { span, depth } = node
|
||||
const hasTokens = (span.input_tokens ?? 0) > 0 || (span.output_tokens ?? 0) > 0
|
||||
const subtitle = span.span_type === 'llm_call'
|
||||
? [span.model, span.provider].filter(Boolean).join(' / ')
|
||||
: span.span_type === 'tool_call' ? span.tool_name : undefined
|
||||
const cacheRead = (span.metadata?.cache_read_tokens as number) ?? 0
|
||||
const cacheCreate = (span.metadata?.cache_creation_tokens as number) ?? 0
|
||||
const thinkingTokens = (span.metadata?.thinking_tokens as number) ?? 0
|
||||
const hasExpandContent = !!span.input_preview || !!span.output_preview || !!span.error || cacheCreate > 0
|
||||
|
||||
return (
|
||||
<div className="border-b border-border last:border-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasExpandContent ? onToggle : undefined}
|
||||
className={`w-full flex items-center gap-1.5 px-3 py-2 text-left transition-colors ${hasExpandContent ? 'hover:bg-surface-tertiary/30 cursor-pointer' : ''}`}
|
||||
style={{ paddingLeft: `${12 + depth * 16}px` }}
|
||||
>
|
||||
<svg
|
||||
className={`h-3 w-3 shrink-0 text-text-muted transition-transform ${expanded ? 'rotate-90' : ''} ${!hasExpandContent ? 'invisible' : ''}`}
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
<span className="text-sm shrink-0">{spanTypeIcon(span.span_type)}</span>
|
||||
<span className="text-xs font-medium text-text-primary truncate min-w-0 flex-1">{span.name}</span>
|
||||
{subtitle && <span className="text-[10px] text-text-muted truncate max-w-[120px] shrink-0">{subtitle}</span>}
|
||||
<div className="flex items-center gap-2 shrink-0 text-[11px] text-text-muted ml-auto">
|
||||
{hasTokens && (
|
||||
<span className="font-mono">
|
||||
{formatTokens(span.input_tokens)}/{formatTokens(span.output_tokens)}
|
||||
{cacheRead > 0 && <span className="ml-1 text-emerald-600 dark:text-emerald-400">({formatTokens(cacheRead)} cached)</span>}
|
||||
{thinkingTokens > 0 && <span className="ml-1 text-orange-600 dark:text-orange-400">({formatTokens(thinkingTokens)} thinking)</span>}
|
||||
</span>
|
||||
)}
|
||||
<span>{formatDuration(span.duration_ms, span.start_time, span.end_time)}</span>
|
||||
<span className={`rounded-full px-1.5 py-0.5 border text-[10px] font-medium ${statusClass(span.status)}`}>
|
||||
{span.status}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && hasExpandContent && (
|
||||
<div className="px-4 pb-3 space-y-2" style={{ paddingLeft: `${28 + depth * 16}px` }}>
|
||||
{cacheCreate > 0 && (
|
||||
<div className="text-[11px]">
|
||||
<span className="text-yellow-600 dark:text-yellow-400">+{formatTokens(cacheCreate)} cache write</span>
|
||||
</div>
|
||||
)}
|
||||
{span.error && <p className="text-[11px] text-red-600 dark:text-red-400">{span.error}</p>}
|
||||
{span.input_preview && (
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-text-secondary mb-1">{t('detail.input')}</p>
|
||||
<TraceContentPreview text={span.input_preview} />
|
||||
</div>
|
||||
)}
|
||||
{span.output_preview && (
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-text-secondary mb-1">{t('detail.output')}</p>
|
||||
<TraceContentPreview text={span.output_preview} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getApiClient, isApiClientReady } from '../lib/api'
|
||||
|
||||
/** Resolve relative path to absolute URL using API base */
|
||||
function resolveBase(path: string): string {
|
||||
if (path.startsWith('http')) return path
|
||||
if (isApiClientReady()) return getApiClient().getBaseUrl() + path
|
||||
return path
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any file path to /v1/files/ URL for serving.
|
||||
* Handles absolute Unix paths, Windows paths, and relative filenames.
|
||||
*/
|
||||
export function toFileUrl(path: string): string {
|
||||
if (!path) return ''
|
||||
if (path.includes('/v1/files/')) return resolveBase(path)
|
||||
// Normalize backslashes (Windows paths: C:\Users\... → C:/Users/...)
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
// Absolute: Unix /... or Windows C:/...
|
||||
if (normalized.startsWith('/')) return resolveBase(`/v1/files${normalized}`)
|
||||
if (/^[a-zA-Z]:\//.test(normalized)) return resolveBase(`/v1/files/${normalized}`)
|
||||
return resolveBase(`/v1/files/${normalized.split('/').pop() ?? normalized}`)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { toFileUrl } from './chat-file-helpers'
|
||||
import type { ChatMessage, ToolCall } from '../stores/chat-store'
|
||||
|
||||
/** Raw message shape from WS chat.history response */
|
||||
export interface RawHistoryMessage {
|
||||
id?: string
|
||||
role: string
|
||||
content?: string
|
||||
thinking?: string
|
||||
timestamp?: number
|
||||
tool_call_id?: string
|
||||
is_error?: boolean
|
||||
tool_calls?: Array<{
|
||||
id: string
|
||||
function?: { name: string; arguments?: Record<string, unknown> }
|
||||
name?: string
|
||||
input?: Record<string, unknown>
|
||||
}>
|
||||
media_refs?: Array<{
|
||||
id?: string
|
||||
mime_type?: string
|
||||
content_type?: string
|
||||
kind?: string
|
||||
path?: string
|
||||
url?: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform raw WS history messages into ChatMessage[] for the store.
|
||||
* Builds tool result map, filters system messages, resolves media URLs.
|
||||
*/
|
||||
export function mapHistoryMessages(raw: RawHistoryMessage[]): ChatMessage[] {
|
||||
// Build tool result map for enriching assistant tool_calls
|
||||
const toolResultMap = new Map<string, { content: string; isError: boolean }>()
|
||||
for (const m of raw) {
|
||||
if (m.role === 'tool' && m.tool_call_id) {
|
||||
toolResultMap.set(m.tool_call_id, {
|
||||
content: m.content ?? '',
|
||||
isError: !!m.is_error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Filter: only user + assistant messages, exclude internal system nudges
|
||||
const filtered = raw.filter((m) =>
|
||||
(m.role === 'user' || m.role === 'assistant') &&
|
||||
!(m.role === 'user' && m.content?.startsWith('[System]'))
|
||||
)
|
||||
|
||||
return filtered.map((m) => ({
|
||||
id: m.id ?? crypto.randomUUID(),
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content ?? '',
|
||||
timestamp: m.timestamp ?? Date.now(),
|
||||
thinkingText: m.thinking,
|
||||
toolCalls: m.tool_calls?.map((tc): ToolCall => {
|
||||
const toolResult = toolResultMap.get(tc.id)
|
||||
return {
|
||||
toolId: tc.id,
|
||||
toolName: tc.function?.name ?? tc.name ?? 'unknown',
|
||||
arguments: tc.function?.arguments ?? tc.input ?? {},
|
||||
state: (toolResult?.isError ? 'error' : 'completed') as 'error' | 'completed',
|
||||
result: toolResult && !toolResult.isError ? toolResult.content : undefined,
|
||||
error: toolResult?.isError ? toolResult.content : undefined,
|
||||
}
|
||||
}),
|
||||
media: m.media_refs?.map((ref) => ({
|
||||
type: ref.mime_type ?? ref.content_type ?? 'image',
|
||||
url: toFileUrl(ref.path ?? ref.id ?? ref.url ?? ''),
|
||||
})),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useRef, useCallback } from 'react'
|
||||
import type { TeamTaskData } from '../types/team'
|
||||
|
||||
/** Event payload shape from team.task.* WS events */
|
||||
export interface TaskEventPayload {
|
||||
team_id: string
|
||||
task_id: string
|
||||
status?: string
|
||||
progress_percent?: number
|
||||
progress_step?: string
|
||||
}
|
||||
|
||||
interface UseDebouncerOptions {
|
||||
activeTeamRef: React.MutableRefObject<string | null>
|
||||
fetchOneTask: (taskId: string) => Promise<void>
|
||||
setTasks: React.Dispatch<React.SetStateAction<TeamTaskData[]>>
|
||||
}
|
||||
|
||||
export function useTaskDebouncer({ activeTeamRef, fetchOneTask, setTasks }: UseDebouncerOptions) {
|
||||
const fetchTimersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
|
||||
const progressTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const pendingProgressRef = useRef(new Map<string, { percent: number; step: string }>())
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
fetchTimersRef.current.forEach((t) => clearTimeout(t))
|
||||
clearTimeout(progressTimerRef.current)
|
||||
}, [])
|
||||
|
||||
/** Debounced fetch-one (300ms per task) */
|
||||
const debouncedFetchTask = useCallback((taskId: string) => {
|
||||
const timers = fetchTimersRef.current
|
||||
const existing = timers.get(taskId)
|
||||
if (existing) clearTimeout(existing)
|
||||
pendingProgressRef.current.delete(taskId)
|
||||
timers.set(taskId, setTimeout(() => {
|
||||
timers.delete(taskId)
|
||||
fetchOneTask(taskId)
|
||||
}, 300))
|
||||
}, [fetchOneTask])
|
||||
|
||||
/** Progress event handler — batches updates with 1s debounce */
|
||||
const handleProgress = useCallback((payload: unknown) => {
|
||||
const p = payload as TaskEventPayload
|
||||
if (!p.task_id || p.team_id !== activeTeamRef.current) return
|
||||
pendingProgressRef.current.set(p.task_id, {
|
||||
percent: p.progress_percent ?? 0,
|
||||
step: p.progress_step ?? '',
|
||||
})
|
||||
clearTimeout(progressTimerRef.current)
|
||||
progressTimerRef.current = setTimeout(() => {
|
||||
const patches = new Map(pendingProgressRef.current)
|
||||
pendingProgressRef.current.clear()
|
||||
setTasks((prev) => prev.map((t) => {
|
||||
const patch = patches.get(t.id)
|
||||
if (!patch) return t
|
||||
return { ...t, progress_percent: patch.percent, progress_step: patch.step }
|
||||
}))
|
||||
}, 1000)
|
||||
}, [activeTeamRef, setTasks])
|
||||
|
||||
/** Deletion handler — immediate remove */
|
||||
const handleDeleted = useCallback((payload: unknown) => {
|
||||
const p = payload as TaskEventPayload
|
||||
if (!p.task_id || p.team_id !== activeTeamRef.current) return
|
||||
setTasks((prev) => prev.filter((t) => t.id !== p.task_id))
|
||||
}, [activeTeamRef, setTasks])
|
||||
|
||||
/** Status-change handler — debounced fetch-one */
|
||||
const handleFetchOne = useCallback((payload: unknown) => {
|
||||
const p = payload as TaskEventPayload
|
||||
if (!p.task_id || p.team_id !== activeTeamRef.current) return
|
||||
debouncedFetchTask(p.task_id)
|
||||
}, [activeTeamRef, debouncedFetchTask])
|
||||
|
||||
return { clearAll, debouncedFetchTask, handleProgress, handleDeleted, handleFetchOne }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { getApiClient } from '../lib/api'
|
||||
import { agentService } from '../services/agent-service'
|
||||
import { toast } from '../stores/toast-store'
|
||||
import type { AgentData, AgentInput } from '../types/agent'
|
||||
|
||||
@@ -11,7 +11,7 @@ export function useAgentCrud() {
|
||||
|
||||
const fetchAgents = useCallback(async () => {
|
||||
try {
|
||||
const res = await getApiClient().get<{ agents: AgentData[] | null }>('/v1/agents')
|
||||
const res = await agentService.list()
|
||||
setAgents(res.agents ?? [])
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch agents:', err)
|
||||
@@ -24,7 +24,7 @@ export function useAgentCrud() {
|
||||
|
||||
const createAgent = useCallback(async (input: AgentInput) => {
|
||||
try {
|
||||
const res = await getApiClient().post<AgentData>('/v1/agents', input)
|
||||
const res = await agentService.create(input)
|
||||
setAgents((prev) => [...prev, res])
|
||||
toast.success('Agent created')
|
||||
return res
|
||||
@@ -36,7 +36,7 @@ export function useAgentCrud() {
|
||||
|
||||
const updateAgent = useCallback(async (id: string, input: Partial<AgentData>) => {
|
||||
try {
|
||||
const res = await getApiClient().put<AgentData>(`/v1/agents/${id}`, input)
|
||||
const res = await agentService.update(id, input)
|
||||
setAgents((prev) => prev.map((a) => a.id === id ? res : a))
|
||||
toast.success('Agent updated')
|
||||
return res
|
||||
@@ -48,7 +48,7 @@ export function useAgentCrud() {
|
||||
|
||||
const deleteAgent = useCallback(async (id: string) => {
|
||||
try {
|
||||
await getApiClient().delete(`/v1/agents/${id}`)
|
||||
await agentService.delete(id)
|
||||
setAgents((prev) => prev.filter((a) => a.id !== id))
|
||||
toast.success('Agent deleted')
|
||||
} catch (err) {
|
||||
@@ -58,7 +58,7 @@ export function useAgentCrud() {
|
||||
}, [])
|
||||
|
||||
const resummonAgent = useCallback(async (id: string) => {
|
||||
await getApiClient().post(`/v1/agents/${id}/resummon`, {})
|
||||
await agentService.resummon(id)
|
||||
// Update status to summoning
|
||||
setAgents((prev) => prev.map((a) => a.id === id ? { ...a, status: 'summoning' } : a))
|
||||
}, [])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { getApiClient } from '../lib/api'
|
||||
import { agentService } from '../services/agent-service'
|
||||
import { useAgentStore } from '../stores/agent-store'
|
||||
import type { Agent } from '../stores/agent-store'
|
||||
|
||||
@@ -9,19 +9,10 @@ let didFetchAgents = false
|
||||
|
||||
export function useAgents() {
|
||||
const { agents, selectedAgentId, setAgents, selectAgent } = useAgentStore()
|
||||
const api = getApiClient()
|
||||
|
||||
const fetchAgents = useCallback(async () => {
|
||||
if (!api) return
|
||||
try {
|
||||
const result = await api.get<{ agents: Array<{
|
||||
id: string
|
||||
agent_key: string
|
||||
display_name?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
other_config?: Record<string, unknown> | null
|
||||
}> }>('/v1/agents')
|
||||
const result = await agentService.listItems()
|
||||
|
||||
const mapped: Agent[] = (result.agents ?? []).map((a) => {
|
||||
const otherCfg = a.other_config ?? {}
|
||||
@@ -41,7 +32,7 @@ export function useAgents() {
|
||||
console.error('Failed to fetch agents:', err)
|
||||
return []
|
||||
}
|
||||
}, [api, setAgents])
|
||||
}, [setAgents])
|
||||
|
||||
// Fetch once globally, auto-select first agent only if none selected
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,84 +1,30 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { getWsClient } from '../lib/ws'
|
||||
import { getApiClient, isApiClientReady } from '../lib/api'
|
||||
import { useChatStore } from '../stores/chat-store'
|
||||
import { chatService } from '../services/chat-service'
|
||||
import { useChatMessageStore } from '../stores/chat-message-store'
|
||||
import { useChatActivityStore } from '../stores/chat-activity-store'
|
||||
import { useSessionStore } from '../stores/session-store'
|
||||
import { toFileUrl } from './chat-file-helpers'
|
||||
import { useStreamBatcher } from './use-stream-batcher'
|
||||
import { mapHistoryMessages } from './chat-history-mapper'
|
||||
import type { AttachedFile } from '../components/chat/InputBar'
|
||||
|
||||
// Convert any file path to /v1/files/ URL for serving.
|
||||
// Absolute paths (from media_refs.path) use the full path so the backend
|
||||
// can resolve the file directly without findInWorkspace fallback.
|
||||
function toFileUrl(path: string): string {
|
||||
if (!path) return ''
|
||||
if (path.includes('/v1/files/')) return resolveBase(path)
|
||||
// Normalize backslashes (Windows paths: C:\Users\... → C:/Users/...)
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
// Absolute: Unix /... or Windows C:/...
|
||||
if (normalized.startsWith('/')) return resolveBase(`/v1/files${normalized}`)
|
||||
if (/^[a-zA-Z]:\//.test(normalized)) return resolveBase(`/v1/files/${normalized}`)
|
||||
return resolveBase(`/v1/files/${normalized.split('/').pop() ?? normalized}`)
|
||||
}
|
||||
|
||||
function resolveBase(path: string): string {
|
||||
if (path.startsWith('http')) return path
|
||||
if (isApiClientReady()) return getApiClient().getBaseUrl() + path
|
||||
return path
|
||||
}
|
||||
|
||||
// RAF batching — prevents 100+ setState calls/sec during streaming
|
||||
function useStreamBatcher(onFlush: (text: string) => void) {
|
||||
const bufferRef = useRef('')
|
||||
const rafRef = useRef(0)
|
||||
|
||||
const append = useCallback(
|
||||
(text: string) => {
|
||||
bufferRef.current += text
|
||||
if (!rafRef.current) {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
onFlush(bufferRef.current)
|
||||
bufferRef.current = ''
|
||||
rafRef.current = 0
|
||||
})
|
||||
}
|
||||
},
|
||||
[onFlush],
|
||||
)
|
||||
|
||||
const flush = useCallback(() => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = 0
|
||||
}
|
||||
if (bufferRef.current) {
|
||||
onFlush(bufferRef.current)
|
||||
bufferRef.current = ''
|
||||
}
|
||||
}, [onFlush])
|
||||
|
||||
return { append, flush }
|
||||
}
|
||||
|
||||
export function useChat() {
|
||||
const ws = getWsClient()
|
||||
const {
|
||||
messages,
|
||||
isRunning,
|
||||
activity,
|
||||
addUserMessage,
|
||||
startRun,
|
||||
appendChunk,
|
||||
appendThinking,
|
||||
addToolCall,
|
||||
updateToolResult,
|
||||
setActivity,
|
||||
completeRun,
|
||||
failRun,
|
||||
setMessages,
|
||||
} = useChatStore()
|
||||
addUserMessage, addAssistantMessage, appendChunk, appendThinking,
|
||||
addToolCall, updateToolResult, finalizeMessage, appendErrorToLastMessage, setMessages,
|
||||
} = useChatMessageStore()
|
||||
|
||||
const {
|
||||
isRunning, activity,
|
||||
startRun, setActivity, completeRun, failRun,
|
||||
} = useChatActivityStore()
|
||||
|
||||
const activeSessionKey = useSessionStore((s) => s.activeSessionKey)
|
||||
const sessionKeyRef = useRef(activeSessionKey)
|
||||
sessionKeyRef.current = activeSessionKey // always up-to-date, no stale closure
|
||||
sessionKeyRef.current = activeSessionKey
|
||||
const currentRunIdRef = useRef<string | null>(null)
|
||||
|
||||
const chunkBatcher = useStreamBatcher(
|
||||
@@ -88,177 +34,102 @@ export function useChat() {
|
||||
useCallback((text: string) => appendThinking(text), [appendThinking]),
|
||||
)
|
||||
|
||||
// Subscribe to agent events from WS
|
||||
useEffect(() => {
|
||||
if (!ws) return
|
||||
|
||||
const unsub = ws.on('agent', (raw: unknown) => {
|
||||
const event = raw as {
|
||||
type: string
|
||||
runId: string
|
||||
sessionKey: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Use ref to avoid stale closure — activeSessionKey may change during auto-session-creation
|
||||
const event = raw as { type: string; runId: string; sessionKey: string; payload: Record<string, unknown> }
|
||||
if (sessionKeyRef.current && event.sessionKey !== sessionKeyRef.current) return
|
||||
|
||||
const p = event.payload ?? {}
|
||||
|
||||
switch (event.type) {
|
||||
case 'run.started':
|
||||
currentRunIdRef.current = event.runId
|
||||
addAssistantMessage(event.runId)
|
||||
startRun(event.runId)
|
||||
break
|
||||
|
||||
// Backend sends: { content: "..." }
|
||||
case 'chunk':
|
||||
chunkBatcher.append((p.content as string) ?? '')
|
||||
break
|
||||
|
||||
// Backend sends: { content: "thinking text..." }
|
||||
case 'thinking':
|
||||
thinkingBatcher.append((p.content as string) ?? '')
|
||||
break
|
||||
|
||||
// Backend sends: { name, id, arguments }
|
||||
case 'tool.call':
|
||||
chunkBatcher.flush()
|
||||
addToolCall({
|
||||
toolId: (p.id as string) ?? '',
|
||||
toolName: (p.name as string) ?? 'unknown',
|
||||
arguments: (p.arguments as Record<string, unknown>) ?? {},
|
||||
})
|
||||
addToolCall({ toolId: (p.id as string) ?? '', toolName: (p.name as string) ?? 'unknown', arguments: (p.arguments as Record<string, unknown>) ?? {} })
|
||||
break
|
||||
|
||||
// Backend sends: { name, id, is_error, result, content (when error), arguments }
|
||||
case 'tool.result': {
|
||||
const isError = p.is_error as boolean
|
||||
const toolId = (p.id as string) ?? ''
|
||||
updateToolResult(
|
||||
toolId,
|
||||
isError ? '' : ((p.result as string) ?? ''),
|
||||
isError ? ((p.content as string) ?? (p.result as string) ?? 'Error') : undefined,
|
||||
)
|
||||
updateToolResult((p.id as string) ?? '', isError ? '' : ((p.result as string) ?? ''), isError ? ((p.content as string) ?? (p.result as string) ?? 'Error') : undefined)
|
||||
break
|
||||
}
|
||||
|
||||
// Backend sends: { content: "intermediate reply text" }
|
||||
case 'block.reply':
|
||||
// Intermediate assistant content during tool iterations — append as chunk
|
||||
chunkBatcher.flush()
|
||||
appendChunk((p.content as string) ?? '')
|
||||
break
|
||||
|
||||
// Backend sends: { phase, tool, tools[], iteration }
|
||||
case 'activity':
|
||||
setActivity({
|
||||
phase: (p.phase as string) ?? 'thinking',
|
||||
tool: p.tool as string | undefined,
|
||||
iteration: p.iteration as number | undefined,
|
||||
})
|
||||
setActivity({ phase: (p.phase as string) ?? 'thinking', tool: p.tool as string | undefined, iteration: p.iteration as number | undefined })
|
||||
break
|
||||
|
||||
// Backend sends: { content, usage: { prompt_tokens, completion_tokens, total_tokens, ... }, media }
|
||||
case 'run.completed': {
|
||||
chunkBatcher.flush()
|
||||
thinkingBatcher.flush()
|
||||
const usage = p.usage as Record<string, number> | undefined
|
||||
completeRun(
|
||||
finalizeMessage(
|
||||
(p.content as string) ?? '',
|
||||
usage ? {
|
||||
inputTokens: usage.prompt_tokens ?? 0,
|
||||
outputTokens: usage.completion_tokens ?? 0,
|
||||
} : undefined,
|
||||
usage ? { inputTokens: usage.prompt_tokens ?? 0, outputTokens: usage.completion_tokens ?? 0 } : undefined,
|
||||
(p.media as { path?: string; content_type?: string; url?: string; type?: string }[] | undefined)
|
||||
?.map((m) => ({ type: m.content_type ?? m.type ?? 'file', url: toFileUrl(m.path ?? m.url ?? '') })),
|
||||
)
|
||||
completeRun()
|
||||
currentRunIdRef.current = null
|
||||
break
|
||||
}
|
||||
|
||||
// Backend sends: { error: "..." }
|
||||
case 'run.failed':
|
||||
chunkBatcher.flush()
|
||||
thinkingBatcher.flush()
|
||||
failRun((p.error as string) ?? 'Unknown error')
|
||||
appendErrorToLastMessage((p.error as string) ?? 'Unknown error')
|
||||
failRun()
|
||||
currentRunIdRef.current = null
|
||||
break
|
||||
|
||||
// User-initiated cancellation — preserve partial content, no error message.
|
||||
case 'run.cancelled':
|
||||
chunkBatcher.flush()
|
||||
thinkingBatcher.flush()
|
||||
useChatStore.getState().cancelRun()
|
||||
useChatActivityStore.getState().cancelRun()
|
||||
currentRunIdRef.current = null
|
||||
break
|
||||
|
||||
// Backend sends: { attempt, maxAttempts, error }
|
||||
case 'run.retrying':
|
||||
setActivity({
|
||||
phase: 'retrying',
|
||||
tool: undefined,
|
||||
iteration: Number(p.attempt) || 0,
|
||||
})
|
||||
setActivity({ phase: 'retrying', tool: undefined, iteration: Number(p.attempt) || 0 })
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
return unsub
|
||||
}, [
|
||||
ws,
|
||||
startRun,
|
||||
appendChunk,
|
||||
addToolCall,
|
||||
updateToolResult,
|
||||
setActivity,
|
||||
completeRun,
|
||||
failRun,
|
||||
chunkBatcher,
|
||||
thinkingBatcher,
|
||||
]) // sessionKeyRef used instead of activeSessionKey — no re-subscribe on session change
|
||||
}, [ws, addAssistantMessage, startRun, appendChunk, addToolCall, updateToolResult, setActivity, finalizeMessage, appendErrorToLastMessage, completeRun, failRun, chunkBatcher, thinkingBatcher])
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (text: string, agentId: string, attachedFiles?: AttachedFile[]) => {
|
||||
if (!ws || (!text.trim() && !attachedFiles?.length)) return
|
||||
|
||||
// Auto-create session if none active
|
||||
let sessionKey = activeSessionKey
|
||||
if (!sessionKey) {
|
||||
sessionKey = `agent:${agentId}:ws:direct:system:${crypto.randomUUID().slice(0, 8)}`
|
||||
const { useSessionStore } = await import('../stores/session-store')
|
||||
useSessionStore.getState().addSession({
|
||||
key: sessionKey,
|
||||
agentId,
|
||||
title: text.trim().slice(0, 40) || 'New Chat',
|
||||
lastMessageAt: Date.now(),
|
||||
messageCount: 0,
|
||||
})
|
||||
useSessionStore.getState().addSession({ key: sessionKey, agentId, title: text.trim().slice(0, 40) || 'New Chat', lastMessageAt: Date.now(), messageCount: 0 })
|
||||
useSessionStore.getState().setActiveSession(sessionKey)
|
||||
}
|
||||
|
||||
addUserMessage(text)
|
||||
|
||||
// Resolve attached files to server paths for chat.send:
|
||||
// - localPath files (pasted paths): pass directly, no upload needed
|
||||
// - File objects (picker/drag/paste): upload via HTTP first
|
||||
let media: { path: string; filename: string }[] | undefined
|
||||
if (attachedFiles?.length) {
|
||||
const api = getApiClient()
|
||||
const uploads = await Promise.all(
|
||||
attachedFiles.map(async (af) => {
|
||||
if (af.localPath) {
|
||||
return { path: af.localPath, filename: af.name }
|
||||
}
|
||||
if (af.localPath) return { path: af.localPath, filename: af.name }
|
||||
if (!af.file) return null
|
||||
try {
|
||||
const res = await api.uploadFile<{ path: string; mime_type: string; filename: string }>(
|
||||
'/v1/media/upload', af.file,
|
||||
)
|
||||
const res = await chatService.uploadMedia(af.file)
|
||||
return { path: res.path, filename: res.filename }
|
||||
} catch (err) {
|
||||
console.error('File upload failed:', af.name, err)
|
||||
return null
|
||||
}
|
||||
} catch (err) { console.error('File upload failed:', af.name, err); return null }
|
||||
}),
|
||||
)
|
||||
media = uploads.filter((u): u is { path: string; filename: string } => u !== null)
|
||||
@@ -266,154 +137,56 @@ export function useChat() {
|
||||
}
|
||||
|
||||
try {
|
||||
await ws.call('chat.send', {
|
||||
message: text,
|
||||
agentId,
|
||||
sessionKey,
|
||||
stream: true,
|
||||
...(media && { media }),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('chat.send failed:', err)
|
||||
}
|
||||
await chatService.send({ message: text, agentId, sessionKey, stream: true, ...(media && { media }) })
|
||||
} catch (err) { console.error('chat.send failed:', err) }
|
||||
},
|
||||
[ws, activeSessionKey, addUserMessage],
|
||||
)
|
||||
|
||||
const loadHistory = useCallback(
|
||||
async (sessionKey: string) => {
|
||||
if (!ws) return
|
||||
try {
|
||||
const result = (await ws.call('chat.history', { sessionKey })) as {
|
||||
messages?: Array<{
|
||||
id?: string
|
||||
role: string
|
||||
content?: string
|
||||
thinking?: string
|
||||
timestamp?: number
|
||||
tool_call_id?: string
|
||||
is_error?: boolean
|
||||
tool_calls?: Array<{
|
||||
id: string
|
||||
function?: { name: string; arguments?: Record<string, unknown> }
|
||||
name?: string
|
||||
input?: Record<string, unknown>
|
||||
}>
|
||||
media_refs?: Array<{
|
||||
id?: string
|
||||
mime_type?: string
|
||||
content_type?: string
|
||||
kind?: string
|
||||
path?: string
|
||||
url?: string
|
||||
}>
|
||||
}>
|
||||
}
|
||||
if (result?.messages) {
|
||||
// Build tool result map for enriching assistant tool_calls
|
||||
const toolResultMap = new Map<string, { content: string; isError: boolean }>()
|
||||
for (const m of result.messages) {
|
||||
if (m.role === 'tool' && m.tool_call_id) {
|
||||
toolResultMap.set(m.tool_call_id, {
|
||||
content: m.content ?? '',
|
||||
isError: !!m.is_error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Filter: only user + assistant messages, exclude internal system nudges
|
||||
const filtered = result.messages.filter((m) =>
|
||||
(m.role === 'user' || m.role === 'assistant') &&
|
||||
!(m.role === 'user' && m.content?.startsWith('[System]'))
|
||||
)
|
||||
setMessages(
|
||||
filtered.map((m) => ({
|
||||
id: m.id ?? crypto.randomUUID(),
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content ?? '',
|
||||
timestamp: m.timestamp ?? Date.now(),
|
||||
thinkingText: m.thinking,
|
||||
toolCalls: m.tool_calls?.map((tc) => {
|
||||
const toolResult = toolResultMap.get(tc.id)
|
||||
return {
|
||||
toolId: tc.id,
|
||||
toolName: tc.function?.name ?? tc.name ?? 'unknown',
|
||||
arguments: tc.function?.arguments ?? tc.input ?? {},
|
||||
state: (toolResult?.isError ? 'error' : 'completed') as 'error' | 'completed',
|
||||
result: toolResult && !toolResult.isError ? toolResult.content : undefined,
|
||||
error: toolResult?.isError ? toolResult.content : undefined,
|
||||
}
|
||||
}),
|
||||
media: m.media_refs?.map((ref) => ({
|
||||
type: ref.mime_type ?? ref.content_type ?? 'image',
|
||||
url: toFileUrl(ref.path ?? ref.id ?? ref.url ?? ''),
|
||||
})),
|
||||
})),
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load history:', err)
|
||||
}
|
||||
const result = await chatService.history(sessionKey)
|
||||
if (result?.messages) setMessages(mapHistoryMessages(result.messages))
|
||||
} catch (err) { console.error('Failed to load history:', err) }
|
||||
},
|
||||
[ws, setMessages],
|
||||
[setMessages],
|
||||
)
|
||||
|
||||
// Abort the current run for the active session.
|
||||
const abort = useCallback(async () => {
|
||||
const sk = sessionKeyRef.current
|
||||
if (!ws || !sk) return
|
||||
try {
|
||||
await ws.call('chat.abort', { sessionKey: sk })
|
||||
} catch {
|
||||
// ignore abort errors
|
||||
}
|
||||
}, [ws])
|
||||
if (!sk) return
|
||||
try { await chatService.abort(sk) } catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
// Reset streaming state + load history when session changes.
|
||||
// Messages are NOT cleared here — loadHistory() replaces them atomically.
|
||||
// Clearing would race with sendMessage's auto-session-creation flow.
|
||||
// Reset streaming state + load history when session changes
|
||||
const prevSessionRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (activeSessionKey === prevSessionRef.current) return
|
||||
prevSessionRef.current = activeSessionKey
|
||||
|
||||
// Reset streaming state only (not messages)
|
||||
chunkBatcher.flush()
|
||||
thinkingBatcher.flush()
|
||||
currentRunIdRef.current = null
|
||||
|
||||
if (!activeSessionKey) {
|
||||
useChatStore.getState().clear()
|
||||
useChatMessageStore.getState().clear()
|
||||
useChatActivityStore.getState().clear()
|
||||
return
|
||||
}
|
||||
|
||||
// Load history — setMessages() atomically replaces, no flash
|
||||
let cancelled = false
|
||||
loadHistory(activeSessionKey).then(() => {
|
||||
if (cancelled) return
|
||||
})
|
||||
loadHistory(activeSessionKey).then(() => { if (cancelled) return })
|
||||
|
||||
// Restore session running state (mirrors web UI pattern).
|
||||
// If user switches to a session with an active run, show the stop button.
|
||||
ws?.call('chat.session_status', { sessionKey: activeSessionKey })
|
||||
.then((raw: unknown) => {
|
||||
chatService.sessionStatus(activeSessionKey)
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
const res = raw as { isRunning?: boolean; activity?: { phase: string; tool?: string; iteration?: number } }
|
||||
if (res?.isRunning) {
|
||||
useChatStore.getState().restoreRunning(res.activity ?? null)
|
||||
}
|
||||
if (res?.isRunning) useChatActivityStore.getState().restoreRunning(res.activity ?? null)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [activeSessionKey, ws, loadHistory, chunkBatcher, thinkingBatcher])
|
||||
}, [activeSessionKey, loadHistory, chunkBatcher, thinkingBatcher])
|
||||
|
||||
return {
|
||||
messages,
|
||||
isRunning,
|
||||
activity,
|
||||
sendMessage,
|
||||
loadHistory,
|
||||
abort,
|
||||
}
|
||||
return { messages, isRunning, activity, sendMessage, loadHistory, abort }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { getWsClient } from '../lib/ws'
|
||||
import { cronService } from '../services/cron-service'
|
||||
import { toast } from '../stores/toast-store'
|
||||
import type { CronJob, CronRunLog, CronSchedule, CronPayload } from '../types/cron'
|
||||
|
||||
@@ -17,10 +17,7 @@ export function useCron(agentId?: string) {
|
||||
|
||||
const fetchJobs = useCallback(async () => {
|
||||
try {
|
||||
const ws = getWsClient()
|
||||
const params: Record<string, unknown> = {}
|
||||
if (agentId) params.agentId = agentId
|
||||
const res = await ws.call('cron.list', params) as { jobs: CronJob[] | null }
|
||||
const res = await cronService.list(agentId)
|
||||
setJobs(res.jobs ?? [])
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch cron jobs:', err)
|
||||
@@ -33,8 +30,7 @@ export function useCron(agentId?: string) {
|
||||
|
||||
const createJob = useCallback(async (params: CreateJobParams) => {
|
||||
try {
|
||||
const ws = getWsClient()
|
||||
const job = await ws.call('cron.create', params as unknown as Record<string, unknown>) as CronJob
|
||||
const job = await cronService.create(params)
|
||||
setJobs((prev) => [...prev, job])
|
||||
toast.success('Cron job created', params.name)
|
||||
return job
|
||||
@@ -46,8 +42,7 @@ export function useCron(agentId?: string) {
|
||||
|
||||
const deleteJob = useCallback(async (jobId: string) => {
|
||||
try {
|
||||
const ws = getWsClient()
|
||||
await ws.call('cron.delete', { jobId })
|
||||
await cronService.delete(jobId)
|
||||
setJobs((prev) => prev.filter((j) => j.id !== jobId))
|
||||
toast.success('Cron job deleted')
|
||||
} catch (err) {
|
||||
@@ -58,8 +53,7 @@ export function useCron(agentId?: string) {
|
||||
|
||||
const toggleJob = useCallback(async (jobId: string) => {
|
||||
try {
|
||||
const ws = getWsClient()
|
||||
const res = await ws.call('cron.toggle', { jobId }) as { enabled: boolean }
|
||||
const res = await cronService.toggle(jobId)
|
||||
setJobs((prev) => prev.map((j) => j.id === jobId ? { ...j, enabled: res.enabled } : j))
|
||||
} catch (err) {
|
||||
toast.error('Failed to toggle cron job', (err as Error).message)
|
||||
@@ -69,8 +63,7 @@ export function useCron(agentId?: string) {
|
||||
|
||||
const runJob = useCallback(async (jobId: string) => {
|
||||
try {
|
||||
const ws = getWsClient()
|
||||
await ws.call('cron.run', { jobId })
|
||||
await cronService.run(jobId)
|
||||
toast.success('Cron job triggered')
|
||||
} catch (err) {
|
||||
toast.error('Failed to run cron job', (err as Error).message)
|
||||
@@ -79,8 +72,7 @@ export function useCron(agentId?: string) {
|
||||
}, [])
|
||||
|
||||
const fetchRuns = useCallback(async (jobId: string): Promise<CronRunLog[]> => {
|
||||
const ws = getWsClient()
|
||||
const res = await ws.call('cron.runs', { jobId, limit: 20 }) as { runs: CronRunLog[] | null }
|
||||
const res = await cronService.runs(jobId)
|
||||
return res.runs ?? []
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { getApiClient } from '../lib/api'
|
||||
import { mcpService } from '../services/mcp-service'
|
||||
import { toast } from '../stores/toast-store'
|
||||
import type { MCPServerData, MCPServerInput, MCPAgentGrant, MCPToolInfo, MCPTestResult } from '../types/mcp'
|
||||
import type { MCPServerData, MCPServerInput, MCPAgentGrant, MCPToolInfo } from '../types/mcp'
|
||||
|
||||
export const MAX_MCP_LITE = 5
|
||||
|
||||
@@ -11,7 +11,7 @@ export function useMcpServers() {
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
try {
|
||||
const res = await getApiClient().get<{ servers: MCPServerData[] | null }>('/v1/mcp/servers')
|
||||
const res = await mcpService.list()
|
||||
setServers(res.servers ?? [])
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch MCP servers:', err)
|
||||
@@ -24,7 +24,7 @@ export function useMcpServers() {
|
||||
|
||||
const createServer = useCallback(async (input: MCPServerInput) => {
|
||||
try {
|
||||
const res = await getApiClient().post<MCPServerData>('/v1/mcp/servers', input)
|
||||
const res = await mcpService.create(input)
|
||||
await fetchServers()
|
||||
toast.success('Server created')
|
||||
return res
|
||||
@@ -36,7 +36,7 @@ export function useMcpServers() {
|
||||
|
||||
const updateServer = useCallback(async (id: string, input: Partial<MCPServerInput>) => {
|
||||
try {
|
||||
const res = await getApiClient().put<MCPServerData>(`/v1/mcp/servers/${id}`, input)
|
||||
const res = await mcpService.update(id, input)
|
||||
await fetchServers()
|
||||
toast.success('Server updated')
|
||||
return res
|
||||
@@ -48,7 +48,7 @@ export function useMcpServers() {
|
||||
|
||||
const deleteServer = useCallback(async (id: string) => {
|
||||
try {
|
||||
await getApiClient().delete(`/v1/mcp/servers/${id}`)
|
||||
await mcpService.delete(id)
|
||||
setServers((prev) => prev.filter((s) => s.id !== id))
|
||||
toast.success('Server deleted')
|
||||
} catch (err) {
|
||||
@@ -65,12 +65,12 @@ export function useMcpServers() {
|
||||
headers?: Record<string, string>
|
||||
env?: Record<string, string>
|
||||
}) => {
|
||||
return getApiClient().post<MCPTestResult>('/v1/mcp/servers/test', data)
|
||||
return mcpService.testConnection(data)
|
||||
}, [])
|
||||
|
||||
const reconnectServer = useCallback(async (id: string) => {
|
||||
try {
|
||||
await getApiClient().post(`/v1/mcp/servers/${id}/reconnect`, {})
|
||||
await mcpService.reconnect(id)
|
||||
toast.success('Connection reset')
|
||||
} catch (err) {
|
||||
toast.error('Failed to reconnect', (err as Error).message)
|
||||
@@ -78,23 +78,23 @@ export function useMcpServers() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const listServerTools = useCallback(async (serverId: string) => {
|
||||
const res = await getApiClient().get<{ tools: MCPToolInfo[] | null }>(`/v1/mcp/servers/${serverId}/tools`)
|
||||
const listServerTools = useCallback(async (serverId: string): Promise<MCPToolInfo[]> => {
|
||||
const res = await mcpService.listTools(serverId)
|
||||
return res.tools ?? []
|
||||
}, [])
|
||||
|
||||
const listGrants = useCallback(async (serverId: string) => {
|
||||
const res = await getApiClient().get<{ grants: MCPAgentGrant[] | null }>(`/v1/mcp/servers/${serverId}/grants`)
|
||||
const listGrants = useCallback(async (serverId: string): Promise<MCPAgentGrant[]> => {
|
||||
const res = await mcpService.listGrants(serverId)
|
||||
return res.grants ?? []
|
||||
}, [])
|
||||
|
||||
const grantAgent = useCallback(async (serverId: string, agentId: string) => {
|
||||
await getApiClient().post(`/v1/mcp/servers/${serverId}/grants/agent`, { agent_id: agentId })
|
||||
await mcpService.grantAgent(serverId, agentId)
|
||||
await fetchServers()
|
||||
}, [fetchServers])
|
||||
|
||||
const revokeAgent = useCallback(async (serverId: string, agentId: string) => {
|
||||
await getApiClient().delete(`/v1/mcp/servers/${serverId}/grants/agent/${agentId}`)
|
||||
await mcpService.revokeAgent(serverId, agentId)
|
||||
await fetchServers()
|
||||
}, [fetchServers])
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { getApiClient } from '../lib/api'
|
||||
import { providerService } from '../services/provider-service'
|
||||
import { toast } from '../stores/toast-store'
|
||||
import type { ProviderData, ProviderInput } from '../types/provider'
|
||||
|
||||
@@ -9,7 +9,7 @@ export function useProviders() {
|
||||
|
||||
const fetchProviders = useCallback(async () => {
|
||||
try {
|
||||
const res = await getApiClient().get<{ providers: ProviderData[] | null }>('/v1/providers')
|
||||
const res = await providerService.list()
|
||||
setProviders(res.providers ?? [])
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch providers:', err)
|
||||
@@ -22,7 +22,7 @@ export function useProviders() {
|
||||
|
||||
const createProvider = useCallback(async (input: ProviderInput) => {
|
||||
try {
|
||||
const res = await getApiClient().post<ProviderData>('/v1/providers', input)
|
||||
const res = await providerService.create(input)
|
||||
setProviders((prev) => [...prev, res])
|
||||
toast.success('Provider created')
|
||||
return res
|
||||
@@ -34,7 +34,7 @@ export function useProviders() {
|
||||
|
||||
const updateProvider = useCallback(async (id: string, input: Partial<ProviderInput>) => {
|
||||
try {
|
||||
const res = await getApiClient().put<ProviderData>(`/v1/providers/${id}`, input)
|
||||
const res = await providerService.update(id, input)
|
||||
setProviders((prev) => prev.map((p) => p.id === id ? res : p))
|
||||
toast.success('Provider updated')
|
||||
return res
|
||||
@@ -46,7 +46,7 @@ export function useProviders() {
|
||||
|
||||
const deleteProvider = useCallback(async (id: string) => {
|
||||
try {
|
||||
await getApiClient().delete(`/v1/providers/${id}`)
|
||||
await providerService.delete(id)
|
||||
setProviders((prev) => prev.filter((p) => p.id !== id))
|
||||
toast.success('Provider deleted')
|
||||
} catch (err) {
|
||||
@@ -56,7 +56,7 @@ export function useProviders() {
|
||||
}, [])
|
||||
|
||||
const verifyProvider = useCallback(async (input: { provider_type: string; api_base?: string; api_key?: string }) => {
|
||||
return getApiClient().post<{ success: boolean; error?: string; models?: string[] }>('/v1/providers/verify', input)
|
||||
return providerService.verify(input)
|
||||
}, [])
|
||||
|
||||
return { providers, loading, fetchProviders, createProvider, updateProvider, deleteProvider, verifyProvider }
|
||||
|
||||
@@ -1,35 +1,25 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { getWsClient } from '../lib/ws'
|
||||
import { sessionService } from '../services/session-service'
|
||||
import { useSessionStore } from '../stores/session-store'
|
||||
import { useAgentStore } from '../stores/agent-store'
|
||||
import { useChatStore } from '../stores/chat-store'
|
||||
|
||||
// Backend SessionInfo: { key, messageCount, created, updated, label, channel, userID }
|
||||
interface SessionInfoResponse {
|
||||
key: string
|
||||
messageCount: number
|
||||
created: string // ISO timestamp
|
||||
updated: string // ISO timestamp
|
||||
label?: string
|
||||
channel?: string
|
||||
}
|
||||
import { useChatMessageStore } from '../stores/chat-message-store'
|
||||
import { useChatActivityStore } from '../stores/chat-activity-store'
|
||||
|
||||
export function useSessions() {
|
||||
const ws = getWsClient()
|
||||
const { sessions, activeSessionKey, setActiveSession, setSessions, removeSession } = useSessionStore()
|
||||
const selectedAgentId = useAgentStore((s) => s.selectedAgentId)
|
||||
|
||||
// When agent changes, clear active session + chat, then fetch new sessions
|
||||
useEffect(() => {
|
||||
if (!ws || !selectedAgentId) return
|
||||
if (!selectedAgentId) return
|
||||
setActiveSession(null)
|
||||
useChatStore.getState().clear()
|
||||
useChatMessageStore.getState().clear()
|
||||
useChatActivityStore.getState().clear()
|
||||
let cancelled = false
|
||||
ws.call('sessions.list', { agentId: selectedAgentId, limit: 30 })
|
||||
.then((result: unknown) => {
|
||||
sessionService.list(selectedAgentId)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
const r = result as { sessions?: SessionInfoResponse[] }
|
||||
const list = (r?.sessions || []).map((s) => ({
|
||||
const list = (result?.sessions || []).map((s) => ({
|
||||
key: s.key,
|
||||
agentId: selectedAgentId,
|
||||
title: s.label || 'Untitled',
|
||||
@@ -40,25 +30,27 @@ export function useSessions() {
|
||||
})
|
||||
.catch(console.error)
|
||||
return () => { cancelled = true }
|
||||
}, [ws, selectedAgentId, setSessions, setActiveSession])
|
||||
}, [selectedAgentId, setSessions, setActiveSession])
|
||||
|
||||
// "New Chat" just clears active session + chat.
|
||||
// Actual session is created by sendMessage on first message (auto-session-creation).
|
||||
const createSession = useCallback(() => {
|
||||
setActiveSession(null)
|
||||
useChatStore.getState().clear()
|
||||
useChatMessageStore.getState().clear()
|
||||
useChatActivityStore.getState().clear()
|
||||
}, [setActiveSession])
|
||||
|
||||
const deleteSession = useCallback(async (key: string) => {
|
||||
try {
|
||||
await ws.call('sessions.delete', { key })
|
||||
await sessionService.delete(key)
|
||||
} catch { /* best effort */ }
|
||||
removeSession(key)
|
||||
if (activeSessionKey === key) {
|
||||
setActiveSession(null)
|
||||
useChatStore.getState().clear()
|
||||
useChatMessageStore.getState().clear()
|
||||
useChatActivityStore.getState().clear()
|
||||
}
|
||||
}, [ws, activeSessionKey, removeSession, setActiveSession])
|
||||
}, [activeSessionKey, removeSession, setActiveSession])
|
||||
|
||||
return { sessions, activeSessionKey, setActiveSession, createSession, deleteSession }
|
||||
}
|
||||
@@ -2,21 +2,9 @@
|
||||
// Adapted from ui/web/src/pages/storage/hooks/use-storage.ts for desktop edition.
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { getApiClient } from '../lib/api'
|
||||
import { storageService } from '../services/storage-service'
|
||||
|
||||
export interface StorageFile {
|
||||
path: string
|
||||
name: string
|
||||
isDir: boolean
|
||||
size: number
|
||||
hasChildren?: boolean
|
||||
protected: boolean
|
||||
}
|
||||
|
||||
interface StorageListResponse {
|
||||
files: StorageFile[]
|
||||
baseDir: string
|
||||
}
|
||||
export type { StorageFile } from '../services/storage-service'
|
||||
|
||||
interface StorageFileContent {
|
||||
content: string
|
||||
@@ -25,15 +13,14 @@ interface StorageFileContent {
|
||||
}
|
||||
|
||||
export function useStorage() {
|
||||
const [files, setFiles] = useState<StorageFile[]>([])
|
||||
const [files, setFiles] = useState<import('../services/storage-service').StorageFile[]>([])
|
||||
const [baseDir, setBaseDir] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const listFiles = useCallback(async (opts?: { silent?: boolean }) => {
|
||||
if (!opts?.silent) setLoading(true)
|
||||
try {
|
||||
const api = getApiClient()
|
||||
const res = await api.get<StorageListResponse>('/v1/storage/files')
|
||||
const res = await storageService.listFiles()
|
||||
setFiles(res.files ?? [])
|
||||
setBaseDir(res.baseDir ?? '')
|
||||
} finally {
|
||||
@@ -41,47 +28,31 @@ export function useStorage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadSubtree = useCallback(async (path: string): Promise<StorageFile[]> => {
|
||||
const api = getApiClient()
|
||||
const res = await api.get<StorageListResponse>(
|
||||
`/v1/storage/files?path=${encodeURIComponent(path)}`,
|
||||
)
|
||||
const loadSubtree = useCallback(async (path: string): Promise<import('../services/storage-service').StorageFile[]> => {
|
||||
const res = await storageService.loadSubtree(path)
|
||||
return res.files ?? []
|
||||
}, [])
|
||||
|
||||
const readFile = useCallback(async (path: string): Promise<StorageFileContent> => {
|
||||
const api = getApiClient()
|
||||
return api.get<StorageFileContent>(
|
||||
`/v1/storage/files/${encodeURIComponent(path)}`,
|
||||
)
|
||||
return storageService.readFile(path)
|
||||
}, [])
|
||||
|
||||
const deleteFile = useCallback(async (path: string) => {
|
||||
const api = getApiClient()
|
||||
await api.delete(`/v1/storage/files/${encodeURIComponent(path)}`)
|
||||
await storageService.deleteFile(path)
|
||||
}, [])
|
||||
|
||||
const uploadFile = useCallback(async (file: File, folder?: string) => {
|
||||
const api = getApiClient()
|
||||
const params = folder ? `?path=${encodeURIComponent(folder)}` : ''
|
||||
await api.uploadFile(`/v1/storage/files${params}`, file)
|
||||
await storageService.uploadFile(file, folder)
|
||||
}, [])
|
||||
|
||||
const moveFile = useCallback(async (fromPath: string, toFolder: string) => {
|
||||
const fileName = fromPath.split('/').pop() ?? fromPath
|
||||
const newPath = toFolder ? `${toFolder}/${fileName}` : fileName
|
||||
if (fromPath === newPath) return
|
||||
const api = getApiClient()
|
||||
await api.putRaw(
|
||||
`/v1/storage/move?from=${encodeURIComponent(fromPath)}&to=${encodeURIComponent(newPath)}`,
|
||||
)
|
||||
await storageService.moveFile(fromPath, toFolder)
|
||||
}, [])
|
||||
|
||||
const fetchRawBlob = useCallback((path: string, download?: boolean): Promise<Blob> => {
|
||||
const api = getApiClient()
|
||||
const params: Record<string, string> = { raw: 'true' }
|
||||
if (download) params.download = 'true'
|
||||
return api.fetchBlob(`/v1/storage/files/${encodeURIComponent(path)}`, params)
|
||||
return storageService.fetchBlob(path, params)
|
||||
}, [])
|
||||
|
||||
return { files, baseDir, loading, listFiles, loadSubtree, readFile, deleteFile, uploadFile, moveFile, fetchRawBlob }
|
||||
@@ -106,8 +77,7 @@ export function useStorageSize() {
|
||||
setState((s) => ({ ...s, loading: true, totalSize: 0, fileCount: 0 }))
|
||||
|
||||
try {
|
||||
const api = getApiClient()
|
||||
const res = await api.streamFetch('/v1/storage/size', controller.signal)
|
||||
const res = await storageService.streamSize(controller.signal)
|
||||
if (!res.body) {
|
||||
setState((s) => ({ ...s, loading: false }))
|
||||
return
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useRef, useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* RAF-based stream batcher — prevents 100+ setState calls/sec during streaming.
|
||||
* Buffers text and flushes once per animation frame.
|
||||
*/
|
||||
export function useStreamBatcher(onFlush: (text: string) => void) {
|
||||
const bufferRef = useRef('')
|
||||
const rafRef = useRef(0)
|
||||
|
||||
const append = useCallback(
|
||||
(text: string) => {
|
||||
bufferRef.current += text
|
||||
if (!rafRef.current) {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
onFlush(bufferRef.current)
|
||||
bufferRef.current = ''
|
||||
rafRef.current = 0
|
||||
})
|
||||
}
|
||||
},
|
||||
[onFlush],
|
||||
)
|
||||
|
||||
const flush = useCallback(() => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = 0
|
||||
}
|
||||
if (bufferRef.current) {
|
||||
onFlush(bufferRef.current)
|
||||
bufferRef.current = ''
|
||||
}
|
||||
}, [onFlush])
|
||||
|
||||
return { append, flush }
|
||||
}
|
||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user