Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions admin/src/components/CcOrchestraPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Terminal, Map, X, Loader2, ExternalLink, Clock, CheckCircle2, AlertCircle, XCircle } from 'lucide-vue-next'
import { claudeCodeApi, type CcSessionSummary } from '@/api/claudeCode'

const props = defineProps<{
sessionId: string | null
}>()

const emit = defineEmits<{
close: []
'open-session': [ccSessionId: string]
}>()

const { t } = useI18n()

const activeTab = ref<'orchestras' | 'roadmap'>('orchestras')
const sessions = ref<CcSessionSummary[]>([])
const loading = ref(false)
const error = ref<string | null>(null)

async function loadSessions() {
if (!props.sessionId) {
sessions.value = []
return
}
loading.value = true
error.value = null
try {
const resp = await claudeCodeApi.listByChatSession(props.sessionId)
sessions.value = resp.sessions || []
} catch (e: unknown) {
error.value = (e as Error).message || 'Failed to load sessions'
sessions.value = []
} finally {
loading.value = false
}
}

watch(() => props.sessionId, () => {
if (activeTab.value === 'orchestras') loadSessions()
}, { immediate: true })

watch(activeTab, (tab) => {
if (tab === 'orchestras') loadSessions()
})

onMounted(() => {
if (activeTab.value === 'orchestras') loadSessions()
})

function formatTime(isoStr: string) {
const d = new Date(isoStr)
const now = new Date()
const diff = now.getTime() - d.getTime()
if (diff < 60_000) return 'just now'
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
}

const statusConfig: Record<string, { icon: typeof CheckCircle2; class: string }> = {
active: { icon: Loader2, class: 'text-blue-400 animate-spin' },
completed: { icon: CheckCircle2, class: 'text-green-400' },
error: { icon: AlertCircle, class: 'text-red-400' },
aborted: { icon: XCircle, class: 'text-muted-foreground' },
}
</script>

<template>
<div class="border-l border-border bg-card/50 flex flex-col flex-shrink-0 h-full">
<!-- Header with tabs -->
<div class="border-b border-border flex-shrink-0">
<div class="flex items-center justify-between px-3 pt-2">
<div class="flex items-center gap-1">
<button
:class="[
'px-2.5 py-1.5 text-xs font-medium rounded-t transition-colors',
activeTab === 'orchestras'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground'
]"
@click="activeTab = 'orchestras'"
>
<Terminal class="w-3 h-3 inline mr-1" />
{{ t('chatView.ccPanel.orchestras') }}
</button>
<button
:class="[
'px-2.5 py-1.5 text-xs font-medium rounded-t transition-colors',
activeTab === 'roadmap'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground'
]"
@click="activeTab = 'roadmap'"
>
<Map class="w-3 h-3 inline mr-1" />
{{ t('chatView.ccPanel.roadmap') }}
</button>
</div>
<button
class="p-1 rounded hover:bg-secondary text-muted-foreground transition-colors"
@click="emit('close')"
>
<X class="w-3.5 h-3.5" />
</button>
</div>
</div>

<!-- Tab content -->
<div class="flex-1 overflow-auto">
<!-- Orchestras tab -->
<template v-if="activeTab === 'orchestras'">
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center p-8">
<Loader2 class="w-5 h-5 animate-spin text-muted-foreground" />
</div>

<!-- Error -->
<div v-else-if="error" class="p-4 text-center text-xs text-destructive">
{{ error }}
</div>

<!-- No session selected -->
<div v-else-if="!sessionId" class="p-4 text-center text-xs text-muted-foreground">
{{ t('chatView.ccPanel.noChat') }}
</div>

<!-- Empty -->
<div v-else-if="sessions.length === 0" class="p-4 text-center text-xs text-muted-foreground">
{{ t('chatView.ccPanel.noOrchestras') }}
</div>

<!-- Session list -->
<div v-else class="p-2 space-y-1">
<button
v-for="s in sessions"
:key="s.id"
class="w-full text-left px-3 py-2.5 rounded-lg hover:bg-secondary/60 transition-colors group"
@click="emit('open-session', s.id)"
>
<div class="flex items-center gap-2 mb-1">
<component
:is="statusConfig[s.status]?.icon || Terminal"
:class="['w-3.5 h-3.5 shrink-0', statusConfig[s.status]?.class || 'text-muted-foreground']"
/>
<span class="text-sm font-medium truncate flex-1">{{ s.title || 'Untitled' }}</span>
<ExternalLink class="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100 shrink-0 transition-opacity" />
</div>
<div class="flex items-center gap-3 text-[10px] text-muted-foreground pl-5">
<span class="flex items-center gap-1">
<Clock class="w-2.5 h-2.5" />
{{ formatTime(s.updated || s.created) }}
</span>
<span v-if="s.model" class="font-mono">{{ s.model }}</span>
<span>{{ s.total_turns }} turns</span>
</div>
</button>
</div>
</template>

