diff --git a/admin/src/views/LoginView.vue b/admin/src/views/LoginView.vue index cf7d0b1..15e9668 100644 --- a/admin/src/views/LoginView.vue +++ b/admin/src/views/LoginView.vue @@ -304,7 +304,7 @@ async function handleSubmit() {
Optional[dict]: """Update session title, system prompt, pinned status, RAG config, or context files.""" result = await self.session.execute( @@ -216,6 +218,10 @@ async def update_session( ) if web_search_enabled is not None: session.web_search_enabled = web_search_enabled + if source is not None: + session.source = source + if source_id is not None: + session.source_id = source_id if source_id else None session.updated = datetime.utcnow() await self.session.flush() diff --git a/mobile/src/api/admin.ts b/mobile/src/api/admin.ts index 8bb5712..ffd7ad1 100644 --- a/mobile/src/api/admin.ts +++ b/mobile/src/api/admin.ts @@ -24,14 +24,119 @@ export interface LlmOption { type: "vllm" | "cloud"; } +export interface ShareableUser { + id: number; + username: string; + display_name: string; + role: string; +} + +export interface ChatShare { + id: number; + session_id: string; + user_id: number; + permission: "read" | "write"; + shared_by: number; + shared_at: string; + username: string; + display_name: string; + is_default_mobile: boolean; +} + +export interface MobileInstance { + id: string; + name: string; + description: string; + enabled: boolean; + llm_backend: string; + llm_persona: string; + system_prompt: string | null; + rag_mode: string; + knowledge_collection_ids: number[] | null; + share_count: number; +} + +export interface MobileShare { + id: number; + resource_id: string; + user_id: number; + permission: "view" | "edit"; + username: string; + display_name: string; +} + export const adminApi = { + // LLM providers getProviders: () => api.get<{ providers: CloudProvider[] }>( "/admin/llm/providers?enabled_only=true", ), + // RAG collections getCollections: () => api.get<{ collections: KnowledgeCollection[] }>( "/admin/wiki-rag/collections", ), + + // Shareable users + getShareableUsers: () => + api.get<{ users: ShareableUser[] }>( + "/admin/chat/shareable-users", + ), + + // Chat shares + getSessionShares: (sessionId: string) => + api.get<{ shares: ChatShare[] }>( + `/admin/chat/sessions/${sessionId}/shares`, + ), + + shareSession: (sessionId: string, userId: number, permission: "read" | "write" = "read") => + api.post<{ share: ChatShare }>( + `/admin/chat/sessions/${sessionId}/shares`, + { user_id: userId, permission }, + ), + + removeSessionShare: (sessionId: string, userId: number) => + api.delete<{ status: string }>( + `/admin/chat/sessions/${sessionId}/shares/${userId}`, + ), + + // Default mobile session + getDefaultMobileUsers: (sessionId: string) => + api.get<{ users: ShareableUser[] }>( + `/admin/chat/sessions/${sessionId}/default-mobile-users`, + ), + + setDefaultMobile: (sessionId: string, userIds: number[]) => + api.put<{ shares: ChatShare[] }>( + `/admin/chat/sessions/${sessionId}/default-mobile`, + { user_ids: userIds }, + ), + + removeDefaultMobile: (sessionId: string, userId: number) => + api.delete<{ status: string }>( + `/admin/chat/sessions/${sessionId}/default-mobile/${userId}`, + ), + + // Mobile instances + getMobileInstances: () => + api.get<{ instances: MobileInstance[] }>( + "/admin/mobile/instances", + ), + + getMobileInstanceShares: (instanceId: string) => + api.get<{ shares: MobileShare[] }>( + `/admin/mobile/instances/${instanceId}/shares`, + ), + + assignMobileInstance: (instanceId: string, userId: number, permission: "view" | "edit" = "edit") => + api.post<{ share: MobileShare }>( + `/admin/mobile/instances/${instanceId}/shares`, + { user_id: userId, permission }, + ), + + removeMobileInstanceShare: (instanceId: string, userId: number) => + api.delete<{ status: string }>( + `/admin/mobile/instances/${instanceId}/shares/${userId}`, + ), }; diff --git a/mobile/src/api/chat.ts b/mobile/src/api/chat.ts index 9212ad7..37e6846 100644 --- a/mobile/src/api/chat.ts +++ b/mobile/src/api/chat.ts @@ -38,6 +38,7 @@ export interface ChatSession { messages: ChatMessage[]; system_prompt?: string; source?: string | null; + source_id?: string | null; context_files?: ContextFile[]; web_search_enabled?: boolean; created: string; @@ -100,6 +101,8 @@ export const chatApi = { system_prompt?: string; context_files?: ContextFile[]; web_search_enabled?: boolean; + source?: string; + source_id?: string; }, ) => api.put<{ session: ChatSession }>( diff --git a/mobile/src/components/MessageBubble.vue b/mobile/src/components/MessageBubble.vue index 0ee581f..accc478 100644 --- a/mobile/src/components/MessageBubble.vue +++ b/mobile/src/components/MessageBubble.vue @@ -7,13 +7,11 @@ const props = defineProps<{ message: ChatMessage; isStreaming?: boolean; isSpeaking?: boolean; - isAdmin?: boolean; }>(); const emit = defineEmits<{ speak: [text: string, id: string]; stopSpeak: []; - copy: [text: string]; edit: [messageId: string, content: string]; saveToContext: [messageId: string, content: string]; summarizeBranch: [messageId: string]; @@ -153,50 +151,49 @@ function cancelEdit() { - - - + + - - + + - - + + - - + +
@@ -233,50 +230,49 @@ function cancelEdit() { - - - + + - - + + - - + + - - + + diff --git a/mobile/src/views/ChatListView.vue b/mobile/src/views/ChatListView.vue index 509cd70..16fbd46 100644 --- a/mobile/src/views/ChatListView.vue +++ b/mobile/src/views/ChatListView.vue @@ -2,6 +2,14 @@ import { ref, computed, onMounted } from "vue"; import { useRouter } from "vue-router"; import { chatApi, type ChatSessionSummary } from "@/api/chat"; +import { + adminApi, + type CloudProvider, + type KnowledgeCollection, + type ShareableUser, + type ChatShare, + type MobileInstance, +} from "@/api/admin"; import { useAuthStore } from "@/stores/auth"; const router = useRouter(); @@ -15,6 +23,32 @@ const isCreating = ref(false); const welcomeInput = ref(""); const isSending = ref(false); +// Admin data (loaded once) +const llmProviders = ref([]); +const ragCollections = ref([]); +const shareableUsers = ref([]); +const mobileInstances = ref([]); + +// Per-session expanded panel +const expandedSessionId = ref(null); +const panelTab = ref<"llm" | "rag" | "share" | "mobile" | "rename">("llm"); +const panelLoading = ref(false); + +// Per-session state (loaded when expanded) +const sessionShares = ref([]); + +// Rename +const renameValue = ref(""); + +// LLM per-session override +const sessionLlm = ref("default"); + +// RAG per-session override +const sessionRagIds = ref([]); + +// Mobile instance attached to session +const sessionMobileInstanceId = ref(null); + // For non-admins: only shared chats const visibleSessions = computed(() => { if (isAdmin.value) return sessions.value; @@ -30,7 +64,6 @@ async function loadSessions() { (a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime(), ); - // Non-admin: auto-create chat and go straight to it if (!isAdmin.value) { await autoOpenChat(); } @@ -41,8 +74,24 @@ async function loadSessions() { } } +async function loadAdminData() { + try { + const [providerData, collectionData, usersData, instancesData] = await Promise.all([ + adminApi.getProviders(), + adminApi.getCollections(), + adminApi.getShareableUsers(), + adminApi.getMobileInstances(), + ]); + llmProviders.value = providerData.providers; + ragCollections.value = collectionData.collections.filter((c) => c.enabled); + shareableUsers.value = usersData.users; + mobileInstances.value = instancesData.instances.filter((i) => i.enabled); + } catch { + // Non-critical + } +} + async function autoOpenChat() { - // Priority 1: default mobile session try { const resp = await chatApi.getMyDefaultMobileSession(); if (resp.session_id) { @@ -50,19 +99,17 @@ async function autoOpenChat() { return; } } catch { - // fallback to other logic + // fallback } - // Priority 2: first visible (shared) session if (visibleSessions.value.length > 0) { router.replace(`/chat/${visibleSessions.value[0]!.id}`); return; } - // Priority 3: create a new session try { const data = await chatApi.createSession(); router.replace(`/chat/${data.session.id}`); } catch { - // fallback — stay on the list + // stay on list } } @@ -89,11 +136,146 @@ async function deleteSession(id: string, event: Event) { try { await chatApi.deleteSession(id); sessions.value = sessions.value.filter((s) => s.id !== id); + if (expandedSessionId.value === id) expandedSessionId.value = null; } catch (e) { error.value = e instanceof Error ? e.message : "Не удалось удалить"; } } +// === Expand/collapse session panel === + +async function toggleSessionPanel(sessionId: string, tab: "llm" | "rag" | "share" | "mobile" | "rename", event: Event) { + event.stopPropagation(); + if (expandedSessionId.value === sessionId && panelTab.value === tab) { + expandedSessionId.value = null; + return; + } + expandedSessionId.value = sessionId; + panelTab.value = tab; + + if (tab === "share") { + await loadSessionShares(sessionId); + } else if (tab === "mobile") { + await loadSessionMobileInstance(sessionId); + } else if (tab === "rename") { + const s = sessions.value.find((s) => s.id === sessionId); + renameValue.value = s?.title || ""; + } else if (tab === "llm" || tab === "rag") { + // Load session details to get current overrides + await loadSessionDetails(sessionId); + } +} + +async function loadSessionDetails(sessionId: string) { + panelLoading.value = true; + try { + await chatApi.getSession(sessionId); + // Reset to defaults — session doesn't store LLM override persistently + sessionLlm.value = "default"; + sessionRagIds.value = []; + // If session has web_search or other settings, we could load them here + } catch { + // ignore + } finally { + panelLoading.value = false; + } +} + +async function loadSessionShares(sessionId: string) { + panelLoading.value = true; + try { + const data = await adminApi.getSessionShares(sessionId); + sessionShares.value = data.shares; + } catch { + sessionShares.value = []; + } finally { + panelLoading.value = false; + } +} + +async function loadSessionMobileInstance(sessionId: string) { + panelLoading.value = true; + try { + const data = await chatApi.getSession(sessionId); + // source_id stores the mobile instance id when source is "mobile" + sessionMobileInstanceId.value = data.session.source_id || null; + } catch { + sessionMobileInstanceId.value = null; + } finally { + panelLoading.value = false; + } +} + +// === Actions === + +async function renameSession() { + if (!expandedSessionId.value || !renameValue.value.trim()) return; + try { + await chatApi.updateSession(expandedSessionId.value, { title: renameValue.value.trim() }); + const s = sessions.value.find((s) => s.id === expandedSessionId.value); + if (s) s.title = renameValue.value.trim(); + expandedSessionId.value = null; + } catch (e) { + error.value = e instanceof Error ? e.message : "Не удалось переименовать"; + } +} + +async function shareWithUser(userId: number) { + if (!expandedSessionId.value) return; + try { + await adminApi.shareSession(expandedSessionId.value, userId, "read"); + await loadSessionShares(expandedSessionId.value); + // Update share count in list + const s = sessions.value.find((s) => s.id === expandedSessionId.value); + if (s) s.share_count = sessionShares.value.length; + } catch (e) { + error.value = e instanceof Error ? e.message : "Не удалось поделиться"; + } +} + +async function removeShare(userId: number) { + if (!expandedSessionId.value) return; + try { + await adminApi.removeSessionShare(expandedSessionId.value, userId); + await loadSessionShares(expandedSessionId.value); + const s = sessions.value.find((s) => s.id === expandedSessionId.value); + if (s) s.share_count = sessionShares.value.length; + } catch (e) { + error.value = e instanceof Error ? e.message : "Не удалось убрать доступ"; + } +} + +async function attachMobileInstance(instanceId: string | null) { + if (!expandedSessionId.value) return; + try { + await chatApi.updateSession(expandedSessionId.value, { + source: instanceId ? "mobile" : "admin", + source_id: instanceId || "", + }); + sessionMobileInstanceId.value = instanceId; + } catch (e) { + error.value = e instanceof Error ? e.message : "Не удалось привязать"; + } +} + +const llmOptions = computed(() => { + const opts = [ + { value: "default", label: "По умолчанию" }, + { value: "vllm", label: "vLLM (Local)" }, + ]; + for (const p of llmProviders.value) { + opts.push({ value: `cloud:${p.id}`, label: `${p.name} (${p.model_name})` }); + } + return opts; +}); + +// Users not yet shared with +const unsharedUsers = computed(() => { + const sharedIds = new Set(sessionShares.value.map((s) => s.user_id)); + return shareableUsers.value.filter((u) => !sharedIds.has(u.id)); +}); + + function formatDate(dateStr: string): string { const date = new Date(dateStr); const now = new Date(); @@ -106,8 +288,8 @@ function formatDate(dateStr: string): string { minute: "2-digit", }); } - if (days === 1) return "yesterday"; - if (days < 7) return `${days}d ago`; + if (days === 1) return "вчера"; + if (days < 7) return `${days}д назад`; return date.toLocaleDateString("ru", { day: "numeric", month: "short", @@ -124,13 +306,10 @@ async function sendFromWelcome() { if (!text || isSending.value) return; isSending.value = true; try { - // If there are shared chats, open the most recent one if (visibleSessions.value.length > 0) { const sessionId = visibleSessions.value[0]!.id; - // Navigate to chat — the message will be typed by user there router.push(`/chat/${sessionId}?msg=${encodeURIComponent(text)}`); } else { - // Create a new session and navigate const data = await chatApi.createSession(text); router.push(`/chat/${data.session.id}?msg=${encodeURIComponent(text)}`); } @@ -141,7 +320,10 @@ async function sendFromWelcome() { } } -onMounted(loadSessions); +onMounted(() => { + loadSessions(); + if (isAdmin.value) loadAdminData(); +});