Răsfoiți Sursa

独立 Bot 设置并整合项目记忆与虚拟机管理

Samien 1 lună în urmă
părinte
comite
fcef03f478

+ 0 - 5
src/client/components/app/SettingsCenterDialog.vue

@@ -11,7 +11,6 @@ import WorkspaceVoiceProviders from '@/components/workspace/WorkspaceVoiceProvid
 import SystemManagementPanel from '@/components/app/SystemManagementPanel.vue'
 import SystemManagementPanel from '@/components/app/SystemManagementPanel.vue'
 import SystemOverviewPanel from '@/components/app/SystemOverviewPanel.vue'
 import SystemOverviewPanel from '@/components/app/SystemOverviewPanel.vue'
 import FileAccessPanel from '@/components/app/FileAccessPanel.vue'
 import FileAccessPanel from '@/components/app/FileAccessPanel.vue'
-import LocalVmSettingsPanel from '@/components/app/LocalVmSettingsPanel.vue'
 import SystemUpdatePanel from '@/components/app/SystemUpdatePanel.vue'
 import SystemUpdatePanel from '@/components/app/SystemUpdatePanel.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AccountInitialAvatar from '@/components/common/AccountInitialAvatar.vue'
 import AccountInitialAvatar from '@/components/common/AccountInitialAvatar.vue'
@@ -31,7 +30,6 @@ type SettingsPage =
   | 'system-push'
   | 'system-push'
   | 'system-nodes'
   | 'system-nodes'
   | 'system-voice'
   | 'system-voice'
-  | 'system-local-vm'
   | 'system-update'
   | 'system-update'
 
 
 type SettingsIcon = 'users' | 'model' | 'settings' | 'panel' | 'monitor' | 'sun' | 'link' | 'bell' | 'audio' | 'refresh'
 type SettingsIcon = 'users' | 'model' | 'settings' | 'panel' | 'monitor' | 'sun' | 'link' | 'bell' | 'audio' | 'refresh'
