Kaynağa Gözat

修正工具与关于的独立页面结构

Samien 1 ay önce
ebeveyn
işleme
2304deb895

+ 2 - 0
README.md

@@ -14,6 +14,8 @@
 - 原生对话保留独立入口,由 Web 转发给 Hermes。
 - 新聊天、事件及文件按登录用户隔离;基础 Profile 的工具、技能和既有记忆能力继续由 Hermes 管理。
 
+Bot 模式的“工具”菜单提供“自动化”和“已连接应用”两个独立入口。“已连接应用”统一管理应用连接与 MCP;“自动化”使用独立的日历、任务列表和运行日志页面。所有模式均可从底部菜单打开独立的“关于”对话框。这些页面不属于设置中心。
+
 ## 运行
 
 需要 Node.js 24 和可通过标准 API 访问的 Hermes 9119 服务。

+ 31 - 0
docs/verification/2026-09-11-independent-surfaces/README.md

@@ -0,0 +1,31 @@
+# 工具与关于的独立页面修正
+
+本次重新核对 OpenMausBot 的 `Sidebar.tsx`、`SidebarProfileMenu.tsx`、`App.tsx`、`PluginsPanel.tsx`、`AboutDialog.tsx` 和 `RoutineCalendarPage.tsx`,纠正上一版把三个独立功能放入设置中心的结构。
+
+| 功能 | 参考项目的入口与承载方式 | 当前实现 |
+| --- | --- | --- |
+| 插件 / 已连接应用 | 工具菜单单一入口,独立大面板 | 统一为“已连接应用”,内部包含应用目录、已连接账号和 MCP |
+| 自动化 | 切换主工作区,独立日程页 | `/conversations/automations`,包含 Bot 筛选、日历、任务列表、运行日志和返回聊天 |
+| 关于 | 账号菜单独立对话框,不依赖设置 | 所有常规工作区的底部菜单均可打开,展示版本与帮助链接 |
+
+设置中心中已移除插件、已连接应用、自动化和关于分类。普通聊天的 Profile 切换保留;关于和帮助在普通聊天、历史、Bot、看板和文件库的底部菜单中均可用。
+
+## 功能与验证
+
+- 自动化汇总接口只返回当前账号、当前有权使用的 Bot;运行日志读取实际 run 状态。日历明确展示每项任务的下次执行时间,不推测完整未来运行次数。
+- 独立自动化页复用现有任务编辑器和写接口,支持选 Bot 新建、编辑、立即执行、暂停及删除;保留聊天侧栏原有定时任务面板。
+- 应用连接和 MCP 的后台配置、授权、数据与工具执行保持原有逻辑。独立应用面板保留未保存更改确认;原生对话框提供焦点约束、Escape 关闭和焦点返回。
+- 全量 `tests/server`、`tests/client`:126 个文件、940 项通过。类型检查和生产构建通过。
+- CUA 浏览器验收:1280 px 桌面独立应用面板、自动化日历、任务编辑和运行日志;375 px 自动化页面和关于对话框。普通聊天与 Bot 模式都实际打开了独立关于,未经过设置中心。
+- 独立 fixture 内创建两个 Bot 和三个计划任务,通过新页面修改其中一项并立即执行成功,再从新建入口选择另一 Bot 创建第四项任务。没有调用真实模型或修改用户现有任务。
+
+浏览器与只读数据库核验摘要见 [browser-result.json](browser-result.json)。
+
+复现环境:
+
+```sh
+npm run build
+WORKSPACE_FIXTURE_PORT=18834 WORKSPACE_FIXTURE_UPSTREAM_PORT=19134 WORKSPACE_FIXTURE_PLUGINS=1 node --import tsx tests/fixtures/workspace-server.ts
+```
+
+本次是源码与界面结构修正;没有重写已发布的 v0.4.6 标签或附件,也未部署、重启实际使用中的服务。

+ 32 - 0
docs/verification/2026-09-11-independent-surfaces/browser-result.json

@@ -0,0 +1,32 @@
+{
+  "toolsMenu": [
+    "自动化",
+    "已连接应用"
+  ],
+  "pluginsSurface": "independent dialog with apps and MCP",
+  "automationRoute": "/conversations/automations",
+  "aboutSurface": "independent dialog",
+  "aboutBrowserModes": [
+    "Bot",
+    "普通聊天"
+  ],
+  "aboutMenuTestedRoutes": [
+    "/chat",
+    "/history",
+    "/conversations",
+    "/kanban",
+    "/files"
+  ],
+  "viewportWidths": [
+    1280,
+    375
+  ],
+  "settingsContainsIndependentPages": false,
+  "fixtureRoutines": 4,
+  "newRoutineCreated": true,
+  "editedRoutineSaved": true,
+  "routineRunStatuses": [
+    "complete"
+  ],
+  "pendingTurns": 0
+}

+ 1 - 1
src/client/App.vue