<!-- Roadmap tab -->
<template v-if="activeTab === 'roadmap'">
<div class="p-4 text-center text-xs text-muted-foreground">
{{ t('chatView.ccPanel.roadmapPlaceholder') }}
</div>
</template>
</div>
</div>
</template>
24 changes: 24 additions & 0 deletions admin/src/plugins/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,14 @@ const messages = {
attachFiles: "Файлы из чата",
workDir: "Рабочая папка",
},
ccPanel: {
orchestras: "Оркестры",
roadmap: "Дорожная карта",
noChat: "Выберите чат",
noOrchestras: "Нет запущенных оркестров",
roadmapPlaceholder: "Дорожная карта (скоро)",
title: "Оркестры Claude Code",
},
},
// Auth
auth: {
Expand Down Expand Up @@ -1747,6 +1755,14 @@ const messages = {
attachFiles: "Files from chat",
workDir: "Working directory",
},
ccPanel: {
orchestras: "Orchestras",
roadmap: "Roadmap",
noChat: "Select a chat",
noOrchestras: "No orchestras running",
roadmapPlaceholder: "Roadmap (coming soon)",
title: "Claude Code Orchestras",
},
},
// Auth
auth: {
Expand Down Expand Up @@ -3104,6 +3120,14 @@ const messages = {
attachFiles: "Чаттан файлдар",
workDir: "Жұмыс қалтасы",
},
ccPanel: {
orchestras: "Оркестрлер",
roadmap: "Жол картасы",
noChat: "Чат таңдаңыз",
noOrchestras: "Іске қосылған оркестрлер жоқ",
roadmapPlaceholder: "Жол картасы (жақында)",
title: "Claude Code оркестрлері",
},
},
// Auth
auth: {
Expand Down
41 changes: 31 additions & 10 deletions admin/src/views/ChatView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { chatApi, ttsApi, llmApi, sttApi, wikiRagApi, type ChatSession, type Cha
import BranchTree from '@/components/BranchTree.vue'
import ChatShareDialog from '@/components/ChatShareDialog.vue'
import ArtifactPanel, { type Artifact } from '@/components/ArtifactPanel.vue'
import CcOrchestraPanel from '@/components/CcOrchestraPanel.vue'
import { useConfirmStore } from '@/stores/confirm'
import { useToastStore } from '@/stores/toast'
import { useAuthStore } from '@/stores/auth'
Expand Down Expand Up @@ -192,6 +193,7 @@ const { width: sidebarWidth, startResize: startSidebarResize, startTouchResize:
const { width: branchTreeWidth, startResize: startBranchResize, startTouchResize: startBranchTouchResize } = useResizablePanel('chat-branch-width', 208, 160, 400, 'left')
const { width: settingsWidth, startResize: startSettingsResize, startTouchResize: startSettingsTouchResize } = useResizablePanel('chat-settings-width', 500, 300, 800, 'left')
const { width: artifactWidth, startResize: startArtifactResize, startTouchResize: startArtifactTouchResize } = useResizablePanel('chat-artifact-width', 500, 300, 800, 'left')
const { width: ccPanelWidth, startResize: startCcPanelResize, startTouchResize: startCcPanelTouchResize } = useResizablePanel('chat-cc-panel-width', 320, 220, 500, 'left')

// Pasted content blocks
const pastedBlocks = ref<PastedBlock[]>([])
Expand Down Expand Up @@ -402,9 +404,13 @@ function renderMarkdown(content: string, messageId?: string): string {
// Branch tree toggle
const showBranchTree = ref(false)

// CC Orchestra panel toggle
const showCcPanel = ref(false)

// Mutual exclusivity: panel watchers
watch(showBranchTree, (v) => { if (v) closeArtifact() })
watch(showSettings, (v) => { if (v) closeArtifact() })
watch(showBranchTree, (v) => { if (v) { closeArtifact(); showCcPanel.value = false } })
watch(showSettings, (v) => { if (v) { closeArtifact(); showCcPanel.value = false } })
watch(showCcPanel, (v) => { if (v) { closeArtifact(); showBranchTree.value = false } })

// File attachment state
const attachedFiles = ref<{ name: string; content: string }[]>([])
Expand Down Expand Up @@ -2305,15 +2311,15 @@ class="w-4 h-4 rounded border flex items-center justify-center shrink-0"
</div>
</div>

<!-- Claude Code toggle (restricted users, admin only) -->
<!-- CC Orchestra panel toggle (restricted users, admin only) -->
<button
v-if="!isChatOnly && ['shaerware', 'ivan'].includes(authStore.user?.username ?? '')"
:class="[
'w-8 h-8 rounded-lg flex items-center justify-center transition-colors shrink-0',
cc.isActive.value ? 'bg-green-600/20 text-green-400' : 'text-muted-foreground hover:bg-secondary/50'
showCcPanel ? 'bg-green-600/20 text-green-400' : 'text-muted-foreground hover:bg-secondary/50'
]"
:title="cc.isActive.value ? t('chatView.claudeCode.disable') : t('chatView.claudeCode.enable')"
@click="toggleCcMode()"
:title="t('chatView.ccPanel.title')"
@click="showCcPanel = !showCcPanel"
>
<Terminal class="w-4 h-4" />
</button>
Expand Down Expand Up @@ -2840,15 +2846,15 @@ class="w-4 h-4 rounded border flex items-center justify-center shrink-0"
</div>
</div>

<!-- Claude Code toggle (restricted users) -->
<!-- CC Orchestra panel toggle (restricted users) -->
<button
v-if="['shaerware', 'ivan'].includes(authStore.user?.username ?? '')"
:class="[
'p-2 rounded-lg border transition-colors',
cc.isActive.value ? 'border-green-600 bg-green-600 text-white' : 'border-border text-muted-foreground hover:bg-secondary/50'
showCcPanel ? 'border-green-600 bg-green-600 text-white' : 'border-border text-muted-foreground hover:bg-secondary/50'
]"
:title="cc.isActive.value ? t('chatView.claudeCode.disable') : t('chatView.claudeCode.enable')"
@click="toggleCcMode()"
:title="t('chatView.ccPanel.title')"
@click="showCcPanel = !showCcPanel"
>
<Terminal class="w-4 h-4" />
</button>
Expand Down Expand Up @@ -3809,6 +3815,21 @@ class="w-4 h-4 rounded border flex items-center justify-center shrink-0"
</div>
</div> <!-- /messages wrapper -->

<!-- CC Orchestra Panel -->
<template v-if="showCcPanel">
<div
class="w-1.5 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors flex-shrink-0"
@mousedown="startCcPanelResize"
@touchstart="startCcPanelTouchResize"
/>
<CcOrchestraPanel
:session-id="currentSessionId"
:style="{ width: ccPanelWidth + 'px' }"
@close="showCcPanel = false"
@open-session="loadCcSession"
/>
</template>

<!-- Branch Tree Panel -->
<template v-if="showBranchTree">
<div
Expand Down
22 changes: 14 additions & 8 deletions modules/llm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,14 +392,8 @@ async def admin_set_llm_backend(
"""Переключить LLM backend с горячей перезагрузкой сервиса"""
container = get_container()

# Stop bridge if currently running and switching away from it
current_service = container.llm_service
if current_service and getattr(current_service, "provider_type", None) == "claude_bridge":
from bridge_manager import bridge_manager

if bridge_manager.is_running:
logger.info("🛑 Stopping bridge (switching backend)...")
await bridge_manager.stop()
# NOTE: bridge is stopped AFTER successful switch (not before),
# so if the target backend fails, bridge remains running.

# Auto-convert "gemini" to default cloud Gemini provider
if request.backend == "gemini":
Expand Down Expand Up @@ -513,6 +507,18 @@ async def check_vllm_health() -> bool:
except ImportError:
raise HTTPException(status_code=503, detail="VLLMLLMService не доступен")

# Stop bridge only after vLLM is confirmed working
current_service = container.llm_service
if (
current_service
and getattr(current_service, "provider_type", None) == "claude_bridge"
):
from bridge_manager import bridge_manager

if bridge_manager.is_running:
logger.info("🛑 Stopping bridge (switching to vLLM)...")
await bridge_manager.stop()

container.llm_service = new_service
os.environ["LLM_BACKEND"] = "vllm"

Expand Down
26 changes: 26 additions & 0 deletions modules/llm/startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,29 @@ async def auto_start_bridge() -> None:
logger.warning(f"🌉 Bridge auto-start failed: {result.get('error', 'unknown')}")
except Exception as e:
logger.error(f"🌉 Error during bridge auto-start: {e}")


async def bridge_health_check() -> None:
"""Periodic health check for bridge — auto-restart if crashed."""
from bridge_manager import bridge_manager
from db.integration import async_cloud_provider_manager

try:
bridge_providers = await async_cloud_provider_manager.get_by_type(
"claude_bridge", enabled_only=True
)
if not bridge_providers:
return

if bridge_manager.is_running:
return

# Bridge should be running but isn't — restart it
logger.warning("🌉 Bridge not running, auto-restarting...")
result = await bridge_manager.start()
if result.get("status") == "ok":
logger.info(f"🌉 Bridge auto-restarted on port {result.get('port', 8787)}")
else:
logger.warning(f"🌉 Bridge auto-restart failed: {result.get('error', 'unknown')}")
except Exception as e:
logger.error(f"🌉 Bridge health check error: {e}")
5 changes: 4 additions & 1 deletion orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ async def startup_event():
# Auto-start bots and bridge
from modules.channels.telegram.startup import auto_start_bots as auto_start_telegram
from modules.channels.whatsapp.startup import auto_start_bots as auto_start_whatsapp
from modules.llm.startup import auto_start_bridge
from modules.llm.startup import auto_start_bridge, bridge_health_check

await auto_start_telegram()
await auto_start_whatsapp()
Expand All @@ -260,6 +260,9 @@ async def startup_event():
from modules.kanban.tasks import sync_kanban_issues

task_registry.register("session-cleanup", cleanup_expired_sessions, interval=3600)
task_registry.register(
"bridge-health-check", bridge_health_check, interval=60, initial_delay=30
)
task_registry.register(
"periodic-vacuum", periodic_vacuum, interval=7 * 24 * 3600, initial_delay=24 * 3600
)
Expand Down
Loading