@@ -108,7 +106,6 @@ const accountItems = computed<NavigationItem[]>(() => [
   { key: 'appearance', label: '外观', icon: 'sun' },
   { key: 'appearance', label: '外观', icon: 'sun' },
 ])
 ])
 const systemItems: NavigationItem[] = [
 const systemItems: NavigationItem[] = [
-  { key: 'system-local-vm', label: '本地虚拟机', icon: 'monitor' },
   { key: 'system-overview', label: '系统概览', icon: 'panel' },
   { key: 'system-overview', label: '系统概览', icon: 'panel' },
   { key: 'system-file-access', label: '文件访问', icon: 'panel' },
   { key: 'system-file-access', label: '文件访问', icon: 'panel' },
   { key: 'system-users', label: '用户与权限', icon: 'users' },
   { key: 'system-users', label: '用户与权限', icon: 'users' },
@@ -132,7 +129,6 @@ const activeTitle = computed(() => ({
   'account-security': '登录与安全',
   'account-security': '登录与安全',
   'account-mobile': '手机登录',
   'account-mobile': '手机登录',
   appearance: '外观',
   appearance: '外观',
-  'system-local-vm': '本地虚拟机',
   'system-overview': '系统概览',
   'system-overview': '系统概览',
   'system-file-access': '文件访问',
   'system-file-access': '文件访问',
   'system-users': '用户与权限',
   'system-users': '用户与权限',
@@ -365,7 +361,6 @@ function requestModeSwitch() {
                 </section>
                 </section>
                 <SystemOverviewPanel v-else-if="activePage === 'system-overview' && isAdmin" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @navigate="selectPage" />
                 <SystemOverviewPanel v-else-if="activePage === 'system-overview' && isAdmin" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @navigate="selectPage" />
                 <FileAccessPanel v-else-if="activePage === 'system-file-access' && isAdmin" :profile="activeProfile?.name" @dirty-change="setDirty('system-file-access', $event)" />
                 <FileAccessPanel v-else-if="activePage === 'system-file-access' && isAdmin" :profile="activeProfile?.name" @dirty-change="setDirty('system-file-access', $event)" />
-                <LocalVmSettingsPanel v-else-if="activePage === 'system-local-vm' && isAdmin" />
                 <SystemManagementPanel v-else-if="activePage === 'system-users' && isAdmin" section="users" :profiles="profiles" :active="true" @dirty-change="setDirty('system-users', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-users' && isAdmin" section="users" :profiles="profiles" :active="true" @dirty-change="setDirty('system-users', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-connection' && isAdmin" section="connection" :active="true" :profiles="profiles" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @dirty-change="setDirty('system-connection', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-connection' && isAdmin" section="connection" :active="true" :profiles="profiles" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @dirty-change="setDirty('system-connection', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-push' && isAdmin" section="push" :active="true" @dirty-change="setDirty('system-push', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-push' && isAdmin" section="push" :active="true" @dirty-change="setDirty('system-push', $event)" />

+ 18 - 8
src/client/components/app/WorkspaceShell.vue

@@ -13,6 +13,7 @@ import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 const AboutDialog = defineAsyncComponent(() => import('./AboutDialog.vue'))
 const AboutDialog = defineAsyncComponent(() => import('./AboutDialog.vue'))
 const UpdateCheckDialog = defineAsyncComponent(() => import('./UpdateCheckDialog.vue'))
 const UpdateCheckDialog = defineAsyncComponent(() => import('./UpdateCheckDialog.vue'))
 const BotPluginsDialog = defineAsyncComponent(() => import('@/components/workspace/BotPluginsDialog.vue'))
 const BotPluginsDialog = defineAsyncComponent(() => import('@/components/workspace/BotPluginsDialog.vue'))
+const BotSettingsDialog = defineAsyncComponent(() => import('@/components/workspace/BotSettingsDialog.vue'))
 
 
 type NavItem = {
 type NavItem = {
   key: 'chat' | 'history' | 'groups' | 'kanban' | 'files'
   key: 'chat' | 'history' | 'groups' | 'kanban' | 'files'
@@ -33,7 +34,6 @@ type SettingsPage =
   | 'system-push'
   | 'system-push'
   | 'system-nodes'
   | 'system-nodes'
   | 'system-voice'
   | 'system-voice'
-  | 'system-local-vm'
   | 'system-update'
   | 'system-update'
 
 
 const SIDEBAR_COLLAPSED_KEY = 'hermes-yaoyao:sidebar-collapsed'
 const SIDEBAR_COLLAPSED_KEY = 'hermes-yaoyao:sidebar-collapsed'
@@ -96,6 +96,7 @@ const emit = defineEmits<{
   createAgent: []
   createAgent: []
   createRemoteAgent: []
   createRemoteAgent: []
   createGroup: []
   createGroup: []
+  botSettingsChanged: []
 }>()
 }>()
 
 
 const route = useRoute()
 const route = useRoute()
@@ -103,7 +104,8 @@ const router = useRouter()
 const mobileDrawerOpen = ref(false)
 const mobileDrawerOpen = ref(false)
 const profileMenuOpen = ref(false)
 const profileMenuOpen = ref(false)
 const settingsOpen = ref(false)
 const settingsOpen = ref(false)
-const standalone = ref<'plugins' | 'about' | 'update' | null>(null)
+const standalone = ref<'plugins' | 'about' | 'update' | 'bot-settings' | null>(null)
+const botSettingsPage = ref<'projects' | 'vm'>('projects')
 const standaloneReturnFocus = ref<HTMLElement>()
 const standaloneReturnFocus = ref<HTMLElement>()
 const settingsPage = ref<SettingsPage>('agent-identity')
 const settingsPage = ref<SettingsPage>('agent-identity')
 const settingsReturnFocus = ref<HTMLButtonElement>()
 const settingsReturnFocus = ref<HTMLButtonElement>()
@@ -276,16 +278,18 @@ async function openToolsMenu(event: MouseEvent) {
   if (toolsMenuOpen.value) { closeToolsMenu(); return }
   if (toolsMenuOpen.value) { closeToolsMenu(); return }
   toolsTrigger = event.currentTarget as HTMLButtonElement
   toolsTrigger = event.currentTarget as HTMLButtonElement
   const rect = toolsTrigger.getBoundingClientRect()
   const rect = toolsTrigger.getBoundingClientRect()
-  toolsPosition.value = { left: `${Math.max(8, Math.min(rect.left, window.innerWidth - 208))}px`, top: `${Math.max(8, rect.top - (window.innerWidth < 768 ? 106 : 90))}px` }
+  const height = 3 * (window.innerWidth < 768 ? 44 : 36) + 18
+  toolsPosition.value = { left: `${Math.max(8, Math.min(rect.left, window.innerWidth - 208))}px`, top: `${Math.max(8, rect.top - height)}px` }
   settingsMenuOpen.value = false; createMenuOpen.value = false; toolsMenuOpen.value = true
   settingsMenuOpen.value = false; createMenuOpen.value = false; toolsMenuOpen.value = true
   await nextTick(); toolsMenu.value?.querySelector<HTMLElement>('[role="menuitem"]')?.focus()
   await nextTick(); toolsMenu.value?.querySelector<HTMLElement>('[role="menuitem"]')?.focus()
 }
 }
-function chooseTool(page: 'apps' | 'automations') {
+function chooseTool(page: 'apps' | 'automations' | 'bot-settings') {
   toolsMenuOpen.value = false
   toolsMenuOpen.value = false
   if (page === 'automations') { void navigate('/conversations/automations?from=' + encodeURIComponent(route.fullPath)); return }
   if (page === 'automations') { void navigate('/conversations/automations?from=' + encodeURIComponent(route.fullPath)); return }
   standaloneReturnFocus.value = toolsTrigger?.closest('.mobile-drawer') ? mobileNavigationTrigger.value : toolsTrigger ?? undefined
   standaloneReturnFocus.value = toolsTrigger?.closest('.mobile-drawer') ? mobileNavigationTrigger.value : toolsTrigger ?? undefined
   mobileDrawerOpen.value = false
   mobileDrawerOpen.value = false
-  standalone.value = 'plugins'
+  botSettingsPage.value = 'projects'
+  standalone.value = page === 'bot-settings' ? 'bot-settings' : 'plugins'
 }
 }
 function switchInterfaceMode() {
 function switchInterfaceMode() {
   if (!props.isAdmin) return
   if (!props.isAdmin) return
@@ -418,7 +422,12 @@ watch(() => activeNav.value.key, () => {
   profileMenuOpen.value = false
   profileMenuOpen.value = false
 })
 })
 
 
-function showLocalVmSettings(){if(props.isAdmin)openSettings('system-local-vm')}
+function showLocalVmSettings(){
+  if(!props.isAdmin)return
+  standaloneReturnFocus.value = document.activeElement instanceof HTMLElement ? document.activeElement : undefined
+  settingsOpen.value = false; settingsMenuOpen.value = false; toolsMenuOpen.value = false; mobileDrawerOpen.value = false
+  botSettingsPage.value = 'vm'; standalone.value = 'bot-settings'
+}
 onMounted(() => {
 onMounted(() => {
   window.addEventListener('yaoyao:local-vm-settings',showLocalVmSettings)
   window.addEventListener('yaoyao:local-vm-settings',showLocalVmSettings)
   try { sidebarCollapsed.value = localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1' } catch { /* optional persistence */ }
   try { sidebarCollapsed.value = localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1' } catch { /* optional persistence */ }
@@ -432,7 +441,7 @@ onBeforeUnmount(() => {
   document.removeEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSidebarSearchClosed)
   document.removeEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSidebarSearchClosed)
   document.removeEventListener('keydown', workspaceKeydown)
   document.removeEventListener('keydown', workspaceKeydown)
 })
 })
-defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}})
+defineExpose({openLocalVm:showLocalVmSettings})
 </script>
 </script>
 
 
 <template>
 <template>
@@ -695,6 +704,7 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
     <AboutDialog v-if="standalone === 'about'" @close="closeStandalone" />
     <AboutDialog v-if="standalone === 'about'" @close="closeStandalone" />
     <UpdateCheckDialog v-if="standalone === 'update' && isAdmin" @close="closeStandalone" @manage="openUpdateSettings" />
     <UpdateCheckDialog v-if="standalone === 'update' && isAdmin" @close="closeStandalone" @manage="openUpdateSettings" />
     <BotPluginsDialog v-if="standalone === 'plugins' && applicationWorkspace" @close="closeStandalone" />
     <BotPluginsDialog v-if="standalone === 'plugins' && applicationWorkspace" @close="closeStandalone" />
+    <BotSettingsDialog v-if="standalone === 'bot-settings'" :is-admin="isAdmin" :initial-page="botSettingsPage" @close="closeStandalone" @changed="emit('botSettingsChanged')" />
     <SettingsCenterDialog
     <SettingsCenterDialog
       :open="settingsOpen"
       :open="settingsOpen"
       :bot-mode="applicationWorkspace"
       :bot-mode="applicationWorkspace"
@@ -731,7 +741,7 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
           <button v-if="isAdmin" type="button" role="menuitem" @click="chooseSettingsAction('bots')"><AppIcon :name="applicationWorkspace ? 'chat' : 'users'" :size="17" />{{ applicationWorkspace ? '进入聊天模式' : '进入 Bot 模式' }}</button>
           <button v-if="isAdmin" type="button" role="menuitem" @click="chooseSettingsAction('bots')"><AppIcon :name="applicationWorkspace ? 'chat' : 'users'" :size="17" />{{ applicationWorkspace ? '进入聊天模式' : '进入 Bot 模式' }}</button>
         </div>
         </div>
       </div>
       </div>
-      <div v-if="toolsMenuOpen && applicationWorkspace" class="workspace-create-dismiss" @pointerdown.self="closeToolsMenu" @keydown.esc.prevent.stop="closeToolsMenu"><div ref="toolsMenu" class="workspace-create-menu workspace-tools-menu" :style="toolsPosition" role="menu" aria-label="工具" @keydown="actionMenuKeydown"><button type="button" role="menuitem" @click="chooseTool('automations')"><AppIcon name="clock" :size="17" />自动化</button><button type="button" role="menuitem" @click="chooseTool('apps')"><AppIcon name="link" :size="17" />已连接应用</button></div></div>
+      <div v-if="toolsMenuOpen && applicationWorkspace" class="workspace-create-dismiss" @pointerdown.self="closeToolsMenu" @keydown.esc.prevent.stop="closeToolsMenu"><div ref="toolsMenu" class="workspace-create-menu workspace-tools-menu" :style="toolsPosition" role="menu" aria-label="工具" @keydown="actionMenuKeydown"><button type="button" role="menuitem" @click="chooseTool('bot-settings')"><AppIcon name="settings" :size="17" />Bot 设置</button><button type="button" role="menuitem" @click="chooseTool('automations')"><AppIcon name="clock" :size="17" />自动化</button><button type="button" role="menuitem" @click="chooseTool('apps')"><AppIcon name="link" :size="17" />已连接应用</button></div></div>
       <div v-if="createMenuOpen" class="workspace-create-dismiss" @pointerdown.self="closeCreateMenu" @keydown.esc.prevent.stop="closeCreateMenu">
       <div v-if="createMenuOpen" class="workspace-create-dismiss" @pointerdown.self="closeCreateMenu" @keydown.esc.prevent.stop="closeCreateMenu">
         <div ref="createMenu" class="workspace-create-menu" :style="createPosition" role="menu" aria-label="新建聊天" @keydown="actionMenuKeydown">
         <div ref="createMenu" class="workspace-create-menu" :style="createPosition" role="menu" aria-label="新建聊天" @keydown="actionMenuKeydown">
           <button type="button" role="menuitem" @click="chooseCreate('agent')"><AppIcon name="users" :size="17" />新建 Bot</button>
           <button type="button" role="menuitem" @click="chooseCreate('agent')"><AppIcon name="users" :size="17" />新建 Bot</button>

+ 61 - 0
src/client/components/workspace/BotSettingsDialog.vue

@@ -0,0 +1,61 @@
+<script setup lang="ts">
+import {computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, watch} from 'vue'
+import {apiRequest} from '@/api/client'
+import AppIcon from '@/components/common/AppIcon.vue'
+import StandaloneDialog from '@/components/common/StandaloneDialog.vue'
+import WorkspaceKnowledgePanel from './WorkspaceKnowledgePanel.vue'
+import type {WorkspaceAgent, WorkspaceConversation} from '@shared/workspace'
+
+const LocalVmSettingsPanel = defineAsyncComponent(() => import('@/components/app/LocalVmSettingsPanel.vue'))
+type Page = 'projects' | 'user' | 'vm'
+const props = defineProps<{isAdmin?: boolean; initialPage?: Page}>()
+const emit = defineEmits<{close: []; changed: []}>()
+const page = ref<Page>(props.initialPage === 'vm' && !props.isAdmin ? 'projects' : props.initialPage ?? 'projects')
+const agents = ref<WorkspaceAgent[]>([]), conversations = ref<WorkspaceConversation[]>([])
+const loading = ref(true), error = ref(''), knowledgeEnabled = ref(false)
+let closed = false
+const pages = computed(() => [
+  {id:'projects' as const, label:'项目', icon:'files' as const},
+  {id:'user' as const, label:'用户记忆', icon:'brain' as const},
+  ...(props.isAdmin ? [{id:'vm' as const, label:'本地虚拟机', icon:'monitor' as const}] : []),
+])
+async function load() {
+  loading.value = true; error.value = ''
+  try {
+    const [capabilities, a, c] = await Promise.all([
+      apiRequest<{features:string[]}>('/api/app/capabilities'),
+      apiRequest<{agents:WorkspaceAgent[]}>('/api/app/agents'),
+      apiRequest<{conversations:WorkspaceConversation[]}>('/api/app/conversations'),
+    ])
+    if (closed) return
+    knowledgeEnabled.value = capabilities.features.includes('bot-file-memory-v1')
+    agents.value = a.agents; conversations.value = c.conversations
+  } catch (cause) { if (!closed) error.value = cause instanceof Error ? cause.message : '无法加载 Bot 设置' }
+  finally { if (!closed) loading.value = false }
+}
+function changed() { emit('changed'); void load() }
+watch(() => props.isAdmin, allowed => { if (!allowed && page.value === 'vm') page.value = 'projects' })
+onMounted(load)
+onBeforeUnmount(() => { closed = true })
+</script>
+
+<template>
+  <StandaloneDialog title="Bot 设置" @close="emit('close')">
+    <div class="bot-settings">
+      <nav aria-label="Bot 设置分类">
+        <button v-for="item in pages" :key="item.id" type="button" :aria-pressed="page === item.id" @click="page = item.id"><AppIcon :name="item.icon" :size="18" /><span>{{item.label}}</span></button>
+      </nav>
+      <LocalVmSettingsPanel v-if="page === 'vm' && isAdmin" />
+      <section v-else class="bot-settings__knowledge" :aria-label="page === 'projects' ? '项目设置' : '用户记忆设置'" :aria-busy="loading">
+        <p v-if="error" role="alert">{{error}} <button type="button" @click="load">重试</button></p>
+        <p v-else-if="loading && !knowledgeEnabled" role="status">正在加载…</p>
+        <WorkspaceKnowledgePanel v-else-if="knowledgeEnabled" embedded :initial-tab="page === 'user' ? 'user' : 'projects'" :agents="agents" :conversations="conversations" @changed="changed" />
+        <p v-else class="bot-settings__empty">当前服务尚不支持项目和用户记忆,请更新服务后重试。</p>
+      </section>
+    </div>
+  </StandaloneDialog>
+</template>
+
+<style scoped>
+.bot-settings{display:grid;gap:24px;min-width:0}.bot-settings nav{display:flex;gap:8px;flex-wrap:wrap;padding-bottom:16px;border-bottom:1px solid var(--line)}.bot-settings nav button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:8px 16px;border:1px solid transparent;border-radius:10px;background:transparent;color:var(--text-secondary);font:500 14px var(--font-ui);cursor:pointer}.bot-settings nav button:hover{background:var(--surface-hover);color:var(--text-primary)}.bot-settings nav button[aria-pressed=true]{background:var(--surface-soft);border-color:var(--line);color:var(--text-primary)}.bot-settings button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.bot-settings__knowledge{min-width:0}.bot-settings__knowledge>p{margin:0;font-size:14px;line-height:1.7;color:var(--text-secondary)}.bot-settings__knowledge>[role=alert]{color:var(--danger)}.bot-settings__knowledge>p button{min-height:44px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}.bot-settings__empty{padding:24px 0;text-align:center}@media(max-width:520px){.bot-settings{gap:20px}.bot-settings nav{gap:4px;flex-wrap:nowrap}.bot-settings nav button{flex:1;padding-inline:8px;gap:6px;white-space:nowrap;font-size:13px}}
+</style>

Fișier diff suprimat deoarece este prea mare
+ 11 - 8
src/client/components/workspace/WorkspaceKnowledgePanel.vue


+ 3 - 2
src/client/views/ConversationsView.vue

@@ -932,12 +932,13 @@ onBeforeUnmount(() => {
     @create-agent="openDialog('agent')"
     @create-agent="openDialog('agent')"
     @create-group="openDialog('group')"
     @create-group="openDialog('group')"
     @create-remote-agent="remotePickerOpen = true"
     @create-remote-agent="remotePickerOpen = true"
+    @bot-settings-changed="knowledgeChanged"
   >
   >
     <template #sidebar
     <template #sidebar
-      ><div v-if="knowledgeEnabled" class="knowledge-shortcuts"><button @click="knowledgePanel?.open('projects')">项目</button><button @click="knowledgePanel?.open('user')">用户记忆</button></div><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @settings="openConversationSettings" @pin="action('pin', $event)" @archive="action('archive', $event)" @delete="changeLifecycle('delete', $event)"
+      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @settings="openConversationSettings" @pin="action('pin', $event)" @archive="action('archive', $event)" @delete="changeLifecycle('delete', $event)"
     /></template>
     /></template>
     <template #mobile-sidebar
     <template #mobile-sidebar
-      ><div v-if="knowledgeEnabled" class="knowledge-shortcuts"><button @click="knowledgePanel?.open('projects')">项目</button><button @click="knowledgePanel?.open('user')">用户记忆</button></div><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @settings="openConversationSettings" @pin="action('pin', $event)" @archive="action('archive', $event)" @delete="changeLifecycle('delete', $event)"
+      ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @settings="openConversationSettings" @pin="action('pin', $event)" @archive="action('archive', $event)" @delete="changeLifecycle('delete', $event)"
     /></template>
     /></template>
     <div v-show="!twoDesktops" class="conversation-with-computer">
     <div v-show="!twoDesktops" class="conversation-with-computer">
     <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
     <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">

+ 65 - 0
tests/client/botSettingsDialog.test.ts

@@ -0,0 +1,65 @@
+import {mount, flushPromises} from '@vue/test-utils'
+import {afterEach, beforeEach, expect, it, vi} from 'vitest'
+import {defineComponent} from 'vue'
+import BotSettingsDialog from '@/components/workspace/BotSettingsDialog.vue'
+import {apiRequest} from '@/api/client'
+
+vi.mock('@/api/client', () => ({apiRequest:vi.fn()}))
+const request = vi.mocked(apiRequest)
+const project={id:'project-a',name:'项目示例',description:'项目说明',memberIds:['bot-a'],groupIds:[],revision:1,archived:false}
+const mountDialog=(isAdmin=false, initialPage='projects')=>mount(BotSettingsDialog,{
+  props:{isAdmin,initialPage} as never,
+  global:{stubs:{
+    StandaloneDialog:defineComponent({props:['title'],template:'<section role="dialog" :aria-label="title"><slot /></section>'}),
+    AppIcon:true,
+    LocalVmSettingsPanel:defineComponent({template:'<div data-testid="vm-settings">虚拟机管理</div>'}),
+  }},
+})
+beforeEach(()=>{
+  request.mockReset()
+  request.mockImplementation(async(path)=>{
+    if(path==='/api/app/capabilities')return {features:['bot-file-memory-v1']} as never
+    if(path==='/api/app/agents')return {agents:[{id:'bot-a',name:'测试 Bot'}]} as never
+    if(path==='/api/app/conversations')return {conversations:[]} as never
+    if(path==='/api/app/workspace/projects')return {projects:[project]} as never
+    if(String(path).includes('/memory-jobs'))return {jobs:[]} as never
+    if(String(path).includes('/memories'))return {memories:[]} as never
+    throw new Error(String(path))
+  })
+})
+afterEach(()=>vi.restoreAllMocks())
+
+it('reuses project and user-memory management inline without nesting dialogs',async()=>{
+  const wrapper=mountDialog()
+  await flushPromises()
+  expect(wrapper.get('[aria-label="Bot 设置分类"]').text()).toContain('项目')
+  expect(wrapper.text()).toContain('项目示例')
+  expect(wrapper.find('dialog').exists()).toBe(false)
+  await wrapper.findAll('button').find(b=>b.text()==='查看记忆')!.trigger('click')
+  await flushPromises()
+  expect(request).toHaveBeenCalledWith(expect.stringContaining('scope=project&projectId=project-a'))
+  await wrapper.findAll('button').find(b=>b.text()==='返回项目')!.trigger('click')
+  await flushPromises()
+  expect(wrapper.text()).toContain('新建项目')
+  await wrapper.get('nav').findAll('button').find(b=>b.text()==='用户记忆')!.trigger('click')
+  await flushPromises()
+  expect(request).toHaveBeenCalledWith(expect.stringContaining('scope=user'))
+  expect(wrapper.text()).toContain('同一账号的 Bot 共享')
+  wrapper.unmount()
+})
+
+it('keeps VM management admin-only, including a requested initial VM page',async()=>{
+  const wrapper=mountDialog(false,'vm')
+  await flushPromises()
+  expect(wrapper.get('nav').text()).not.toContain('本地虚拟机')
+  expect(wrapper.find('[data-testid="vm-settings"]').exists()).toBe(false)
+  await wrapper.setProps({isAdmin:true})
+  await wrapper.get('nav').findAll('button').find(b=>b.text()==='本地虚拟机')!.trigger('click')
+  await flushPromises()
+  expect(wrapper.find('[data-testid="vm-settings"]').exists()).toBe(true)
+  await wrapper.setProps({isAdmin:false})
+  await flushPromises()
+  expect(wrapper.find('[data-testid="vm-settings"]').exists()).toBe(false)
+  expect(wrapper.text()).toContain('项目示例')
+  wrapper.unmount()
+})

+ 1 - 1
tests/client/settingsCenterDialog.test.ts

@@ -84,7 +84,7 @@ describe('Settings center dialog', () => {
   it.each([false, true])('keeps independent tools and About outside settings (Bot mode=%s)', botMode => {
   it.each([false, true])('keeps independent tools and About outside settings (Bot mode=%s)', botMode => {
     const wrapper = mountSettings({ botMode, initialPage: 'bot-plugins' })
     const wrapper = mountSettings({ botMode, initialPage: 'bot-plugins' })
     const labels = wrapper.findAll('.settings-sidebar nav button').map(button => button.text())
     const labels = wrapper.findAll('.settings-sidebar nav button').map(button => button.text())
-    for (const label of ['插件', '已连接应用', '自动化', '关于']) expect(labels).not.toContain(label)
+    for (const label of ['插件', '已连接应用', '自动化', '关于', '本地虚拟机']) expect(labels).not.toContain(label)
     expect(wrapper.find('[data-testid="bot-plugins"]').exists()).toBe(false)
     expect(wrapper.find('[data-testid="bot-plugins"]').exists()).toBe(false)
     expect(wrapper.find('.bot-about').exists()).toBe(false)
     expect(wrapper.find('.bot-about').exists()).toBe(false)
     wrapper.unmount()
     wrapper.unmount()

+ 20 - 2
tests/client/workspaceShell.test.ts

@@ -50,6 +50,7 @@ async function mountShell(start = '/chat') {
       stubs: {
       stubs: {
         SettingsCenterDialog: SettingsCenterDialogStub,
         SettingsCenterDialog: SettingsCenterDialogStub,
         BotPluginsDialog: defineComponent({emits:['close'],template:'<div data-testid="plugins-dialog"><button @click="$emit(\'close\')">关闭已连接应用</button></div>'}),
         BotPluginsDialog: defineComponent({emits:['close'],template:'<div data-testid="plugins-dialog"><button @click="$emit(\'close\')">关闭已连接应用</button></div>'}),
+        BotSettingsDialog: defineComponent({props:['initialPage','isAdmin'],emits:['close'],template:'<div data-testid="bot-settings-dialog" :data-page="initialPage"><button @click="$emit(\'close\')">关闭 Bot 设置</button></div>'}),
         UpdateCheckDialog: defineComponent({emits:['close', 'manage'],template:'<div data-testid="update-dialog"><button @click="$emit(\'close\')">关闭检测更新</button><button @click="$emit(\'manage\')">前往更新</button></div>'}),
         UpdateCheckDialog: defineComponent({emits:['close', 'manage'],template:'<div data-testid="update-dialog"><button @click="$emit(\'close\')">关闭检测更新</button><button @click="$emit(\'manage\')">前往更新</button></div>'}),
         AboutDialog: defineComponent({emits:['close'],template:'<div data-testid="about-dialog"><button @click="$emit(\'close\')">关闭关于</button></div>'}),
         AboutDialog: defineComponent({emits:['close'],template:'<div data-testid="about-dialog"><button @click="$emit(\'close\')">关闭关于</button></div>'}),
         AgentAvatar: true,
         AgentAvatar: true,
@@ -197,11 +198,11 @@ it('shows Bot tools only in Bot mode and restores keyboard focus after closing',
   await trigger.trigger('click')
   await trigger.trigger('click')
   const menu = document.querySelector<HTMLElement>('.workspace-tools-menu')!
   const menu = document.querySelector<HTMLElement>('.workspace-tools-menu')!
   const items = [...menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')]
   const items = [...menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')]
-  expect(items.map(b => b.textContent?.trim())).toEqual(['自动化', '已连接应用'])
+  expect(items.map(b => b.textContent?.trim())).toEqual(['Bot 设置', '自动化', '已连接应用'])
   expect(document.activeElement).toBe(items[0])
   expect(document.activeElement).toBe(items[0])
   menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
   menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
   expect(document.activeElement).toBe(items[1])
   expect(document.activeElement).toBe(items[1])
-  items[1]!.click(); await wrapper.vm.$nextTick()
+  items[2]!.click(); await wrapper.vm.$nextTick()
   await flushPromises()
   await flushPromises()
   expect(wrapper.find('[data-testid="settings-center"]').exists()).toBe(false)
   expect(wrapper.find('[data-testid="settings-center"]').exists()).toBe(false)
   expect(wrapper.find('[data-testid="plugins-dialog"]').exists()).toBe(true)
   expect(wrapper.find('[data-testid="plugins-dialog"]').exists()).toBe(true)
@@ -210,6 +211,23 @@ it('shows Bot tools only in Bot mode and restores keyboard focus after closing',
   wrapper.unmount()
   wrapper.unmount()
 })
 })
 
 
+it('opens dedicated Bot settings from tools and routes the existing VM shortcut there', async () => {
+  const wrapper = await mountShell('/conversations')
+  const trigger = wrapper.get<HTMLButtonElement>('.desktop-sidebar .sidebar-tools-trigger')
+  await trigger.trigger('click')
+  ;[...document.querySelectorAll<HTMLButtonElement>('.workspace-tools-menu button')].find(b => b.textContent?.trim() === 'Bot 设置')!.click()
+  await flushPromises()
+  expect(wrapper.get('[data-testid="bot-settings-dialog"]').attributes('data-page')).toBe('projects')
+  expect(wrapper.find('[data-testid="settings-center"]').exists()).toBe(false)
+  await wrapper.get('[data-testid="bot-settings-dialog"] button').trigger('click')
+  expect(document.activeElement).toBe(trigger.element)
+  window.dispatchEvent(new Event('yaoyao:local-vm-settings'))
+  await flushPromises()
+  expect(wrapper.get('[data-testid="bot-settings-dialog"]').attributes('data-page')).toBe('vm')
+  expect(wrapper.find('[data-testid="settings-center"]').exists()).toBe(false)
+  wrapper.unmount()
+})
+
 
 
 it.each(['/chat', '/history', '/conversations', '/kanban', '/files'])('opens an independent About dialog from the menu in %s', async path => {
 it.each(['/chat', '/history', '/conversations', '/kanban', '/files'])('opens an independent About dialog from the menu in %s', async path => {
   const wrapper = await mountShell(path)
   const wrapper = await mountShell(path)

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff