From c0fc11ad5471b293008d67f52d9d1984fb71f7da Mon Sep 17 00:00:00 2001 From: ShaerWare Date: Fri, 27 Mar 2026 23:00:58 +0500 Subject: [PATCH] fix: restore admin controls in mobile chat + safe area for nav bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile ChatView: restored LLM selector, RAG collections, system prompt editor, export (copy/md/json) for admin users. MessageBubble: role-gated action buttons (edit, regenerate, summarize, delete) — admin only. Added edge-to-edge Android support (transparent nav bar, WindowCompat) and safe-input-bottom CSS class with JS visualViewport fallback for devices where env(safe-area-inset-bottom) returns 0. Admin ChatView: fixed panel height (h-full on BranchTree, ArtifactPanel, SettingsPanel), hide welcome screen when panels are open. ## NEWS 📱 **Мобильное приложение: полный набор инструментов для админа** В чат мобильного приложения вернулись все элементы управления для администратора: выбор LLM-провайдера, базы знаний (RAG), редактор системного промпта и экспорт диалогов. Кнопки действий с сообщениями (редактирование, перегенерация, суммаризация) теперь видны только администраторам. Также исправлено перекрытие поля ввода навигационными кнопками Android. Co-Authored-By: Claude Opus 4.6 --- admin/src/components/BranchTree.vue | 2 +- admin/src/views/ChatView.vue | 8 +- mobile/src/components/MessageBubble.vue | 167 ++++----- mobile/src/views/ChatView.vue | 427 ++++++++++++++++++++++-- 4 files changed, 487 insertions(+), 117 deletions(-) diff --git a/admin/src/components/BranchTree.vue b/admin/src/components/BranchTree.vue index 3806635..bab34fa 100644 --- a/admin/src/components/BranchTree.vue +++ b/admin/src/components/BranchTree.vue @@ -201,7 +201,7 @@ onUnmounted(() => { diff --git a/mobile/src/views/ChatView.vue b/mobile/src/views/ChatView.vue index b878613..64166d9 100644 --- a/mobile/src/views/ChatView.vue +++ b/mobile/src/views/ChatView.vue @@ -9,6 +9,12 @@ import { type BranchNode, type ContextFile, } from "@/api/chat"; +import { + adminApi, + type CloudProvider, + type KnowledgeCollection, + type LlmOption, +} from "@/api/admin"; import { useAuthStore } from "@/stores/auth"; import { useTts } from "@/composables/useTts"; import MessageBubble from "@/components/MessageBubble.vue"; @@ -18,6 +24,7 @@ const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const tts = useTts(); +const isAdmin = computed(() => auth.isAdmin); const sessionId = computed(() => route.params.id as string); const title = ref("Chat"); @@ -32,14 +39,39 @@ const messagesContainer = ref(null); // Panels const showBranches = ref(false); const showContextFiles = ref(false); +const showSettings = ref(false); const branches = ref([]); const contextFiles = ref([]); const branchesLoading = ref(false); const contextFileInputRef = ref(null); - -// Web search +const settingsTab = ref<"files" | "prompt">("files"); + +// Admin: LLM & RAG +const llmProviders = ref([]); +const ragCollections = ref([]); +const selectedLlm = ref("default"); +const selectedCollectionIds = ref([]); +const showLlmDropdown = ref(false); +const showRagDropdown = ref(false); +const showExportDropdown = ref(false); +const customPrompt = ref(""); const webSearchEnabled = ref(false); +const llmOptions = computed(() => { + const opts: LlmOption[] = [ + { value: "default", label: "Default", type: "vllm" }, + { value: "vllm", label: "vLLM (Local)", type: "vllm" }, + ]; + for (const p of llmProviders.value) { + opts.push({ + value: `cloud:${p.id}`, + label: `${p.name} (${p.model_name})`, + type: "cloud", + }); + } + return opts; +}); + // Resizable panel height (portrait) / width (landscape) const panelSize = ref(Math.round(window.innerHeight * 0.5)); const isResizing = ref(false); @@ -52,6 +84,23 @@ function updateOrientation() { isLandscape.value = window.innerWidth > window.innerHeight; } +// Safe area bottom (JS fallback for Android) +const safeAreaBottom = ref(0); +function detectSafeArea() { + const safeVal = getComputedStyle(document.documentElement).getPropertyValue("--safe-area-bottom").trim(); + const px = parseInt(safeVal, 10); + if (px > 0) { + safeAreaBottom.value = px; + } else { + // Fallback: difference between screen height and viewport height + const vv = window.visualViewport; + if (vv) { + const diff = window.screen.height - vv.height; + safeAreaBottom.value = diff > 20 ? Math.min(diff, 60) : 0; + } + } +} + let abortStream: (() => void) | null = null; // === Resize handle === @@ -80,6 +129,22 @@ function onResizeEnd() { isResizing.value = false; } +// === Admin data === + +async function loadAdminData() { + if (!isAdmin.value) return; + try { + const [providerData, collectionData] = await Promise.all([ + adminApi.getProviders(), + adminApi.getCollections(), + ]); + llmProviders.value = providerData.providers; + ragCollections.value = collectionData.collections.filter((c) => c.enabled); + } catch { + // Non-critical + } +} + // === Session === async function loadSession() { @@ -93,6 +158,7 @@ async function loadSession() { (m) => m.is_active !== false, ); contextFiles.value = data.session.context_files || []; + customPrompt.value = data.session.system_prompt || ""; webSearchEnabled.value = !!data.session.web_search_enabled; await scrollToBottom(); } catch (e) { @@ -124,6 +190,18 @@ async function sendMessage(content: string) { isStreaming.value = true; streamingContent.value = ""; + // Build LLM/RAG overrides for admin + const overrides: Record = {}; + if (isAdmin.value) { + if (selectedLlm.value && selectedLlm.value !== "default") { + overrides.llm_backend = selectedLlm.value; + } + if (selectedCollectionIds.value.length > 0) { + overrides.rag_mode = "selected"; + overrides.knowledge_collection_ids = selectedCollectionIds.value; + } + } + const { abort } = chatApi.streamMessage( sessionId.value, content, @@ -166,6 +244,7 @@ async function sendMessage(content: string) { break; } }, + overrides, ); abortStream = abort; @@ -218,6 +297,7 @@ async function loadBranches() { function toggleBranches() { showContextFiles.value = false; + showSettings.value = false; showBranches.value = !showBranches.value; if (showBranches.value) loadBranches(); } @@ -295,6 +375,7 @@ const flatBranches = computed(() => flattenBranches(branches.value)); function toggleContextFiles() { showBranches.value = false; + showSettings.value = false; showContextFiles.value = !showContextFiles.value; } @@ -335,6 +416,82 @@ async function saveContextFiles() { } } +// === Settings panel === + +function toggleSettings() { + showBranches.value = false; + showContextFiles.value = false; + showSettings.value = !showSettings.value; +} + +async function saveSystemPrompt() { + try { + await chatApi.updateSession(sessionId.value, { + system_prompt: customPrompt.value, + }); + } catch (e) { + error.value = e instanceof Error ? e.message : "Не удалось сохранить промпт"; + } +} + +// === LLM / RAG dropdowns === + +function selectLlm(value: string) { + selectedLlm.value = value; + showLlmDropdown.value = false; +} + +function toggleCollection(id: number) { + const idx = selectedCollectionIds.value.indexOf(id); + if (idx >= 0) { + selectedCollectionIds.value.splice(idx, 1); + } else { + selectedCollectionIds.value.push(id); + } +} + +// === Export === + +function copyChatToClipboard() { + const text = messages.value + .map((m) => `${m.role === "user" ? "Вы" : "Ассистент"}: ${m.content}`) + .join("\n\n"); + navigator.clipboard.writeText(text); + showExportDropdown.value = false; +} + +function exportChatMarkdown() { + const md = messages.value + .map((m) => `## ${m.role === "user" ? "Вы" : "Ассистент"}\n\n${m.content}`) + .join("\n\n---\n\n"); + downloadFile(`${title.value}.md`, md, "text/markdown"); + showExportDropdown.value = false; +} + +function exportChatJson() { + const data = { + title: title.value, + exported: new Date().toISOString(), + messages: messages.value.map((m) => ({ + role: m.role, + content: m.content, + timestamp: m.timestamp, + })), + }; + downloadFile(`${title.value}.json`, JSON.stringify(data, null, 2), "application/json"); + showExportDropdown.value = false; +} + +function downloadFile(name: string, content: string, type: string) { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + URL.revokeObjectURL(url); +} + // === Message actions === async function handleEditMessage(messageId: string, content: string) { @@ -352,6 +509,7 @@ function handleSaveToContext(_messageId: string, content: string) { saveContextFiles(); showContextFiles.value = true; showBranches.value = false; + showSettings.value = false; } async function handleSummarizeBranch(messageId: string) { @@ -362,6 +520,7 @@ async function handleSummarizeBranch(messageId: string) { saveContextFiles(); showContextFiles.value = true; showBranches.value = false; + showSettings.value = false; } catch (e) { error.value = e instanceof Error ? e.message : "Не удалось суммаризировать"; } @@ -395,16 +554,24 @@ async function handleDeleteFromMessage(messageId: string) { } } -const anyPanelOpen = computed(() => showBranches.value || showContextFiles.value); +const anyPanelOpen = computed(() => showBranches.value || showContextFiles.value || showSettings.value); const activePanelName = computed(() => { if (showBranches.value) return "Дерево веток"; if (showContextFiles.value) return "Файлы контекста"; + if (showSettings.value) return "Настройки"; return ""; }); function closePanel() { showBranches.value = false; showContextFiles.value = false; + showSettings.value = false; +} + +function onGlobalClick() { + showLlmDropdown.value = false; + showRagDropdown.value = false; + showExportDropdown.value = false; } watch(streamingContent, () => { @@ -413,7 +580,8 @@ watch(streamingContent, () => { onMounted(async () => { await loadSession(); - // Auto-send message from welcome screen + loadAdminData(); + detectSafeArea(); const msg = route.query.msg as string | undefined; if (msg) { router.replace({ path: route.path, query: {} }); @@ -425,6 +593,7 @@ onMounted(async () => { window.addEventListener("mouseup", onResizeEnd); window.addEventListener("touchmove", onResizeMove, { passive: false }); window.addEventListener("touchend", onResizeEnd); + document.addEventListener("click", onGlobalClick); }); onUnmounted(() => { @@ -433,6 +602,7 @@ onUnmounted(() => { window.removeEventListener("mouseup", onResizeEnd); window.removeEventListener("touchmove", onResizeMove); window.removeEventListener("touchend", onResizeEnd); + document.removeEventListener("click", onGlobalClick); }); @@ -460,20 +630,125 @@ onUnmounted(() => { {{ title }} + + + - - - - -