@@ -24,7 +24,7 @@ const agentIdentityFixture = import.meta.env.DEV
   && new URLSearchParams(window.location.search).get('fixture') === 'agent-identity'
 
 const pageTitle = computed(() => {
-  const name = route.name === 'computer' ? '电脑接管' : route.path.startsWith('/history')
+  const name = route.name === 'computer' ? '电脑接管' : route.name === 'bot-automations' ? '自动化' : route.path.startsWith('/history')
     ? '历史记录'
     : route.path.startsWith('/conversations')
     ? '聊天'

+ 16 - 0
src/client/components/app/AboutDialog.vue

@@ -0,0 +1,16 @@
+<script setup lang="ts">
+import { version } from '../../../../package.json'
+import BrandMark from '@/components/common/BrandMark.vue'
+import StandaloneDialog from '@/components/common/StandaloneDialog.vue'
+const emit = defineEmits<{ close: [] }>()
+</script>
+<template>
+  <StandaloneDialog title="关于夭夭 AI" compact @close="emit('close')"><section class="about-content">
+    <BrandMark :size="56" :label="false" compact bare /><h3>夭夭 AI</h3><p class="about-version">版本 {{ version }}</p>
+    <p>与 AI 聊天,让 Bot 协作完成任务。<br />基于 Apache-2.0 许可证开放源代码。</p>
+    <nav aria-label="关于链接"><a href="https://github.com/Lsamien/yaoyao" target="_blank" rel="noopener noreferrer">GitHub</a><a href="https://yaoyao.samien.cn" target="_blank" rel="noopener noreferrer">帮助中心</a><a href="https://github.com/Lsamien/yaoyao/releases" target="_blank" rel="noopener noreferrer">版本发布</a></nav>
+  </section></StandaloneDialog>
+</template>
+<style scoped>
+.about-content{display:grid;justify-items:center;gap:12px;text-align:center}.about-content h3{font-size:21px;margin:0}.about-content p{margin:0;color:var(--text-secondary);font-size:13px;line-height:1.8}.about-content .about-version{font-size:12px}.about-content nav{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin-top:8px}.about-content a{padding:10px 8px;color:var(--accent);font-size:13px;text-decoration:none}.about-content a:hover{text-decoration:underline}.about-content a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+</style>

+ 4 - 23
src/client/components/app/SettingsCenterDialog.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed, defineAsyncComponent, nextTick, reactive, ref, watch } from 'vue'
+import { computed, nextTick, reactive, ref, watch } from 'vue'
 import type { Profile } from '@shared/types'
 import AccountSecurityPanel from '@/components/app/AccountSecurityPanel.vue'
 import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
@@ -16,13 +16,8 @@ import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AccountInitialAvatar from '@/components/common/AccountInitialAvatar.vue'
 import type { ProfileIdentityInput } from '@/api/profiles'
 import AppIcon from '@/components/common/AppIcon.vue'
-import BrandMark from '@/components/common/BrandMark.vue'
-import { version } from '../../../../package.json'
-const BotPluginsPanel = defineAsyncComponent(() => import('@/components/workspace/BotPluginsPanel.vue'))
-const BotAutomationPanel = defineAsyncComponent(() => import('@/components/workspace/BotAutomationPanel.vue'))
 
 type SettingsPage =
-  | 'bot-plugins' | 'bot-apps' | 'bot-routines' | 'bot-about'
   | 'agent-identity'
   | 'agent-models'
   | 'account-security'
@@ -37,7 +32,7 @@ type SettingsPage =
   | 'system-local-vm'
   | 'system-update'
 
-type SettingsIcon = 'users' | 'model' | 'settings' | 'panel' | 'monitor' | 'sun' | 'link' | 'bell' | 'audio' | 'refresh' | 'tools' | 'info' | 'clock'
+type SettingsIcon = 'users' | 'model' | 'settings' | 'panel' | 'monitor' | 'sun' | 'link' | 'bell' | 'audio' | 'refresh'
 type ThemePreference = 'light' | 'dark' | 'system'
 
 interface NavigationItem {
@@ -110,12 +105,6 @@ const accountItems = computed<NavigationItem[]>(() => [
   ...(props.isAdmin ? [{ key: 'account-mobile', label: '手机登录', icon: 'panel' } satisfies NavigationItem] : []),
   { key: 'appearance', label: '外观', icon: 'sun' },
 ])
-const botItems = computed<NavigationItem[]>(() => props.botMode ? [
-  { key: 'bot-plugins', label: '插件', icon: 'tools' },
-  { key: 'bot-apps', label: '已连接应用', icon: 'link' },
-  { key: 'bot-routines', label: '自动化', icon: 'clock' },
-  { key: 'bot-about', label: '关于', icon: 'info' },
-] : [])
 const systemItems: NavigationItem[] = [
   { key: 'system-local-vm', label: '本地虚拟机', icon: 'monitor' },
   { key: 'system-overview', label: '系统概览', icon: 'panel' },
@@ -130,13 +119,11 @@ const systemItems: NavigationItem[] = [
 const allAllowedPages = computed(() => new Set<SettingsPage>([
   ...agentItems.value.map(item => item.key),
   ...accountItems.value.map(item => item.key),
-  ...botItems.value.map(item => item.key),
   ...(props.isAdmin ? systemItems.map(item => item.key) : []),
 ]))
 const activeDirty = computed(() => Boolean(dirtyPages[activePage.value]))
 const showFixedFooter = computed(() => activePage.value === 'agent-identity' || activePage.value === 'account-security')
 const activeTitle = computed(() => ({
-  'bot-plugins': '插件', 'bot-apps': '已连接应用', 'bot-routines': '自动化', 'bot-about': '关于夭夭 AI',
   'agent-identity': '身份与头像',
   'agent-models': '模型与 Provider',
   'account-security': '登录与安全',
@@ -154,7 +141,6 @@ const activeTitle = computed(() => ({
 const accountName = computed(() => props.pairingUserName || props.userName || '当前账号')
 const showAgentSelector = computed(() => activePage.value.startsWith('agent-'))
 const activeScope = computed(() => {
-  if (activePage.value.startsWith('bot-')) return 'Bot 模式 · 当前账号'
   if (activePage.value.startsWith('agent-')) return `正在设置:${profileTitle(props.activeProfile)} / ${props.activeProfile?.name || '未选择'}`
   if (activePage.value.startsWith('account-')) return `当前账号:${accountName.value}${props.isAdmin ? ' · 管理员' : ''}`
   if (activePage.value === 'appearance') return '仅影响当前浏览器'
@@ -264,7 +250,7 @@ watch(() => [props.open, props.initialPage] as const, ([open, initialPage]) => {
     : props.activeProfile ? 'agent-identity' : 'account-security'
   for (const key of Object.keys(dirtyPages) as SettingsPage[]) dirtyPages[key] = false
   profileMenuOpen.value = false
-  mobileDetailOpen.value = initialPage.startsWith('bot-')
+  mobileDetailOpen.value = false
   void nextTick(() => dialog.value?.focus())
 }, { immediate: true })
 function requestModeSwitch() {
@@ -315,7 +301,6 @@ function requestModeSwitch() {
 
                 <nav>
                   <section v-if="isAdmin"><button type="button" :disabled="updateLocked" @click="requestModeSwitch"><AppIcon :name="botMode ? 'chat' : 'users'" :size="18" /><span>{{ botMode ? '进入聊天模式' : '进入 Bot 模式' }}</span></button></section>
-                  <section v-if="botItems.length"><h3>Bot 模式</h3><button v-for="item in botItems" :key="item.key" type="button" :class="{ active: activePage === item.key }" :aria-current="activePage === item.key ? 'page' : undefined" @click="selectPage(item.key)"><AppIcon :name="item.icon" :size="20" /><span>{{ item.label }}</span></button></section>
                   <section>
                     <h3>当前机器人</h3>
                     <button v-for="item in agentItems" :key="item.key" type="button" :class="{ active: activePage === item.key }" :aria-current="activePage === item.key ? 'page' : undefined" @click="selectPage(item.key)"><AppIcon :name="item.icon" :size="20" /><span>{{ item.label }}</span></button>
@@ -339,11 +324,8 @@ function requestModeSwitch() {
                 <button class="settings-center__close" type="button" aria-label="关闭设置中心" :disabled="updateLocked" @click="requestClose"><AppIcon name="close" :size="18" /></button>
               </header>
               <div class="settings-content__scroll">
-                <BotPluginsPanel v-if="botMode && (activePage === 'bot-plugins' || activePage === 'bot-apps')" :key="activePage" :connected-only="activePage === 'bot-apps'" @dirty-change="setDirty(activePage, $event)" />
-                <BotAutomationPanel v-else-if="botMode && activePage === 'bot-routines'" />
-                <section v-else-if="botMode && activePage === 'bot-about'" class="bot-about" aria-label="关于夭夭 AI"><BrandMark :size="48" /><h4>夭夭 AI</h4><p>版本 {{ version }}</p><p>与 AI 聊天,让 Bot 协作完成任务。</p><a href="https://yaoyao.samien.cn" target="_blank" rel="noopener noreferrer">帮助中心 <AppIcon name="external" :size="15" /></a></section>
                 <AgentIdentityPanel
-                  v-else-if="activePage === 'agent-identity' && activeProfile"
+                  v-if="activePage === 'agent-identity' && activeProfile"
                   :key="activeProfile.name"
                   :profile="activeProfile"
                   :busy="identityBusy"
@@ -412,7 +394,6 @@ function requestModeSwitch() {
 </template>
 
 <style scoped>
-.bot-about{display:grid;justify-items:center;gap:14px;padding:36px 12px;text-align:center}.bot-about h4{font-size:22px;margin:0}.bot-about p{margin:0;color:var(--text-secondary);line-height:1.6}.bot-about a{display:flex;gap:6px;align-items:center;color:var(--accent);padding:10px}.bot-about a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
 .settings-center-layer { position: fixed; z-index: 300; inset: 0; display: grid; place-items: center; padding: 64px; background: color-mix(in srgb, #000 24%, transparent); backdrop-filter: blur(4px); }
 .settings-center { display: block; width: min(820px, calc(100vw - 128px)); height: min(600px, calc(100dvh - 128px)); overflow: hidden; border: 1px solid var(--line); border-radius: 16px; outline: 0; background: var(--surface-raised); box-shadow: 0 20px 60px rgba(0,0,0,.18); color: var(--text-primary); }
 .settings-center__close,.mobile-back { display: grid; width: 44px; height: 44px; place-items: center; padding: 0; border: 0; border-radius: 10px; background: transparent; color: var(--text-primary); cursor: pointer; }

+ 30 - 16
src/client/components/app/WorkspaceShell.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import type { Profile } from '@shared/types'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
@@ -10,6 +10,8 @@ import BrandMark from '@/components/common/BrandMark.vue'
 import SettingsCenterDialog from '@/components/app/SettingsCenterDialog.vue'
 import { rememberInterfacePath } from '@/utils/interfaceMode'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
+const AboutDialog = defineAsyncComponent(() => import('./AboutDialog.vue'))
+const BotPluginsDialog = defineAsyncComponent(() => import('@/components/workspace/BotPluginsDialog.vue'))
 
 type NavItem = {
   key: 'chat' | 'history' | 'groups' | 'kanban' | 'files'
@@ -19,7 +21,6 @@ type NavItem = {
 }
 
 type SettingsPage =
-  | 'bot-plugins' | 'bot-apps' | 'bot-routines' | 'bot-about'
   | 'agent-identity'
   | 'agent-models'
   | 'account-security'
@@ -101,6 +102,8 @@ const router = useRouter()
 const mobileDrawerOpen = ref(false)
 const profileMenuOpen = ref(false)
 const settingsOpen = ref(false)
+const standalone = ref<'plugins' | 'about' | null>(null)
+const standaloneReturnFocus = ref<HTMLElement>()
 const settingsPage = ref<SettingsPage>('agent-identity')
 const settingsReturnFocus = ref<HTMLButtonElement>()
 const mobileNavigationTrigger = ref<HTMLButtonElement>()
@@ -215,7 +218,7 @@ async function openSettingsMenu(event: MouseEvent) {
   if (settingsMenuOpen.value) { closeSettingsMenu(); return }
   settingsTrigger = event.currentTarget as HTMLButtonElement
   const rect = settingsTrigger.getBoundingClientRect()
-  const height = applicationWorkspace.value ? (props.isAdmin ? 4 : 3) * (window.innerWidth < 768 ? 44 : 36) + 18 : 92
+  const height = (props.isAdmin ? 4 : 3) * (window.innerWidth < 768 ? 44 : 36) + 18
   settingsMenuPosition.value = { left: `${Math.max(8, Math.min(rect.right - 180, window.innerWidth - 188))}px`, top: `${Math.max(8, rect.top - height)}px` }
   profileMenuOpen.value = false
   createMenuOpen.value = false
@@ -229,22 +232,31 @@ function chooseSettingsAction(action: 'settings' | 'bots' | 'about') {
   if (action === 'bots') { switchInterfaceMode(); return }
   settingsReturnFocus.value = settingsTrigger?.closest('.mobile-drawer')
     ? mobileNavigationTrigger.value : settingsTrigger ?? undefined
-  openSettings(action === 'about' && applicationWorkspace.value ? 'bot-about' : applicationWorkspace.value ? 'account-security' : 'agent-identity')
+  if (action === 'about') {
+    standaloneReturnFocus.value = settingsReturnFocus.value
+    mobileDrawerOpen.value = false
+    standalone.value = 'about'
+    return
+  }
+  openSettings(applicationWorkspace.value ? 'account-security' : 'agent-identity')
 }
+function closeStandalone() { standalone.value = null; void nextTick(() => standaloneReturnFocus.value?.isConnected && standaloneReturnFocus.value.focus()) }
 function closeToolsMenu() { toolsMenuOpen.value = false; void nextTick(() => toolsTrigger?.isConnected && toolsTrigger.focus()) }
 async function openToolsMenu(event: MouseEvent) {
   if (!applicationWorkspace.value) return
   if (toolsMenuOpen.value) { closeToolsMenu(); return }
   toolsTrigger = event.currentTarget as HTMLButtonElement
   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 ? 150 : 126))}px` }
+  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` }
   settingsMenuOpen.value = false; createMenuOpen.value = false; toolsMenuOpen.value = true
   await nextTick(); toolsMenu.value?.querySelector<HTMLElement>('[role="menuitem"]')?.focus()
 }
-function chooseTool(page: 'bot-plugins' | 'bot-apps' | 'bot-routines') {
+function chooseTool(page: 'apps' | 'automations') {
   toolsMenuOpen.value = false
-  settingsReturnFocus.value = toolsTrigger?.closest('.mobile-drawer') ? mobileNavigationTrigger.value : toolsTrigger ?? undefined
-  openSettings(page)
+  if (page === 'automations') { void navigate('/conversations/automations?from=' + encodeURIComponent(route.fullPath)); return }
+  standaloneReturnFocus.value = toolsTrigger?.closest('.mobile-drawer') ? mobileNavigationTrigger.value : toolsTrigger ?? undefined
+  mobileDrawerOpen.value = false
+  standalone.value = 'plugins'
 }
 function switchInterfaceMode() {
   if (!props.isAdmin) return
@@ -371,7 +383,7 @@ function handleSidebarFocusout(event: FocusEvent) {
 
 function handleSidebarSearchClosed() { sidebarSearchOpen.value = false }
 
-watch(() => route.fullPath, () => { mobileDrawerOpen.value = false; createMenuOpen.value = false; settingsMenuOpen.value = false; toolsMenuOpen.value = false })
+watch(() => route.fullPath, () => { mobileDrawerOpen.value = false; createMenuOpen.value = false; settingsMenuOpen.value = false; toolsMenuOpen.value = false; standalone.value = null })
 watch(() => activeNav.value.key, () => {
   sidebarSearchOpen.value = false
   profileMenuOpen.value = false
@@ -395,7 +407,7 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
 </script>
 
 <template>
-  <div class="workspace-shell" :class="{ 'workspace-shell--collapsed': sidebarCollapsed && !applicationWorkspace, 'workspace-shell--sidebar-focused': sidebarFocusMode, 'workspace-shell--conversations': applicationWorkspace, 'workspace-shell--conversation-open': applicationWorkspace && !!route.params.id }" :inert="settingsOpen || (applicationWorkspace && sidebarSearchOpen)">
+  <div class="workspace-shell" :class="{ 'workspace-shell--collapsed': sidebarCollapsed && !applicationWorkspace, 'workspace-shell--sidebar-focused': sidebarFocusMode, 'workspace-shell--conversations': applicationWorkspace, 'workspace-shell--conversation-open': applicationWorkspace && !!route.params.id }" :inert="settingsOpen || !!standalone || (applicationWorkspace && sidebarSearchOpen)">
     <header class="mobile-header" :inert="mobileDrawerOpen">
       <button ref="mobileNavigationTrigger" class="icon-button" type="button" aria-label="打开导航" @click="openMobileDrawer">
         <AppIcon name="menu" :size="20" />
@@ -651,6 +663,8 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
       </aside>
     </Transition>
 
+    <AboutDialog v-if="standalone === 'about'" @close="closeStandalone" />
+    <BotPluginsDialog v-if="standalone === 'plugins' && applicationWorkspace" @close="closeStandalone" />
     <SettingsCenterDialog
       :open="settingsOpen"
       :bot-mode="applicationWorkspace"
@@ -678,14 +692,14 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
     />
     <Teleport to="body">
       <div v-if="settingsMenuOpen" class="workspace-create-dismiss" @pointerdown.self="closeSettingsMenu" @keydown.esc.prevent.stop="closeSettingsMenu">
-        <div ref="settingsMenu" class="workspace-create-menu workspace-settings-menu" :class="{ 'workspace-settings-menu--bot': applicationWorkspace }" :style="settingsMenuPosition" role="menu" :aria-label="applicationWorkspace ? '账号菜单' : '设置与模式'" @keydown="actionMenuKeydown">
-          <button type="button" role="menuitem" @click="chooseSettingsAction('settings')"><AppIcon name="settings" :size="17" />{{ applicationWorkspace ? '设置' : '进入设置' }}</button>
-          <button v-if="applicationWorkspace" type="button" role="menuitem" @click="chooseSettingsAction('about')"><AppIcon name="info" :size="17" />关于</button>
-          <a v-if="applicationWorkspace" role="menuitem" href="https://yaoyao.samien.cn" target="_blank" rel="noopener noreferrer" @click="closeSettingsMenu"><AppIcon name="external" :size="17" />帮助</a>
+        <div ref="settingsMenu" class="workspace-create-menu workspace-settings-menu" :style="settingsMenuPosition" role="menu" aria-label="账号菜单" @keydown="actionMenuKeydown">
+          <button type="button" role="menuitem" @click="chooseSettingsAction('settings')"><AppIcon name="settings" :size="17" />设置</button>
+          <button type="button" role="menuitem" @click="chooseSettingsAction('about')"><AppIcon name="info" :size="17" />关于</button>
+          <a role="menuitem" href="https://yaoyao.samien.cn" target="_blank" rel="noopener noreferrer" @click="closeSettingsMenu"><AppIcon name="external" :size="17" />帮助</a>
           <button v-if="isAdmin" type="button" role="menuitem" @click="chooseSettingsAction('bots')"><AppIcon :name="applicationWorkspace ? 'chat' : 'users'" :size="17" />{{ applicationWorkspace ? '进入聊天模式' : '进入 Bot 模式' }}</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-plugins')"><AppIcon name="tools" :size="17" />插件</button><button type="button" role="menuitem" @click="chooseTool('bot-routines')"><AppIcon name="clock" :size="17" />自动化</button><button type="button" role="menuitem" @click="chooseTool('bot-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('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 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>
@@ -925,7 +939,7 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
 .workspace-create-menu{position:absolute;width:180px;padding:5px;border:1px solid var(--line);border-radius:11px;background:var(--surface-raised);box-shadow:var(--shadow-float)}
 .workspace-create-menu button{display:flex;width:100%;min-height:36px;align-items:center;gap:9px;padding:7px 10px;border:0;border-radius:7px;background:transparent;color:var(--text-primary);font:13px var(--font-ui);text-align:left;cursor:pointer}
 .workspace-create-menu button:hover,.workspace-create-menu button:focus-visible{outline:0;background:var(--surface-hover)}
-@media(max-width:767px){.workspace-tools-menu button,.workspace-settings-menu--bot button,.workspace-settings-menu--bot a{min-height:44px}}
+@media(max-width:767px){.workspace-tools-menu button,.workspace-settings-menu button,.workspace-settings-menu a{min-height:44px}}
 
 .bot-list-header{display:flex;align-items:center;gap:10px;padding:20px 20px 12px;min-height:74px}
 .bot-list-header__spacer{flex:1}

+ 24 - 0
src/client/components/common/StandaloneDialog.vue

@@ -0,0 +1,24 @@
+<script setup lang="ts">
+import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
+import AppIcon from './AppIcon.vue'
+const props = defineProps<{ title: string; compact?: boolean; beforeClose?: () => boolean }>()
+const emit = defineEmits<{ close: [] }>()
+const dialog = ref<HTMLDialogElement>()
+function close() { if (props.beforeClose?.() !== false) emit('close') }
+function backdrop(event: MouseEvent) {
+  if (event.target !== dialog.value) return
+  const rect = dialog.value.getBoundingClientRect()
+  if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) close()
+}
+onMounted(async () => { await nextTick(); dialog.value?.showModal() })
+onBeforeUnmount(() => dialog.value?.close())
+</script>
+<template>
+  <Teleport to="body"><dialog ref="dialog" class="standalone-dialog" :class="{ 'standalone-dialog--compact': compact }" :aria-label="title" @cancel.prevent="close" @click="backdrop">
+    <header><h2>{{ title }}</h2><button type="button" :aria-label="`关闭${title}`" autofocus @click="close"><AppIcon name="close" :size="20" /></button></header>
+    <div class="standalone-dialog__content"><slot /></div>
+  </dialog></Teleport>
+</template>
+<style scoped>
+.standalone-dialog{width:min(1040px,calc(100vw - 48px));height:min(780px,calc(100dvh - 48px));max-width:none;max-height:none;margin:auto;padding:0;border:1px solid var(--line);border-radius:22px;background:var(--surface);color:var(--text-primary);box-shadow:0 24px 100px #0005;overflow:hidden}.standalone-dialog[open]{display:flex;flex-direction:column}.standalone-dialog::backdrop{background:#0007;backdrop-filter:blur(3px)}header{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:20px 24px 16px;border-bottom:1px solid var(--line);flex-shrink:0}h2{font-size:21px;font-weight:650;margin:0;letter-spacing:-.02em}header button{display:grid;place-items:center;width:44px;height:44px;padding:0;flex-shrink:0;border:0;border-radius:10px;color:inherit;background:transparent;cursor:pointer}header button:hover{background:var(--surface-hover)}header button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.standalone-dialog__content{padding:24px;overflow:auto;min-height:0;flex:1}.standalone-dialog--compact{width:min(380px,calc(100vw - 32px));height:fit-content;max-height:calc(100dvh - 32px);border-radius:18px}.standalone-dialog--compact header{padding:10px 12px 10px 20px}.standalone-dialog--compact h2{font-size:15px}.standalone-dialog--compact .standalone-dialog__content{padding:20px 24px 28px}@media(max-width:600px){.standalone-dialog:not(.standalone-dialog--compact){width:100vw;height:100dvh;border:0;border-radius:0}header{padding:10px 12px 10px 20px}h2{font-size:18px}.standalone-dialog__content{padding:20px}}
+</style>

+ 0 - 11
src/client/components/workspace/BotAutomationPanel.vue

@@ -1,11 +0,0 @@
-<script setup lang="ts">
-import { computed, onMounted, ref } from 'vue'
-import { apiRequest } from '@/api/client'
-import type { WorkspaceAgent } from '@shared/workspace'
-import WorkspaceRoutinesPanel from './WorkspaceRoutinesPanel.vue'
-const agents = ref<WorkspaceAgent[]>([]), selected = ref(''), error = ref(''), loading = ref(true)
-const available = computed(() => agents.value.filter(a => !a.archived && !a.remoteAgentId && !a.temporaryGoalId))
-onMounted(async () => { try { agents.value = (await apiRequest<{ agents: WorkspaceAgent[] }>('/api/app/agents')).agents; selected.value = available.value[0]?.id ?? '' } catch (e) { error.value = e instanceof Error ? e.message : '无法读取 Bot' } finally { loading.value = false } })
-</script>
-<template><section class="bot-automations" aria-label="Bot 自动化"><p>选择 Bot,管理自动执行的任务和运行记录。</p><p v-if="error" role="alert">{{ error }}</p><p v-if="loading" role="status">正在读取…</p><template v-else-if="available.length"><label>执行 Bot<select v-model="selected"><option v-for="agent in available" :key="agent.id" :value="agent.id">{{ agent.name }}</option></select></label><WorkspaceRoutinesPanel v-if="selected" :key="selected" :agent-id="selected" /></template><p v-else>还没有可设置自动化的 Bot,请先创建机器人。</p></section></template>
-<style scoped>.bot-automations{display:grid;gap:16px;color:var(--text-primary);font-size:13px}.bot-automations>p{color:var(--text-secondary);line-height:1.6;margin:0}.bot-automations>label{display:grid;gap:8px}.bot-automations select{padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:inherit;font:inherit}.bot-automations select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.bot-automations>[role=alert]{color:var(--danger)}</style>

+ 9 - 0
src/client/components/workspace/BotPluginsDialog.vue

@@ -0,0 +1,9 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import StandaloneDialog from '@/components/common/StandaloneDialog.vue'
+import BotPluginsPanel from './BotPluginsPanel.vue'
+const emit = defineEmits<{ close: [] }>()
+const dirty = ref(false)
+const beforeClose = () => !dirty.value || window.confirm('放弃尚未保存的插件更改?')
+</script>
+<template><StandaloneDialog title="已连接应用" :before-close="beforeClose" @close="emit('close')"><BotPluginsPanel @dirty-change="dirty = $event" /></StandaloneDialog></template>

Dosya farkı çok büyük olduğundan ihmal edildi
+ 3 - 4
src/client/components/workspace/BotPluginsPanel.vue


+ 7 - 5
src/client/components/workspace/WorkspaceRoutinesPanel.vue

@@ -4,7 +4,8 @@ import AppIcon from '@/components/common/AppIcon.vue'
 import {apiRequest} from '@/api/client'
 import {createUuid} from '@/utils/id'
 import type {WorkspaceRoutine,WorkspaceRoutineRun,WorkspaceSchedule} from '@shared/workspacePanels'
-const props=defineProps<{agentId:string}>()
+const props=defineProps<{agentId:string;editorOnly?:boolean}>()
+const emit=defineEmits<{changed:[];executed:[id:string]}>()
 const routines=ref<WorkspaceRoutine[]>([]),runs=ref<WorkspaceRoutineRun[]>([]),editing=ref<string|null>(null),showEditor=ref(false),error=ref(''),busy=ref(false)
 const form=reactive({name:'',prompt:'',enabled:true,kind:'daily' as WorkspaceSchedule['kind'],time:'09:00',timezone:Intl.DateTimeFormat().resolvedOptions().timeZone,everyMinutes:60,at:'',weekdays:[1,2,3,4,5]})
 const status:Record<string,string>={queued:'排队中',running:'执行中',waiting:'等待处理',complete:'已完成',failed:'失败',interrupted:'已停止',uncertain:'状态待确认',skipped:'已跳过'}
@@ -18,20 +19,21 @@ const scheduleLabel=(r:WorkspaceRoutine)=>r.schedule.kind==='interval'?`每 ${r.
 const scheduleState=(r:WorkspaceRoutine)=>r.enabled?'已启用':r.schedule.kind==='once'&&r.lastAt?'已结束':'已暂停'
 function openLogs(id=''){logFilter.value=id;view.value='logs';query.value='';statusFilter.value='all'}
 watch(showEditor,async(value)=>{await nextTick();if(value)dialog.value?.showModal();else dialog.value?.close()})
-async function runNow(r:WorkspaceRoutine){await action(async()=>{await apiRequest(base()+'/'+r.id+'/run',{method:'POST',body:{requestId:createUuid()}});showEditor.value=false;openLogs(r.id)})}
+async function runNow(r:WorkspaceRoutine){await action(async()=>{await apiRequest(base()+'/'+r.id+'/run',{method:'POST',body:{requestId:createUuid()}});showEditor.value=false;openLogs(r.id);emit('executed',r.id)})}
 let timer:ReturnType<typeof setTimeout>|undefined,closed=false,generation=0
 const base=()=>`/api/app/agents/${props.agentId}/routines`
 async function load(){const version=generation;try{const value=await apiRequest<{routines:WorkspaceRoutine[];runs:WorkspaceRoutineRun[]}>(base());if(version===generation&&!closed){routines.value=value.routines;runs.value=value.runs;error.value=''}}catch(e){if(version===generation&&!closed)error.value=e instanceof Error?e.message:'无法读取定时任务'}}
 async function cycle(){const version=generation;if(!document.hidden&&!busy.value)await load();if(!closed&&version===generation)timer=setTimeout(cycle,5000)}
 function edit(r?:WorkspaceRoutine){editing.value=r?.id??null;Object.assign(form,{name:r?.name??'',prompt:r?.prompt??'',enabled:r?.enabled??true,kind:r?.schedule.kind??'daily',time:r?.schedule.time??'09:00',timezone:r?.schedule.timezone??Intl.DateTimeFormat().resolvedOptions().timeZone,everyMinutes:r?.schedule.everyMinutes??60,at:r?.schedule.at?new Date(r.schedule.at-new Date(r.schedule.at).getTimezoneOffset()*60000).toISOString().slice(0,16):'',weekdays:r?.schedule.weekdays??[1,2,3,4,5]});showEditor.value=true}
-async function action(work:()=>Promise<unknown>){if(busy.value)return;busy.value=true;error.value='';try{await work();await load()}catch(e){error.value=e instanceof Error?e.message:'定时任务操作失败'}finally{busy.value=false}}
+async function action(work:()=>Promise<unknown>){if(busy.value)return;busy.value=true;error.value='';try{await work();await load();emit('changed')}catch(e){error.value=e instanceof Error?e.message:'定时任务操作失败'}finally{busy.value=false}}
 function payload(r:WorkspaceRoutine){return {name:r.name,prompt:r.prompt,enabled:!r.enabled,schedule:r.schedule}}
 async function save(){const original=selectedRoutine.value?.schedule.at,originalDisplay=original?new Date(original-new Date(original).getTimezoneOffset()*60000).toISOString().slice(0,16):undefined;const schedule:WorkspaceSchedule={kind:form.kind,timezone:form.timezone,...(form.kind==='once'?{at:originalDisplay===form.at?original:new Date(form.at).getTime()}:form.kind==='interval'?{everyMinutes:form.everyMinutes}:{time:form.time,...(form.kind==='weekly'?{weekdays:form.weekdays}:{})})};await action(async()=>{await apiRequest(base()+(editing.value?'/'+editing.value:''),{method:editing.value?'PUT':'POST',body:{name:form.name,prompt:form.prompt,enabled:form.enabled,schedule} as any});showEditor.value=false})}
-watch(()=>props.agentId,()=>{generation++;routines.value=[];runs.value=[];showEditor.value=false;view.value='list';logFilter.value='';clearTimeout(timer);void cycle()},{immediate:true})
+watch(()=>props.agentId,()=>{generation++;routines.value=[];runs.value=[];showEditor.value=false;view.value='list';logFilter.value='';clearTimeout(timer);if(!props.editorOnly)void cycle()},{immediate:true})
+defineExpose({edit:(r?:WorkspaceRoutine)=>{if(props.editorOnly)routines.value=r?[r]:[];edit(r)}})
 onBeforeUnmount(()=>{closed=true;generation++;clearTimeout(timer)})
 </script>
 <template>
- <section class="routines" aria-label="机器人定时任务">
+ <section v-show="!editorOnly" class="routines" aria-label="机器人定时任务">
   <template v-if="view==='list'">
    <div class="routine-overview"><div class="overview-title"><AppIcon name="calendar" :size="16"/><h2>定时任务</h2><span>{{routines.length}}</span></div><div class="overview-actions"><button class="primary" @click="edit()"><AppIcon name="plus" :size="14"/>新建定时任务</button><button @click="openLogs()"><AppIcon name="file" :size="13"/>运行日志</button></div></div>
    <p v-if="error" class="error-box" role="alert">{{error}}</p>

+ 1 - 0
src/client/router/index.ts

@@ -8,6 +8,7 @@ const router = createRouter({
     { path: '/chat/:sessionId?', name: 'chat', component: () => import('@/views/ChatView.vue') },
     { path: '/history/:sessionId?', name: 'history', component: () => import('@/views/HistoryView.vue') },
     { path: '/conversations/computer/:agentId', name: 'computer', component: () => import('@/views/ComputerViewerView.vue') },
+    { path: '/conversations/automations', name: 'bot-automations', component: () => import('@/views/BotAutomationsView.vue') },
     { path: '/conversations/:id?', name: 'conversations', component: () => import('@/views/ConversationsView.vue') },
     { path: '/kanban/:boardSlug?', name: 'kanban', component: () => import('@/views/KanbanView.vue') },
     { path: '/files', name: 'files', component: () => import('@/views/FilesView.vue') },

Dosya farkı çok büyük olduğundan ihmal edildi
+ 111 - 0
src/client/views/BotAutomationsView.vue


+ 1 - 1
src/server/botPlugins/connectedApps.ts

@@ -74,7 +74,7 @@ export class ConnectedApps {
     this.catalogCache.delete(owner); this.invalidate(owner)
     return this.status(owner)
   }
-  private require(owner: string) { const value = this.read(owner); if (!value?.key) throw new HttpError(409, '请先在插件设置中配置 Composio API Key', 'connected_apps_unconfigured'); return value }
+  private require(owner: string) { const value = this.read(owner); if (!value?.key) throw new HttpError(409, '请先在已连接应用中配置 Composio API Key', 'connected_apps_unconfigured'); return value }
   async catalog(owner: string): Promise<{ cards: BotAppCard[]; source: 'api' | 'curated' }> {
     const value = this.read(owner), cache = this.catalogCache.get(owner)
     if (cache && Date.now() - cache.at < 600_000) return { cards: cache.cards, source: 'api' }

+ 1 - 1
src/server/botPlugins/workspacePlugins.ts

@@ -167,7 +167,7 @@ export class WorkspacePlugins {
           catch (error) { if (error instanceof HttpError) throw error; throw new HttpError(502, '插件工具调用失败或连接已关闭', 'plugin_call_failed') }
         }, dispose,
       }
-    } catch (error) { await dispose(); if (error instanceof HttpError) throw error; throw new HttpError(502, '插件初始化失败,请在设置中测试连接', 'plugin_initialization_failed') }
+    } catch (error) { await dispose(); if (error instanceof HttpError) throw error; throw new HttpError(502, '插件初始化失败,请在已连接应用中测试连接', 'plugin_initialization_failed') }
   }
   router() {
     const router = new Router({ prefix: '/api/app/bot-tools' })

+ 7 - 0
src/server/workspaceRoutines.ts

@@ -76,6 +76,13 @@ export class WorkspaceRoutines {
   start(){if(this.timer)return;this.tick();this.timer=setInterval(()=>this.tick(),10000);this.timer.unref()}
   close(){this.closed=true;clearInterval(this.timer)}
   router(){const router=new Router()
+    router.get('/api/app/bot-tools/automations',ctx=>{
+      const owner=this.auth.require(ctx).id
+      const agents=this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>{try{this.agent(owner,a.id);return true}catch(error){if(error instanceof HttpError&&[403,404,409].includes(error.status))return false;throw error}})
+      const allowed=new Set(agents.map(a=>a.id))
+      const runs=this.store.list<WorkspaceRoutineRun>(owner,'routine-run').filter(r=>allowed.has(r.agentId)).sort((a,b)=>b.startedAt-a.startedAt).slice(0,300).map(r=>{const run=r.runId?this.store.get<WorkspaceRun>(owner,'run',r.runId):undefined;return run?{...r,status:run.status,error:run.error}:r})
+      ctx.body={agents:agents.map(({id,name,avatar})=>({id,name,avatar})),routines:this.store.list<WorkspaceRoutine>(owner,'routine').filter(r=>allowed.has(r.agentId)),runs}
+    })
     router.get('/api/app/agents/:id/routines',ctx=>{const owner=this.auth.require(ctx).id;this.store.require(owner,'agent',ctx.params.id);ctx.body={routines:this.store.list<WorkspaceRoutine>(owner,'routine').filter(r=>r.agentId===ctx.params.id),runs:this.runs(owner,ctx.params.id)}})
     router.post('/api/app/agents/:id/routines',ctx=>{ctx.body={routine:this.save(this.auth.require(ctx).id,ctx.params.id,(ctx.request as any).body)};ctx.status=201})
     router.put('/api/app/agents/:id/routines/:routineId',ctx=>{const owner=this.auth.require(ctx).id;this.store.require(owner,'routine',ctx.params.routineId);ctx.body={routine:this.save(owner,ctx.params.id,(ctx.request as any).body,ctx.params.routineId)}})

+ 2 - 2
tests/client/botPluginsPanel.test.ts

@@ -21,11 +21,11 @@ beforeEach(() => {
   })
 })
 afterEach(() => { document.body.innerHTML = ''; vi.restoreAllMocks() })
-const render = (connectedOnly = false) => mount(BotPluginsPanel, { attachTo: document.body, props: { connectedOnly }, global: { stubs: { AppIcon: true } } })
+const render = () => mount(BotPluginsPanel, { attachTo: document.body, global: { stubs: { AppIcon: true } } })
 function button(wrapper: ReturnType<typeof render>, text: string) { return wrapper.findAll('button').find(b => b.text() === text)! }
 
 it('retains connected accounts on failed refresh instead of presenting them as disconnected', async () => {
-  const wrapper = render(true); await flushPromises()
+  const wrapper = render(); await flushPromises()
   expect(wrapper.text()).toContain('工作账号')
   failInventory = true
   await wrapper.get('[aria-label="刷新应用连接"]').trigger('click'); await flushPromises()

+ 78 - 0
tests/client/independentBotSurfaces.test.ts

@@ -0,0 +1,78 @@
+import { mount, flushPromises } from '@vue/test-utils'
+import { defineComponent } from 'vue'
+import { createMemoryHistory, createRouter } from 'vue-router'
+import { beforeEach, afterEach, expect, it, vi } from 'vitest'
+import BotAutomationsView from '@/views/BotAutomationsView.vue'
+import AboutDialog from '@/components/app/AboutDialog.vue'
+import BotPluginsDialog from '@/components/workspace/BotPluginsDialog.vue'
+import { apiRequest } from '@/api/client'
+import appRouter from '@/router'
+
+vi.mock('@/api/client', () => ({ apiRequest: vi.fn() }))
+const bots = [{ id: '11111111-1111-4111-8111-111111111111', name: '工作 Bot', avatar: '' }, { id: '22222222-2222-4222-8222-222222222222', name: '研究 Bot', avatar: '' }]
+const edit = vi.fn()
+const editorStub = defineComponent({ name: 'WorkspaceRoutinesPanel', props: ['agentId', 'editorOnly'], emits: ['changed', 'executed'], setup(_props, { expose }) { expose({ edit }); return {} }, template: '<div data-testid="routine-editor" :data-agent="agentId" />' })
+const dialogStub = defineComponent({ name: 'StandaloneDialog', props: ['title', 'compact', 'beforeClose'], emits: ['close'], template: '<div role="dialog" :aria-label="title"><slot /></div>' })
+const routine = () => ({ id: 'task-1', agentId: bots[0]!.id, name: '日常核对', prompt: '核对项目', enabled: true, schedule: { kind: 'daily', timezone: 'Asia/Shanghai', time: '09:00' }, nextAt: Date.now() + 3600000, createdAt: 1, updatedAt: 1 })
+beforeEach(() => {
+  vi.useFakeTimers(); vi.setSystemTime(new Date('2026-09-11T00:00:00Z')); edit.mockReset()
+  vi.mocked(apiRequest).mockReset().mockResolvedValue({ agents: bots, routines: [routine()], runs: [{ id: 'run-1', routineId: 'task-1', agentId: bots[0]!.id, status: 'complete', startedAt: Date.now(), scheduledAt: 0, conversationId: '33333333-3333-4333-8333-333333333333' }] })
+})
+afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); document.body.innerHTML = '' })
+async function automation() {
+  const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/conversations/automations', component: { template: '<div />' } }, { path: '/conversations/:id?', component: { template: '<div />' } }] })
+  await router.push('/conversations/automations?from=%2Fconversations'); await router.isReady()
+  const wrapper = mount(BotAutomationsView, { global: { plugins: [router], stubs: { AppIcon: true, AgentAvatar: true, WorkspaceRoutinesPanel: editorStub } } })
+  await flushPromises()
+  return { wrapper, router }
+}
+
+it('resolves the independent automation URL before the conversation ID route', () => {
+  const route = appRouter.resolve('/conversations/automations')
+  expect(route.name).toBe('bot-automations'); expect(route.params.id).toBeUndefined()
+})
+it('renders automation as its own workspace with Bot filters, calendar, task list and logs', async () => {
+  const { wrapper, router } = await automation()
+  expect(wrapper.get('h1').text()).toBe('自动化')
+  expect(wrapper.find('.settings-sidebar').exists()).toBe(false)
+  expect(wrapper.findAll('.schedule-day')).toHaveLength(7)
+  expect(wrapper.get('.schedule-event').text()).toContain('日常核对')
+  const tab = (name: string) => wrapper.findAll('[role="tab"]').find(b => b.text() === name)!
+  await tab('任务列表').trigger('click')
+  expect(wrapper.findAll('.automation-card')).toHaveLength(1)
+  await wrapper.get('.routine-description').trigger('click'); await flushPromises()
+  expect(edit).toHaveBeenCalledWith(expect.objectContaining({ id: 'task-1', agentId: bots[0]!.id }))
+  await tab('运行日志').trigger('click')
+  expect(wrapper.get('.run-card').text()).toContain('已完成')
+  expect(wrapper.get('.run-card a').attributes('href')).toBe('/conversations/33333333-3333-4333-8333-333333333333')
+  await wrapper.get('[aria-label="返回聊天"]').trigger('click'); await flushPromises()
+  expect(router.currentRoute.value.path).toBe('/conversations')
+  wrapper.unmount()
+})
+it('chooses an explicit Bot when creating from the all-Bots automation view', async () => {
+  const { wrapper } = await automation()
+  await wrapper.get('.new-automation > button').trigger('click')
+  await wrapper.findAll('.new-menu button')[1]!.trigger('click'); await flushPromises()
+  expect(wrapper.get('[data-testid="routine-editor"]').attributes('data-agent')).toBe(bots[1]!.id)
+  expect(edit).toHaveBeenCalledWith(undefined)
+  wrapper.unmount()
+})
+it('shows product identity and help in an independent About dialog without settings navigation', () => {
+  const wrapper = mount(AboutDialog, { global: { stubs: { StandaloneDialog: dialogStub, BrandMark: true } } })
+  expect(wrapper.get('[role="dialog"]').attributes('aria-label')).toBe('关于夭夭 AI')
+  expect(wrapper.text()).toContain('版本')
+  expect(wrapper.find('.settings-sidebar').exists()).toBe(false)
+  expect(wrapper.findAll('a').some(a => a.attributes('href') === 'https://yaoyao.samien.cn')).toBe(true)
+  wrapper.unmount()
+})
+it('uses one connected-app dialog for apps and MCP, guarding unsaved edits on close', async () => {
+  const panel = defineComponent({ name: 'BotPluginsPanel', emits: ['dirty-change'], template: '<button @click="$emit(\'dirty-change\', true)">未保存更改</button>' })
+  const wrapper = mount(BotPluginsDialog, { global: { stubs: { StandaloneDialog: dialogStub, BotPluginsPanel: panel } } })
+  expect(wrapper.get('[role="dialog"]').attributes('aria-label')).toBe('已连接应用')
+  const canClose = wrapper.getComponent(dialogStub).props('beforeClose')
+  expect(canClose()).toBe(true)
+  await wrapper.get('button').trigger('click')
+  vi.spyOn(window, 'confirm').mockReturnValue(false)
+  expect(canClose()).toBe(false)
+  wrapper.unmount()
+})

+ 7 - 15
tests/client/settingsCenterDialog.test.ts

@@ -41,8 +41,6 @@ const childStubs = {
   Teleport: true,
   AgentAvatar: true,
   AppIcon: true,
-  BotPluginsPanel: simpleStub('BotPluginsPanel', 'bot-plugins'),
-  BotAutomationPanel: simpleStub('BotAutomationPanel', 'bot-automations'),
   AgentIdentityPanel: simpleStub('AgentIdentityPanel', 'agent-identity'),
   ModelServicesPanel: ModelServicesPanelStub,
   AccountSecurityPanel: simpleStub('AccountSecurityPanel', 'account-security'),
@@ -83,19 +81,13 @@ afterEach(() => {
 })
 
 describe('Settings center dialog', () => {
-  it('keeps plugin, connected-app and automation settings scoped to Bot mode', async () => {
-    const normal = mountSettings({ initialPage: 'bot-plugins' })
-    expect(normal.find('[data-testid="bot-plugins"]').exists()).toBe(false)
-    expect(normal.findAll('.settings-sidebar nav button').some(b => b.text() === '插件')).toBe(false)
-    normal.unmount()
-    const bot = mountSettings({ botMode: true, initialPage: 'bot-plugins' })
-    await vi.waitFor(() => expect(bot.find('[data-testid="bot-plugins"]').exists()).toBe(true))
-    await navigationButton(bot, '自动化').trigger('click')
-    await vi.waitFor(() => expect(bot.find('[data-testid="bot-automations"]').exists()).toBe(true))
-    await navigationButton(bot, '关于').trigger('click')
-    expect(bot.get('.bot-about').text()).toContain('夭夭 AI')
-    expect(bot.get('.bot-about a').attributes('href')).toBe('https://yaoyao.samien.cn')
-    bot.unmount()
+  it.each([false, true])('keeps independent tools and About outside settings (Bot mode=%s)', botMode => {
+    const wrapper = mountSettings({ botMode, initialPage: 'bot-plugins' })
+    const labels = wrapper.findAll('.settings-sidebar nav button').map(button => button.text())
+    for (const label of ['插件', '已连接应用', '自动化', '关于']) expect(labels).not.toContain(label)
+    expect(wrapper.find('[data-testid="bot-plugins"]').exists()).toBe(false)
+    expect(wrapper.find('.bot-about').exists()).toBe(false)
+    wrapper.unmount()
   })
   it('groups administrator pages and routes Agent, voice, and system content to the right scope', async () => {
     const wrapper = mountSettings()

+ 35 - 6
tests/client/workspaceShell.test.ts

@@ -1,4 +1,4 @@
-import { mount } from '@vue/test-utils'
+import { mount, flushPromises } from '@vue/test-utils'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { createMemoryHistory, createRouter } from 'vue-router'
 import { defineComponent } from 'vue'
@@ -26,6 +26,7 @@ async function mountShell(start = '/chat') {
     routes: [
       { path: '/chat', component: { template: '<div></div>' } },
       { path: '/history', component: { template: '<div></div>' } },
+      { path: '/conversations/automations', component: { template: '<div></div>' } },
       { path: '/conversations', component: { template: '<div></div>' } },
       { path: '/groups', component: { template: '<div></div>' } },
       { path: '/kanban', component: { template: '<div></div>' } },
@@ -48,6 +49,8 @@ async function mountShell(start = '/chat') {
       plugins: [router],
       stubs: {
         SettingsCenterDialog: SettingsCenterDialogStub,
+        BotPluginsDialog: defineComponent({emits:['close'],template:'<div data-testid="plugins-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,
         AppIcon: true,
         BrandMark: true,
@@ -114,7 +117,7 @@ describe('Workspace shell account controls', () => {
     expect(settingsTrigger.attributes('aria-label')).toBe('设置与模式')
     await settingsTrigger.trigger('click')
     const actions = document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu [role="menuitem"]')
-    expect([...actions].map(button => button.textContent?.trim())).toEqual(['进入设置', '进入 Bot 模式'])
+    expect([...actions].map(button => button.textContent?.trim())).toEqual(['设置', '关于', '帮助', '进入 Bot 模式'])
     actions[0]!.click()
     await wrapper.vm.$nextTick()
     const settings = wrapper.get('[data-testid="settings-center"]')
@@ -139,7 +142,7 @@ describe('Workspace shell account controls', () => {
 
     expect(desktop.findAll('.sidebar-feature-nav button').map(button => button.text())).not.toContain('聊天')
     await settingsTrigger.trigger('click')
-    document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu [role="menuitem"]')[1]!.click()
+    document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu [role="menuitem"]')[3]!.click()
     await vi.waitFor(() => expect(wrapper.classes()).toContain('workspace-shell--conversations'))
     expect(document.querySelector('.workspace-settings-menu')).toBeNull()
     wrapper.unmount()
@@ -191,13 +194,39 @@ it('shows Bot tools only in Bot mode and restores keyboard focus after closing',
   await trigger.trigger('click')
   const menu = document.querySelector<HTMLElement>('.workspace-tools-menu')!
   const items = [...menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')]
-  expect(items.map(b => b.textContent?.trim())).toEqual(['插件', '自动化', '已连接应用'])
+  expect(items.map(b => b.textContent?.trim())).toEqual(['自动化', '已连接应用'])
   expect(document.activeElement).toBe(items[0])
   menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
   expect(document.activeElement).toBe(items[1])
   items[1]!.click(); await wrapper.vm.$nextTick()
-  expect(wrapper.get('[data-testid="settings-center"]').attributes('data-page')).toBe('bot-routines')
-  await wrapper.get('[data-testid="close-settings"]').trigger('click'); await wrapper.vm.$nextTick()
+  await flushPromises()
+  expect(wrapper.find('[data-testid="settings-center"]').exists()).toBe(false)
+  expect(wrapper.find('[data-testid="plugins-dialog"]').exists()).toBe(true)
+  await wrapper.get('[data-testid="plugins-dialog"] button').trigger('click'); await wrapper.vm.$nextTick()
   expect(document.activeElement).toBe(trigger.element)
   wrapper.unmount()
 })
+
+
+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 trigger = wrapper.get<HTMLButtonElement>('.desktop-sidebar .sidebar-settings-trigger')
+  await trigger.trigger('click')
+  const item = [...document.querySelectorAll<HTMLButtonElement>('.workspace-settings-menu button')].find(b => b.textContent?.trim() === '关于')!
+  item.click(); await flushPromises()
+  expect(wrapper.find('[data-testid="about-dialog"]').exists()).toBe(true)
+  expect(wrapper.find('[data-testid="settings-center"]').exists()).toBe(false)
+  await wrapper.get('[data-testid="about-dialog"] button').trigger('click'); await flushPromises()
+  expect(document.activeElement).toBe(trigger.element)
+  wrapper.unmount()
+})
+
+it('navigates to the independent automation route without opening settings', async () => {
+  const wrapper = await mountShell('/conversations')
+  await wrapper.get('.desktop-sidebar .sidebar-tools-trigger').trigger('click')
+  const item = [...document.querySelectorAll<HTMLButtonElement>('.workspace-tools-menu button')].find(b => b.textContent?.trim() === '自动化')!
+  item.click(); await flushPromises()
+  expect(wrapper.vm.$router.currentRoute.value.path).toBe('/conversations/automations')
+  expect(wrapper.find('[data-testid="settings-center"]').exists()).toBe(false)
+  wrapper.unmount()
+})

+ 20 - 0
tests/server/workspacePanels.test.ts

@@ -37,6 +37,26 @@ it('calculates daily and weekly schedules in the requested timezone, including D
  expect(nextRoutineAt({kind:'daily',timezone:'America/New_York',time:'09:00'},Date.parse('2026-03-07T15:00:00Z'))).toBe(Date.parse('2026-03-08T13:00:00Z'))
 })
 
+it('returns an account-scoped automation overview with only currently permitted Bots and current run states',async()=>{
+ const own=store.createAgent('owner',{name:'自己的 Bot',profile:'default'}),blocked=store.createAgent('owner',{name:'未授权 Bot',profile:'blocked'}),foreign=store.createAgent('other',{name:'其他账号 Bot',profile:'default'})
+ const service=new WorkspaceRoutines(store,auth,nodes,{} as any)
+ const body={name:'日常核对',prompt:'核对内容',enabled:true,schedule:{kind:'interval',timezone:'UTC',everyMinutes:60}}
+ const routine=service.save('owner',own.id,body)
+ service.save('owner',blocked.id,body);service.save('other',foreign.id,body)
+ const id=randomUUID();store.put('owner','run',id,{id,status:'complete'})
+ store.put('owner','routine-run','receipt',{id:'receipt',agentId:own.id,routineId:routine.id,runId:id,status:'queued',startedAt:1,scheduledAt:0})
+ nodes.sourceAllowed=(_owner,_node,profile)=>profile!=='blocked'
+ const app=new Koa();app.use(service.router().routes())
+ const result=await request(app.callback()).get('/api/app/bot-tools/automations')
+ expect(result.status).toBe(200)
+ expect(result.body.agents.map((a:any)=>a.id)).toEqual([own.id])
+ expect(result.body.routines.map((r:any)=>r.id)).toEqual([routine.id])
+ expect(result.body.runs).toMatchObject([{id:'receipt',status:'complete'}])
+ const other=await request(app.callback()).get('/api/app/bot-tools/automations').set('x-user','other')
+ expect(other.body.agents.map((a:any)=>a.id)).toEqual([foreign.id])
+ expect(other.body.runs).toEqual([])
+})
+
 it('keeps the original next occurrence when only a routine title or prompt changes',()=>{
  vi.useFakeTimers();vi.setSystemTime(new Date('2026-09-10T00:00:00Z'))
  const agent=store.createAgent('owner',{name:'排期验收',profile:'default'}),service=new WorkspaceRoutines(store,auth,nodes,{} as any)

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor