瀏覽代碼

feat: 添加智能体等模块

jiaxing.liao 4 周之前
父節點
當前提交
46456e6ab9
共有 36 個文件被更改,包括 2301 次插入953 次删除
  1. 67 0
      CLAUDE.md
  2. 119 0
      apps/web/components.d.ts
  3. 2 1
      apps/web/package.json
  4. 1 0
      apps/web/src/assets/icons/mcp.svg
  5. 1 0
      apps/web/src/assets/icons/ollama.svg
  6. 1 0
      apps/web/src/assets/icons/prompts.svg
  7. 1 0
      apps/web/src/assets/icons/skills.svg
  8. 1 0
      apps/web/src/assets/icons/storage.svg
  9. 1 0
      apps/web/src/assets/icons/websearch.svg
  10. 76 10
      apps/web/src/components/Sidebar/index.vue
  11. 42 0
      apps/web/src/router/index.ts
  12. 1 0
      apps/web/src/types/vue3-emoji-picker.d.ts
  13. 560 727
      apps/web/src/views/agent/components/EditModal.vue
  14. 193 34
      apps/web/src/views/agent/index.vue
  15. 2 0
      apps/web/src/views/agent/type.d.ts
  16. 6 2
      apps/web/src/views/chat/composables/useChatStream.ts
  17. 8 8
      apps/web/src/views/knowledge/DocumentManage.vue
  18. 1 1
      apps/web/src/views/knowledge/KnowledgeBaseSidebar.vue
  19. 0 9
      apps/web/src/views/knowledge/index.vue
  20. 29 4
      apps/web/src/views/model/ModelManage.vue
  21. 40 0
      apps/web/src/views/model/ModelPage.vue
  22. 44 0
      apps/web/src/views/model/OllamaPage.vue
  23. 2 69
      apps/web/src/views/model/index.vue
  24. 37 0
      apps/web/src/views/resource/McpPage.vue
  25. 37 0
      apps/web/src/views/resource/PromptPage.vue
  26. 37 0
      apps/web/src/views/resource/SkillsPage.vue
  27. 37 0
      apps/web/src/views/resource/StoragePage.vue
  28. 37 0
      apps/web/src/views/resource/WebSearchPage.vue
  29. 1 1
      apps/web/src/views/resource/components/McpPanel.vue
  30. 24 8
      apps/web/src/views/resource/components/PromptTemplatePanel.vue
  31. 131 0
      apps/web/src/views/resource/components/SkillsPanel.vue
  32. 728 0
      apps/web/src/views/resource/components/StorageManager.vue
  33. 6 2
      apps/web/src/views/resource/components/WebSearchPanel.vue
  34. 2 76
      apps/web/src/views/resource/index.vue
  35. 1 1
      packages/api-client/index.ts
  36. 25 0
      pnpm-lock.yaml

+ 67 - 0
CLAUDE.md

@@ -0,0 +1,67 @@
+---
+name: karpathy-guidelines
+description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
+license: MIT
+---
+
+# Karpathy Guidelines
+
+Behavioral guidelines to reduce common LLM coding mistakes, derived from [Andrej Karpathy's observations](https://x.com/karpathy/status/2015883857489522876) on LLM coding pitfalls.
+
+**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
+
+## 1. Think Before Coding
+
+**Don't assume. Don't hide confusion. Surface tradeoffs.**
+
+Before implementing:
+- State your assumptions explicitly. If uncertain, ask.
+- If multiple interpretations exist, present them - don't pick silently.
+- If a simpler approach exists, say so. Push back when warranted.
+- If something is unclear, stop. Name what's confusing. Ask.
+
+## 2. Simplicity First
+
+**Minimum code that solves the problem. Nothing speculative.**
+
+- No features beyond what was asked.
+- No abstractions for single-use code.
+- No "flexibility" or "configurability" that wasn't requested.
+- No error handling for impossible scenarios.
+- If you write 200 lines and it could be 50, rewrite it.
+
+Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
+
+## 3. Surgical Changes
+
+**Touch only what you must. Clean up only your own mess.**
+
+When editing existing code:
+- Don't "improve" adjacent code, comments, or formatting.
+- Don't refactor things that aren't broken.
+- Match existing style, even if you'd do it differently.
+- If you notice unrelated dead code, mention it - don't delete it.
+
+When your changes create orphans:
+- Remove imports/variables/functions that YOUR changes made unused.
+- Don't remove pre-existing dead code unless asked.
+
+The test: Every changed line should trace directly to the user's request.
+
+## 4. Goal-Driven Execution
+
+**Define success criteria. Loop until verified.**
+
+Transform tasks into verifiable goals:
+- "Add validation" → "Write tests for invalid inputs, then make them pass"
+- "Fix the bug" → "Write a test that reproduces it, then make it pass"
+- "Refactor X" → "Ensure tests pass before and after"
+
+For multi-step tasks, state a brief plan:
+```
+1. [Step] → verify: [check]
+2. [Step] → verify: [check]
+3. [Step] → verify: [check]
+```
+
+Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.

+ 119 - 0
apps/web/components.d.ts

@@ -13,6 +13,64 @@ export {}
 declare module 'vue' {
 declare module 'vue' {
   export interface GlobalComponents {
   export interface GlobalComponents {
     CodeEditor: typeof import('./src/components/CodeEditor/CodeEditor.vue')['default']
     CodeEditor: typeof import('./src/components/CodeEditor/CodeEditor.vue')['default']
+    ElAlert: typeof import('element-plus/es')['ElAlert']
+    ElAside: typeof import('element-plus/es')['ElAside']
+    ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
+    ElAvatar: typeof import('element-plus/es')['ElAvatar']
+    ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
+    ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
+    ElButton: typeof import('element-plus/es')['ElButton']
+    ElCard: typeof import('element-plus/es')['ElCard']
+    ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+    ElCheckTag: typeof import('element-plus/es')['ElCheckTag']
+    ElCol: typeof import('element-plus/es')['ElCol']
+    ElCollapse: typeof import('element-plus/es')['ElCollapse']
+    ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
+    ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
+    ElContainer: typeof import('element-plus/es')['ElContainer']
+    ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+    ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
+    ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
+    ElDialog: typeof import('element-plus/es')['ElDialog']
+    ElDivider: typeof import('element-plus/es')['ElDivider']
+    ElDrawer: typeof import('element-plus/es')['ElDrawer']
+    ElDropdown: typeof import('element-plus/es')['ElDropdown']
+    ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
+    ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
+    ElEmpty: typeof import('element-plus/es')['ElEmpty']
+    ElForm: typeof import('element-plus/es')['ElForm']
+    ElFormItem: typeof import('element-plus/es')['ElFormItem']
+    ElIcon: typeof import('element-plus/es')['ElIcon']
+    ElImage: typeof import('element-plus/es')['ElImage']
+    ElInput: typeof import('element-plus/es')['ElInput']
+    ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
+    ElInputTag: typeof import('element-plus/es')['ElInputTag']
+    ElMain: typeof import('element-plus/es')['ElMain']
+    ElMenu: typeof import('element-plus/es')['ElMenu']
+    ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
+    ElOption: typeof import('element-plus/es')['ElOption']
+    ElPagination: typeof import('element-plus/es')['ElPagination']
+    ElPopover: typeof import('element-plus/es')['ElPopover']
+    ElProgress: typeof import('element-plus/es')['ElProgress']
+    ElRadio: typeof import('element-plus/es')['ElRadio']
+    ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
+    ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
+    ElRow: typeof import('element-plus/es')['ElRow']
+    ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
+    ElSelect: typeof import('element-plus/es')['ElSelect']
+    ElSlider: typeof import('element-plus/es')['ElSlider']
+    ElSpace: typeof import('element-plus/es')['ElSpace']
+    ElSplitter: typeof import('element-plus/es')['ElSplitter']
+    ElSplitterPanel: typeof import('element-plus/es')['ElSplitterPanel']
+    ElSwitch: typeof import('element-plus/es')['ElSwitch']
+    ElTable: typeof import('element-plus/es')['ElTable']
+    ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
+    ElTabPane: typeof import('element-plus/es')['ElTabPane']
+    ElTabs: typeof import('element-plus/es')['ElTabs']
+    ElTag: typeof import('element-plus/es')['ElTag']
+    ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
+    ElTooltip: typeof import('element-plus/es')['ElTooltip']
+    ElUpload: typeof import('element-plus/es')['ElUpload']
     ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
     ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
     RichEditor: typeof import('./src/components/RichEditor/index.vue')['default']
     RichEditor: typeof import('./src/components/RichEditor/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterLink: typeof import('vue-router')['RouterLink']
@@ -23,11 +81,72 @@ declare module 'vue' {
     TemplateModal: typeof import('./src/components/TemplateModal/index.vue')['default']
     TemplateModal: typeof import('./src/components/TemplateModal/index.vue')['default']
     VarLabel: typeof import('./src/components/VarLabel/index.vue')['default']
     VarLabel: typeof import('./src/components/VarLabel/index.vue')['default']
   }
   }
+  export interface GlobalDirectives {
+    vLoading: typeof import('element-plus/es')['ElLoadingDirective']
+  }
 }
 }
 
 
 // For TSX support
 // For TSX support
 declare global {
 declare global {
   const CodeEditor: typeof import('./src/components/CodeEditor/CodeEditor.vue')['default']
   const CodeEditor: typeof import('./src/components/CodeEditor/CodeEditor.vue')['default']
+  const ElAlert: typeof import('element-plus/es')['ElAlert']
+  const ElAside: typeof import('element-plus/es')['ElAside']
+  const ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
+  const ElAvatar: typeof import('element-plus/es')['ElAvatar']
+  const ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
+  const ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
+  const ElButton: typeof import('element-plus/es')['ElButton']
+  const ElCard: typeof import('element-plus/es')['ElCard']
+  const ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+  const ElCheckTag: typeof import('element-plus/es')['ElCheckTag']
+  const ElCol: typeof import('element-plus/es')['ElCol']
+  const ElCollapse: typeof import('element-plus/es')['ElCollapse']
+  const ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
+  const ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
+  const ElContainer: typeof import('element-plus/es')['ElContainer']
+  const ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+  const ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
+  const ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
+  const ElDialog: typeof import('element-plus/es')['ElDialog']
+  const ElDivider: typeof import('element-plus/es')['ElDivider']
+  const ElDrawer: typeof import('element-plus/es')['ElDrawer']
+  const ElDropdown: typeof import('element-plus/es')['ElDropdown']
+  const ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
+  const ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
+  const ElEmpty: typeof import('element-plus/es')['ElEmpty']
+  const ElForm: typeof import('element-plus/es')['ElForm']
+  const ElFormItem: typeof import('element-plus/es')['ElFormItem']
+  const ElIcon: typeof import('element-plus/es')['ElIcon']
+  const ElImage: typeof import('element-plus/es')['ElImage']
+  const ElInput: typeof import('element-plus/es')['ElInput']
+  const ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
+  const ElInputTag: typeof import('element-plus/es')['ElInputTag']
+  const ElMain: typeof import('element-plus/es')['ElMain']
+  const ElMenu: typeof import('element-plus/es')['ElMenu']
+  const ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
+  const ElOption: typeof import('element-plus/es')['ElOption']
+  const ElPagination: typeof import('element-plus/es')['ElPagination']
+  const ElPopover: typeof import('element-plus/es')['ElPopover']
+  const ElProgress: typeof import('element-plus/es')['ElProgress']
+  const ElRadio: typeof import('element-plus/es')['ElRadio']
+  const ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
+  const ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
+  const ElRow: typeof import('element-plus/es')['ElRow']
+  const ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
+  const ElSelect: typeof import('element-plus/es')['ElSelect']
+  const ElSlider: typeof import('element-plus/es')['ElSlider']
+  const ElSpace: typeof import('element-plus/es')['ElSpace']
+  const ElSplitter: typeof import('element-plus/es')['ElSplitter']
+  const ElSplitterPanel: typeof import('element-plus/es')['ElSplitterPanel']
+  const ElSwitch: typeof import('element-plus/es')['ElSwitch']
+  const ElTable: typeof import('element-plus/es')['ElTable']
+  const ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
+  const ElTabPane: typeof import('element-plus/es')['ElTabPane']
+  const ElTabs: typeof import('element-plus/es')['ElTabs']
+  const ElTag: typeof import('element-plus/es')['ElTag']
+  const ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
+  const ElTooltip: typeof import('element-plus/es')['ElTooltip']
+  const ElUpload: typeof import('element-plus/es')['ElUpload']
   const ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
   const ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
   const RichEditor: typeof import('./src/components/RichEditor/index.vue')['default']
   const RichEditor: typeof import('./src/components/RichEditor/index.vue')['default']
   const RouterLink: typeof import('vue-router')['RouterLink']
   const RouterLink: typeof import('vue-router')['RouterLink']

+ 2 - 1
apps/web/package.json

@@ -30,7 +30,8 @@
     "vue-draggable-plus": "^0.6.1",
     "vue-draggable-plus": "^0.6.1",
     "vue-element-plus-x": "^1.3.98",
     "vue-element-plus-x": "^1.3.98",
     "vue-hooks-plus": "^2.4.1",
     "vue-hooks-plus": "^2.4.1",
-    "vue-router": "4"
+    "vue-router": "4",
+    "vue3-emoji-picker": "^1.1.8"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@repo/api-client": "workspace:*",
     "@repo/api-client": "workspace:*",

文件差異過大導致無法顯示
+ 1 - 0
apps/web/src/assets/icons/mcp.svg


文件差異過大導致無法顯示
+ 1 - 0
apps/web/src/assets/icons/ollama.svg


文件差異過大導致無法顯示
+ 1 - 0
apps/web/src/assets/icons/prompts.svg


文件差異過大導致無法顯示
+ 1 - 0
apps/web/src/assets/icons/skills.svg


文件差異過大導致無法顯示
+ 1 - 0
apps/web/src/assets/icons/storage.svg


文件差異過大導致無法顯示
+ 1 - 0
apps/web/src/assets/icons/websearch.svg


+ 76 - 10
apps/web/src/components/Sidebar/index.vue

@@ -106,14 +106,6 @@
 				<span v-if="!collapsed" class="label">{{ t('sidebar.menu.knowledge') }}</span>
 				<span v-if="!collapsed" class="label">{{ t('sidebar.menu.knowledge') }}</span>
 			</el-menu-item>
 			</el-menu-item>
 
 
-			<el-menu-item index="/resource">
-				<el-tooltip v-if="collapsed" :content="t('sidebar.menu.resource')" placement="right">
-					<span><SvgIcon name="source" /></span>
-				</el-tooltip>
-				<SvgIcon v-else name="source" />
-				<span v-if="!collapsed" class="label">{{ t('sidebar.menu.resource') }}</span>
-			</el-menu-item>
-
 			<el-menu-item index="/execution">
 			<el-menu-item index="/execution">
 				<el-tooltip v-if="collapsed" :content="t('sidebar.menu.execution')" placement="right">
 				<el-tooltip v-if="collapsed" :content="t('sidebar.menu.execution')" placement="right">
 					<span><SvgIcon name="play" /></span>
 					<span><SvgIcon name="play" /></span>
@@ -138,8 +130,8 @@
 		<div class="bottom-menu">
 		<div class="bottom-menu">
 			<div
 			<div
 				class="bottom-item"
 				class="bottom-item"
-				:class="{ active: router.currentRoute.value.path === '/model' }"
-				@click="$router.push('/model')"
+				:class="{ active: isBottomMenuActive('/models') }"
+				@click="$router.push('/models')"
 			>
 			>
 				<el-tooltip v-if="collapsed" content="模型管理" placement="right">
 				<el-tooltip v-if="collapsed" content="模型管理" placement="right">
 					<span><SvgIcon name="model" /></span>
 					<span><SvgIcon name="model" /></span>
@@ -148,6 +140,78 @@
 				<span v-if="!collapsed" class="label">模型管理</span>
 				<span v-if="!collapsed" class="label">模型管理</span>
 			</div>
 			</div>
 
 
+			<div
+				class="bottom-item"
+				:class="{ active: isBottomMenuActive('/ollama') }"
+				@click="$router.push('/ollama')"
+			>
+				<el-tooltip v-if="collapsed" content="Ollama" placement="right">
+					<span><SvgIcon name="ollama" /></span>
+				</el-tooltip>
+				<SvgIcon v-else name="ollama" />
+				<span v-if="!collapsed" class="label">Ollama</span>
+			</div>
+
+			<div
+				class="bottom-item"
+				:class="{ active: isBottomMenuActive('/prompts') }"
+				@click="$router.push('/prompts')"
+			>
+				<el-tooltip v-if="collapsed" content="提示词模板" placement="right">
+					<span><SvgIcon name="prompts" /></span>
+				</el-tooltip>
+				<SvgIcon v-else name="prompts" />
+				<span v-if="!collapsed" class="label">提示词模板</span>
+			</div>
+
+			<div
+				class="bottom-item"
+				:class="{ active: isBottomMenuActive('/storage') }"
+				@click="$router.push('/storage')"
+			>
+				<el-tooltip v-if="collapsed" content="存储引擎" placement="right">
+					<span><SvgIcon name="storage" /></span>
+				</el-tooltip>
+				<SvgIcon v-else name="storage" />
+				<span v-if="!collapsed" class="label">存储引擎</span>
+			</div>
+
+			<div
+				class="bottom-item"
+				:class="{ active: isBottomMenuActive('/web-search') }"
+				@click="$router.push('/web-search')"
+			>
+				<el-tooltip v-if="collapsed" content="网络搜索" placement="right">
+					<span><SvgIcon name="websearch" /></span>
+				</el-tooltip>
+				<SvgIcon v-else name="websearch" />
+				<span v-if="!collapsed" class="label">网络搜索</span>
+			</div>
+
+			<div
+				class="bottom-item"
+				:class="{ active: isBottomMenuActive('/mcp') }"
+				@click="$router.push('/mcp')"
+			>
+				<el-tooltip v-if="collapsed" content="MCP服务" placement="right">
+					<span><SvgIcon name="mcp" /></span>
+				</el-tooltip>
+				<SvgIcon v-else name="mcp" />
+				<span v-if="!collapsed" class="label">MCP服务</span>
+			</div>
+
+			<div
+				class="bottom-item"
+				:class="{ active: isBottomMenuActive('/skills') }"
+				@click="$router.push('/skills')"
+			>
+				<el-tooltip v-if="collapsed" content="Skills技能" placement="right">
+					<span><SvgIcon name="skills" /></span>
+				</el-tooltip>
+				<SvgIcon v-else name="skills" />
+				<span v-if="!collapsed" class="label">Skills技能</span>
+			</div>
+
 			<div class="bottom-item" :class="{ active: showTemplateModal }" @click="handleTemplateClick">
 			<div class="bottom-item" :class="{ active: showTemplateModal }" @click="handleTemplateClick">
 				<el-tooltip v-if="collapsed" :content="t('sidebar.menu.templates')" placement="right">
 				<el-tooltip v-if="collapsed" :content="t('sidebar.menu.templates')" placement="right">
 					<span><SvgIcon name="box" /></span>
 					<span><SvgIcon name="box" /></span>
@@ -237,6 +301,8 @@ const createModalVisible = ref(false)
 // 计算当前活跃的菜单项
 // 计算当前活跃的菜单项
 const activeMenu = computed(() => router.currentRoute.value.path)
 const activeMenu = computed(() => router.currentRoute.value.path)
 
 
+const isBottomMenuActive = (path: string) => router.currentRoute.value.path === path
+
 const toggle = () => {
 const toggle = () => {
 	collapsed.value = !collapsed.value
 	collapsed.value = !collapsed.value
 }
 }

+ 42 - 0
apps/web/src/router/index.ts

@@ -18,9 +18,16 @@ const ModelLog = () => import('@/views/ModelLog.vue')
 const WorkflowExecution = () => import('@/views/WorkflowExecution.vue')
 const WorkflowExecution = () => import('@/views/WorkflowExecution.vue')
 const FlowManagement = () => import('@/views/FlowManagement.vue')
 const FlowManagement = () => import('@/views/FlowManagement.vue')
 const ModelManager = () => import('@/views/model/index.vue')
 const ModelManager = () => import('@/views/model/index.vue')
+const ModelPage = () => import('@/views/model/ModelPage.vue')
+const OllamaPage = () => import('@/views/model/OllamaPage.vue')
 const KnowledgeManager = () => import('@/views/knowledge/index.vue')
 const KnowledgeManager = () => import('@/views/knowledge/index.vue')
 const AgentManager = () => import('@/views/agent/index.vue')
 const AgentManager = () => import('@/views/agent/index.vue')
 const ResourceManager = () => import('@/views/resource/index.vue')
 const ResourceManager = () => import('@/views/resource/index.vue')
+const PromptPage = () => import('@/views/resource/PromptPage.vue')
+const WebSearchPage = () => import('@/views/resource/WebSearchPage.vue')
+const McpPage = () => import('@/views/resource/McpPage.vue')
+const StoragePage = () => import('@/views/resource/StoragePage.vue')
+const SkillsPage = () => import('@/views/resource/SkillsPage.vue')
 const Workspace = () => import('@/views/workspace/index.vue')
 const Workspace = () => import('@/views/workspace/index.vue')
 
 
 const routes = [
 const routes = [
@@ -104,6 +111,16 @@ const routes = [
 				name: 'ModelManager',
 				name: 'ModelManager',
 				component: ModelManager
 				component: ModelManager
 			},
 			},
+			{
+				path: 'models',
+				name: 'ModelPage',
+				component: ModelPage
+			},
+			{
+				path: 'ollama',
+				name: 'OllamaPage',
+				component: OllamaPage
+			},
 			{
 			{
 				path: 'knowledge',
 				path: 'knowledge',
 				name: 'KnowledgeManager',
 				name: 'KnowledgeManager',
@@ -114,6 +131,31 @@ const routes = [
 				name: 'ResourceManager',
 				name: 'ResourceManager',
 				component: ResourceManager
 				component: ResourceManager
 			},
 			},
+			{
+				path: 'prompts',
+				name: 'PromptPage',
+				component: PromptPage
+			},
+			{
+				path: 'storage',
+				name: 'StoragePage',
+				component: StoragePage
+			},
+			{
+				path: 'web-search',
+				name: 'WebSearchPage',
+				component: WebSearchPage
+			},
+			{
+				path: 'mcp',
+				name: 'McpPage',
+				component: McpPage
+			},
+			{
+				path: 'skills',
+				name: 'SkillsPage',
+				component: SkillsPage
+			},
 			{
 			{
 				path: 'workspace',
 				path: 'workspace',
 				name: 'Workspace',
 				name: 'Workspace',

+ 1 - 0
apps/web/src/types/vue3-emoji-picker.d.ts

@@ -0,0 +1 @@
+declare module 'vue3-emoji-picker'

文件差異過大導致無法顯示
+ 560 - 727
apps/web/src/views/agent/components/EditModal.vue


+ 193 - 34
apps/web/src/views/agent/index.vue

@@ -46,23 +46,25 @@
 						style="width: 160px"
 						style="width: 160px"
 						@change="handleSearch"
 						@change="handleSearch"
 					>
 					>
-						<el-option label="quick-answer" value="quick-answer" />
-						<el-option label="agent" value="agent" />
+						<el-option label="问答模式" value="quick-answer" />
+						<el-option label="智能推理模式" value="smart-reasoning" />
 					</el-select>
 					</el-select>
-					<el-select
+					<!-- <el-select
 						v-model="filters.type"
 						v-model="filters.type"
 						clearable
 						clearable
 						placeholder="类型"
 						placeholder="类型"
 						style="width: 160px"
 						style="width: 160px"
 						@change="handleSearch"
 						@change="handleSearch"
 					>
 					>
-						<el-option label="knowledgeqa" value="knowledgeqa" />
+						<el-option label="rag-qa" value="rag-qa" />
 						<el-option label="agent" value="agent" />
 						<el-option label="agent" value="agent" />
-					</el-select>
+					</el-select> -->
 					<el-button @click="handleReset">重置</el-button>
 					<el-button @click="handleReset">重置</el-button>
 				</div>
 				</div>
 				<div class="toolbar-right">
 				<div class="toolbar-right">
-					<span class="pill">第 {{ pagination.currentPage }} / {{ pagination.totalPages || 1 }} 页</span>
+					<span class="pill"
+						>第 {{ pagination.currentPage }} / {{ pagination.totalPages || 1 }} 页</span
+					>
 					<span class="pill">每页 {{ pagination.pageSize }} 条</span>
 					<span class="pill">每页 {{ pagination.pageSize }} 条</span>
 				</div>
 				</div>
 			</div>
 			</div>
@@ -71,29 +73,73 @@
 				<el-empty v-if="!visibleList.length && !loading" description="暂无智能体" class="empty" />
 				<el-empty v-if="!visibleList.length && !loading" description="暂无智能体" class="empty" />
 
 
 				<div v-for="item in visibleList" :key="item.id" class="card" @click="openDetail(item.id)">
 				<div v-for="item in visibleList" :key="item.id" class="card" @click="openDetail(item.id)">
-					<div class="cover">
-						<div class="cover-badges">
-							<span class="badge">{{ item.mode || '-' }}</span>
-							<span class="badge subtle">{{ item.type || '-' }}</span>
+					<div class="card-head">
+						<div class="emoji-avatar">{{ getAgentEmoji(item.avatar) }}</div>
+						<div class="card-head__content">
+							<div class="card-head__top">
+								<div class="title">{{ item.name || '未命名智能体' }}</div>
+								<div class="actions" @click.stop>
+									<el-dropdown :hide-on-click="false">
+										<span class="actions-trigger">
+											<el-icon><MoreFilled /></el-icon>
+										</span>
+										<template #dropdown>
+											<el-dropdown-menu>
+												<el-dropdown-item>
+													<el-button link type="primary" @click="openEditDrawer(item.id)"
+														>编辑</el-button
+													>
+												</el-dropdown-item>
+												<el-dropdown-item>
+													<el-button link type="danger" @click="removeItem(item.id)"
+														>删除</el-button
+													>
+												</el-dropdown-item>
+											</el-dropdown-menu>
+										</template>
+									</el-dropdown>
+								</div>
+							</div>
+							<div class="card-subtitle">
+								<span class="badge">{{ formatModeLabel(item.mode) }}</span>
+								<span class="badge subtle">{{ formatTypeLabel(item.type) }}</span>
+							</div>
 						</div>
 						</div>
 					</div>
 					</div>
 
 
 					<div class="body">
 					<div class="body">
 						<div class="title">{{ item.name || '未命名智能体' }}</div>
 						<div class="title">{{ item.name || '未命名智能体' }}</div>
-						<div class="desc">{{ item.description || '-' }}</div>
+						<div class="desc">{{ item.description || '暂无描述' }}</div>
 
 
 						<div class="tags">
 						<div class="tags">
 							<el-tag v-if="item.is_builtin" type="success" effect="light">内置</el-tag>
 							<el-tag v-if="item.is_builtin" type="success" effect="light">内置</el-tag>
-							<el-tag v-if="item.status === 'published'" type="warning" effect="light">已发布</el-tag>
-							<el-tag v-if="item.status !== 'published'" type="warning" effect="light">未发布</el-tag>
-							<el-tag v-if="item.config?.web_search_config?.web_search_enabled" type="info" effect="light">
+							<el-tag v-if="item.status === 'published'" type="warning" effect="light"
+								>已发布</el-tag
+							>
+							<el-tag v-if="item.status !== 'published'" type="warning" effect="light"
+								>未发布</el-tag
+							>
+							<el-tag
+								v-if="item.config?.web_search_config?.web_search_enabled"
+								type="info"
+								effect="light"
+							>
 								网络搜索
 								网络搜索
 							</el-tag>
 							</el-tag>
-							<el-tag v-if="item.config?.img_vlm_config?.image_upload_enabled" type="info" effect="light">
+							<el-tag
+								v-if="item.config?.img_vlm_config?.image_upload_enabled"
+								type="info"
+								effect="light"
+							>
 								图片上传
 								图片上传
 							</el-tag>
 							</el-tag>
 						</div>
 						</div>
 
 
+						<!-- <div class="card-meta">
+							<span class="meta-chip">{{ formatId(item.id) }}</span>
+							<span class="meta-chip">{{ item.config?.model_config?.model_id || '未配置模型' }}</span>
+						</div> -->
+
 						<div class="actions" @click.stop>
 						<div class="actions" @click.stop>
 							<el-dropdown :hide-on-click="false">
 							<el-dropdown :hide-on-click="false">
 								<span class="cursor-pointer">
 								<span class="cursor-pointer">
@@ -102,7 +148,9 @@
 								<template #dropdown>
 								<template #dropdown>
 									<el-dropdown-menu>
 									<el-dropdown-menu>
 										<el-dropdown-item>
 										<el-dropdown-item>
-											<el-button link type="primary" @click="openEditDrawer(item.id)">编辑</el-button>
+											<el-button link type="primary" @click="openEditDrawer(item.id)"
+												>编辑</el-button
+											>
 										</el-dropdown-item>
 										</el-dropdown-item>
 										<el-dropdown-item>
 										<el-dropdown-item>
 											<el-button link type="danger" @click="removeItem(item.id)">删除</el-button>
 											<el-button link type="danger" @click="removeItem(item.id)">删除</el-button>
@@ -135,7 +183,9 @@
 			<el-descriptions v-if="detailItem" :column="1" border>
 			<el-descriptions v-if="detailItem" :column="1" border>
 				<el-descriptions-item label="名称">{{ detailItem.name }}</el-descriptions-item>
 				<el-descriptions-item label="名称">{{ detailItem.name }}</el-descriptions-item>
 				<el-descriptions-item label="模式">{{ detailItem.mode }}</el-descriptions-item>
 				<el-descriptions-item label="模式">{{ detailItem.mode }}</el-descriptions-item>
-				<el-descriptions-item label="描述">{{ detailItem.description || '-' }}</el-descriptions-item>
+				<el-descriptions-item label="描述">{{
+					detailItem.description || '-'
+				}}</el-descriptions-item>
 				<el-descriptions-item label="系统提示词">{{
 				<el-descriptions-item label="系统提示词">{{
 					detailItem.config?.basic_config?.system_prompt || '-'
 					detailItem.config?.basic_config?.system_prompt || '-'
 				}}</el-descriptions-item>
 				}}</el-descriptions-item>
@@ -189,6 +239,29 @@ const publishedCount = computed(
 	() => list.value.filter((item) => item.status === 'published' || item.is_builtin).length
 	() => list.value.filter((item) => item.status === 'published' || item.is_builtin).length
 )
 )
 
 
+function getAgentEmoji(avatar?: string) {
+	return avatar?.trim() || '🤖'
+}
+
+function formatModeLabel(mode?: string) {
+	if (mode === 'quick-answer') return '问答模式'
+	if (mode === 'smart-reasoning') return '推理模式'
+	if (mode === 'agent') return 'Agent'
+	return mode || '未设置模式'
+}
+
+function formatTypeLabel(type?: string) {
+	if (type === 'knowledgeqa') return 'Knowledge'
+	if (type === 'agent') return 'Agent'
+	return type || '默认类型'
+}
+
+function formatId(id?: string) {
+	if (!id) return 'ID 未生成'
+	if (id.length <= 14) return id
+	return `${id.slice(0, 6)}...${id.slice(-4)}`
+}
+
 async function loadList(pageIndex = 1) {
 async function loadList(pageIndex = 1) {
 	loading.value = true
 	loading.value = true
 	try {
 	try {
@@ -354,18 +427,56 @@ onMounted(async () => {
 	box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
 	box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
 	cursor: pointer;
 	cursor: pointer;
 	padding: 16px;
 	padding: 16px;
+	display: flex;
+	flex-direction: column;
+	gap: 14px;
+	transition:
+		transform 0.22s ease,
+		box-shadow 0.22s ease,
+		border-color 0.22s ease;
+}
+.card:hover {
+	transform: translateY(-3px);
+	border-color: #d8dee9;
+	box-shadow: 0 18px 36px rgba(15, 23, 42, 0.1);
+}
+.card-head {
+	display: flex;
+	align-items: center;
+	gap: 14px;
+	padding: 14px 16px;
+	border-radius: 18px;
+	background:
+		radial-gradient(circle at top left, rgba(15, 23, 42, 0.06), transparent 38%),
+		linear-gradient(135deg, #f8fafc, #eef2ff 58%, #fff7ed);
 }
 }
-.cover {
-	position: relative;
-	height: 120px;
-	background: linear-gradient(135deg, #eff6ff, #fdf2f8);
+.emoji-avatar {
+	width: 68px;
+	height: 68px;
 	border-radius: 20px;
 	border-radius: 20px;
+	display: grid;
+	place-items: center;
+	flex-shrink: 0;
+	font-size: 34px;
+	background: rgba(255, 255, 255, 0.82);
+	box-shadow:
+		inset 0 1px 0 rgba(255, 255, 255, 0.9),
+		0 12px 24px rgba(148, 163, 184, 0.18);
+}
+.card-head__content {
+	min-width: 0;
+	flex: 1;
+}
+.card-head__top {
+	display: flex;
+	align-items: flex-start;
+	justify-content: space-between;
+	gap: 12px;
 }
 }
-.cover-badges {
-	position: absolute;
-	top: 14px;
-	left: 14px;
+.card-subtitle {
+	margin-top: 10px;
 	display: flex;
 	display: flex;
+	flex-wrap: wrap;
 	gap: 8px;
 	gap: 8px;
 }
 }
 .badge {
 .badge {
@@ -373,35 +484,83 @@ onMounted(async () => {
 	border-radius: 999px;
 	border-radius: 999px;
 	background: rgba(255, 255, 255, 0.92);
 	background: rgba(255, 255, 255, 0.92);
 	font-size: 12px;
 	font-size: 12px;
+	color: #334155;
+	border: 1px solid rgba(203, 213, 225, 0.9);
 }
 }
 .badge.subtle {
 .badge.subtle {
-	background: rgba(15, 23, 42, 0.75);
-	color: #fff;
+	background: rgba(15, 23, 42, 0.82);
+	color: #f8fafc;
+	border-color: transparent;
 }
 }
 .body {
 .body {
-	padding: 16px;
+	padding: 0 2px;
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
 }
 }
 .title {
 .title {
-	font-size: 14px;
+	font-size: 18px;
 	font-weight: 700;
 	font-weight: 700;
+	line-height: 1.25;
+	color: #0f172a;
+	word-break: break-word;
 }
 }
 .desc {
 .desc {
-	margin-top: 6px;
-	min-height: 42px;
-	color: #6b7280;
+	min-height: 44px;
+	color: #64748b;
 	font-size: 14px;
 	font-size: 14px;
+	line-height: 1.65;
+	display: -webkit-box;
+	-webkit-line-clamp: 2;
+	-webkit-box-orient: vertical;
+	overflow: hidden;
 }
 }
 .tags {
 .tags {
 	display: flex;
 	display: flex;
 	flex-wrap: wrap;
 	flex-wrap: wrap;
 	gap: 8px;
 	gap: 8px;
-	margin-top: 12px;
 }
 }
 .actions {
 .actions {
 	display: flex;
 	display: flex;
 	justify-content: flex-end;
 	justify-content: flex-end;
 	gap: 8px;
 	gap: 8px;
-	margin-top: 14px;
+	margin-left: auto;
+}
+.actions-trigger {
+	width: 30px;
+	height: 30px;
+	border-radius: 999px;
+	display: grid;
+	place-items: center;
+	color: #475569;
+	background: rgba(255, 255, 255, 0.8);
+	transition:
+		background 0.2s ease,
+		color 0.2s ease;
+}
+.actions-trigger:hover {
+	background: rgba(15, 23, 42, 0.08);
+	color: #0f172a;
+}
+.card-meta {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 8px;
+}
+.meta-chip {
+	max-width: 100%;
+	padding: 6px 10px;
+	border-radius: 999px;
+	background: #f8fafc;
+	border: 1px solid #e2e8f0;
+	color: #64748b;
+	font-size: 12px;
+	line-height: 1.2;
+	font-variant-numeric: tabular-nums;
+}
+.body > .title,
+.body > .actions {
+	display: none;
 }
 }
 .pagination {
 .pagination {
 	display: flex;
 	display: flex;

+ 2 - 0
apps/web/src/views/agent/type.d.ts

@@ -34,6 +34,8 @@ export interface AgentImageConfig {
 	image_upload_enabled?: boolean
 	image_upload_enabled?: boolean
 	vlm_model_id?: string
 	vlm_model_id?: string
 	image_storage_provider?: string
 	image_storage_provider?: string
+	audio_upload_enabled?: boolean
+	asr_model_id?: string
 }
 }
 
 
 export interface AgentFaqConfig {
 export interface AgentFaqConfig {

+ 6 - 2
apps/web/src/views/chat/composables/useChatStream.ts

@@ -47,12 +47,16 @@ export function useChatStream() {
 		abortController = new AbortController()
 		abortController = new AbortController()
 
 
 		try {
 		try {
+			const token =
+				localStorage.getItem('oauth2token') ||
+				document.cookie.match(new RegExp('(^| )' + 'x-sessionId_b' + '=([^;]*)(;|$)'))?.[2]
+
 			const response = await fetch(url, {
 			const response = await fetch(url, {
 				method: 'POST',
 				method: 'POST',
 				headers: {
 				headers: {
-					'Content-Type': 'application/json'
+					'Content-Type': 'application/json',
 					// 可能需要添加认证 Header,例如:
 					// 可能需要添加认证 Header,例如:
-					// 'Authorization': `Bearer ${token}`
+					Authorization: `Bearer ${token}`
 				},
 				},
 				body: JSON.stringify(body),
 				body: JSON.stringify(body),
 				signal: abortController.signal
 				signal: abortController.signal

+ 8 - 8
apps/web/src/views/knowledge/DocumentManage.vue

@@ -65,13 +65,6 @@
 						</el-tag>
 						</el-tag>
 					</template>
 					</template>
 				</el-table-column>
 				</el-table-column>
-				<el-table-column prop="enable_status" label="启用状态" width="120">
-					<template #default="{ row }">
-						<el-tag :type="row.enable_status === 'enable' ? 'success' : 'danger'">
-							{{ row.enable_status === 'enable' ? '启用' : '禁用' }}
-						</el-tag>
-					</template>
-				</el-table-column>
 				<el-table-column label="错误信息" min-width="180">
 				<el-table-column label="错误信息" min-width="180">
 					<template #default="{ row }">
 					<template #default="{ row }">
 						<span class="truncate-text">{{ row.error_message || '-' }}</span>
 						<span class="truncate-text">{{ row.error_message || '-' }}</span>
@@ -80,7 +73,14 @@
 				<el-table-column prop="updateTime" label="更新时间" width="180" />
 				<el-table-column prop="updateTime" label="更新时间" width="180" />
 				<el-table-column label="操作" width="220" fixed="right">
 				<el-table-column label="操作" width="220" fixed="right">
 					<template #default="{ row }">
 					<template #default="{ row }">
-						<el-button link type="primary" @click="openEditDrawer(row)">编辑</el-button>
+						<el-button
+							v-if="row.file_type === 'manual'"
+							link
+							type="primary"
+							@click="openEditDrawer(row)"
+						>
+							编辑
+						</el-button>
 						<el-button link type="warning" @click="reparseKnowledge(row.id)">重新解析</el-button>
 						<el-button link type="warning" @click="reparseKnowledge(row.id)">重新解析</el-button>
 						<el-button link type="danger" @click="removeKnowledge(row.id)">删除</el-button>
 						<el-button link type="danger" @click="removeKnowledge(row.id)">删除</el-button>
 					</template>
 					</template>

+ 1 - 1
apps/web/src/views/knowledge/KnowledgeBaseSidebar.vue

@@ -54,7 +54,7 @@
 					<div class="base-card__top">
 					<div class="base-card__top">
 						<div class="base-card__title">{{ item.name }}</div>
 						<div class="base-card__title">{{ item.name }}</div>
 						<el-tag :type="item.type === 'faq' ? 'warning' : 'success'" size="small">
 						<el-tag :type="item.type === 'faq' ? 'warning' : 'success'" size="small">
-							{{ item.type === 'faq' ? '问答库' : '知识库' }}
+							{{ item.type === 'faq' ? '问答' : '文档' }}
 						</el-tag>
 						</el-tag>
 					</div>
 					</div>
 					<div class="base-card__desc">{{ item.description || '暂无描述' }}</div>
 					<div class="base-card__desc">{{ item.description || '暂无描述' }}</div>

+ 0 - 9
apps/web/src/views/knowledge/index.vue

@@ -3,7 +3,6 @@
 		<div class="header">
 		<div class="header">
 			<div class="flex items-center justify-between">
 			<div class="flex items-center justify-between">
 				<h1>知识库</h1>
 				<h1>知识库</h1>
-				<el-button pain @click="openStorageModal" :icon="Files"> 存储引擎 </el-button>
 			</div>
 			</div>
 			<p class="subtitle">统一管理知识库,并在库内处理知识内容和问答条目。</p>
 			<p class="subtitle">统一管理知识库,并在库内处理知识内容和问答条目。</p>
 		</div>
 		</div>
@@ -25,7 +24,6 @@
 			<el-empty v-else description="请先在左侧选择一个知识库" class="empty-container" />
 			<el-empty v-else description="请先在左侧选择一个知识库" class="empty-container" />
 		</div>
 		</div>
 	</div>
 	</div>
-	<StorageModal ref="storageModalRef" />
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
@@ -34,22 +32,15 @@ import DocumentManage from './DocumentManage.vue'
 import KnowledgeBaseSidebar from './KnowledgeBaseSidebar.vue'
 import KnowledgeBaseSidebar from './KnowledgeBaseSidebar.vue'
 import QaManage from './QaManage.vue'
 import QaManage from './QaManage.vue'
 import type { KnowledgeBaseItem } from './types'
 import type { KnowledgeBaseItem } from './types'
-import { Files } from '@element-plus/icons-vue'
-import StorageModal from './components/StorageModal.vue'
 import WikiManage from './WikiManage.vue'
 import WikiManage from './WikiManage.vue'
 
 
 const currentBase = ref<KnowledgeBaseItem>()
 const currentBase = ref<KnowledgeBaseItem>()
 const currentModule = ref<'document' | 'qa' | 'wiki'>('document')
 const currentModule = ref<'document' | 'qa' | 'wiki'>('document')
-const storageModalRef = ref<InstanceType<typeof StorageModal>>()
 
 
 function handleSelectBase(base?: KnowledgeBaseItem) {
 function handleSelectBase(base?: KnowledgeBaseItem) {
 	currentBase.value = base
 	currentBase.value = base
 	currentModule.value = base?.type === 'document' ? 'document' : 'qa'
 	currentModule.value = base?.type === 'document' ? 'document' : 'qa'
 }
 }
-
-function openStorageModal() {
-	storageModalRef.value?.open()
-}
 </script>
 </script>
 
 
 <style scoped lang="less">
 <style scoped lang="less">

+ 29 - 4
apps/web/src/views/model/ModelManage.vue

@@ -212,7 +212,7 @@
 				<el-form-item v-if="modelForm.source === 'local'" label="本地模型" prop="name">
 				<el-form-item v-if="modelForm.source === 'local'" label="本地模型" prop="name">
 					<el-select v-model="modelForm.name" placeholder="请选择" style="width: 100%">
 					<el-select v-model="modelForm.name" placeholder="请选择" style="width: 100%">
 						<el-option
 						<el-option
-							v-for="model in localOllamaModels"
+							v-for="model in availableLocalModels"
 							:key="model.name"
 							:key="model.name"
 							:label="model.name"
 							:label="model.name"
 							:value="model.name"
 							:value="model.name"
@@ -363,10 +363,10 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import { ref, reactive, onMounted } from 'vue'
+import { computed, ref, reactive, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Plus, Refresh } from '@element-plus/icons-vue'
 import { Plus, Refresh } from '@element-plus/icons-vue'
-import { aiModel } from '@repo/api-service'
+import { aiModel, ollama } from '@repo/api-service'
 import type {
 import type {
 	ModelItem,
 	ModelItem,
 	ModelProvider,
 	ModelProvider,
@@ -376,7 +376,14 @@ import type {
 	modelType
 	modelType
 } from './types'
 } from './types'
 
 
-const props = defineProps<{ localOllamaModels: OllamaModel[] }>()
+const props = withDefaults(
+	defineProps<{
+		localOllamaModels?: OllamaModel[]
+	}>(),
+	{
+		localOllamaModels: () => []
+	}
+)
 
 
 const modelLoading = ref(false)
 const modelLoading = ref(false)
 const submitLoading = ref(false)
 const submitLoading = ref(false)
@@ -388,6 +395,7 @@ const currentModelId = ref<string | null>(null)
 const currentDetailModel = ref<ModelDetail | null>(null)
 const currentDetailModel = ref<ModelDetail | null>(null)
 const modelFormRef = ref()
 const modelFormRef = ref()
 const customHeaderList = ref<Array<{ key: string; value: string }>>([{ key: '', value: '' }])
 const customHeaderList = ref<Array<{ key: string; value: string }>>([{ key: '', value: '' }])
+const localModelOptions = ref<OllamaModel[]>([])
 const detailCheckLoading = ref(false)
 const detailCheckLoading = ref(false)
 const detailCheckResult = reactive<{ success: boolean; message: string }>({
 const detailCheckResult = reactive<{ success: boolean; message: string }>({
 	success: false,
 	success: false,
@@ -409,6 +417,10 @@ const pagination = reactive({
 	total: 0
 	total: 0
 })
 })
 
 
+const availableLocalModels = computed(() =>
+	props.localOllamaModels.length ? props.localOllamaModels : localModelOptions.value
+)
+
 const modelForm = reactive<ModelCreateForm>({
 const modelForm = reactive<ModelCreateForm>({
 	source: 'local',
 	source: 'local',
 	type: 'KnowledgeQA',
 	type: 'KnowledgeQA',
@@ -575,6 +587,18 @@ async function getAllModelList() {
 	}
 	}
 }
 }
 
 
+async function loadLocalModels() {
+	if (props.localOllamaModels.length) return
+	try {
+		const res = await ollama.postOllamaModels({})
+		if (res?.isSuccess) {
+			localModelOptions.value = (res.result || []) as OllamaModel[]
+		}
+	} catch {
+		localModelOptions.value = []
+	}
+}
+
 function handleSearch() {
 function handleSearch() {
 	pagination.pageIndex = 1
 	pagination.pageIndex = 1
 	getAllModelList()
 	getAllModelList()
@@ -868,6 +892,7 @@ async function deleteModelConfirm(id: string) {
 
 
 onMounted(() => {
 onMounted(() => {
 	// getProviders()
 	// getProviders()
+	loadLocalModels()
 	getAllModelList()
 	getAllModelList()
 	window.addEventListener('open-import-ollama-model', (e: any) => {
 	window.addEventListener('open-import-ollama-model', (e: any) => {
 		resetModelForm()
 		resetModelForm()

+ 40 - 0
apps/web/src/views/model/ModelPage.vue

@@ -0,0 +1,40 @@
+<template>
+	<div class="management-page">
+		<div class="page-head">
+			<div>
+				<h1>模型管理</h1>
+				<p>统一管理远程模型与本地导入到系统的模型配置。</p>
+			</div>
+		</div>
+		<ModelManage />
+	</div>
+</template>
+
+<script setup lang="ts">
+import ModelManage from './ModelManage.vue'
+</script>
+
+<style scoped lang="less">
+.management-page {
+	padding: 16px;
+	background: #f5f7fa;
+	min-height: 100%;
+	box-sizing: border-box;
+}
+
+.page-head {
+	margin-bottom: 20px;
+
+	h1 {
+		margin: 0;
+		font-size: 28px;
+		color: var(--text-primary);
+	}
+
+	p {
+		margin: 6px 0 0;
+		font-size: 14px;
+		color: var(--text-secondary);
+	}
+}
+</style>

+ 44 - 0
apps/web/src/views/model/OllamaPage.vue

@@ -0,0 +1,44 @@
+<template>
+	<div class="management-page">
+		<div class="page-head">
+			<div>
+				<h1>Ollama</h1>
+				<p>查看 Ollama 连接状态、本地模型列表,以及下载任务进度。</p>
+			</div>
+		</div>
+		<OllamaManage @open-import-model="handleOpenImport" />
+	</div>
+</template>
+
+<script setup lang="ts">
+import OllamaManage from './OllamaManage.vue'
+
+function handleOpenImport(name: string) {
+	window.dispatchEvent(new CustomEvent('open-import-ollama-model', { detail: name }))
+}
+</script>
+
+<style scoped lang="less">
+.management-page {
+	padding: 16px;
+	background: #f5f7fa;
+	min-height: 100%;
+	box-sizing: border-box;
+}
+
+.page-head {
+	margin-bottom: 20px;
+
+	h1 {
+		margin: 0;
+		font-size: 28px;
+		color: var(--text-primary);
+	}
+
+	p {
+		margin: 6px 0 0;
+		font-size: 14px;
+		color: var(--text-secondary);
+	}
+}
+</style>

+ 2 - 69
apps/web/src/views/model/index.vue

@@ -1,74 +1,7 @@
 <template>
 <template>
-	<div class="model-management-page">
-		<!-- 页面公共标题 -->
-		<div class="header">
-			<div>
-				<h1>模型管理</h1>
-				<p class="subtitle">可以通过Ollama下载模型到本地或接入模型提供商</p>
-			</div>
-		</div>
-
-		<!-- 标签页切换 -->
-		<el-tabs v-model="activeTab" class="main-tabs">
-			<el-tab-pane label="模型管理" name="model">
-				<ModelManage :local-ollama-models="localModels" />
-			</el-tab-pane>
-			<el-tab-pane label="Ollama" name="ollama">
-				<OllamaManage
-					@update:localModels="updateLocalModels"
-					@open-import-model="handleOpenImport"
-				/>
-			</el-tab-pane>
-		</el-tabs>
-	</div>
+	<ModelPage />
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import OllamaManage from './OllamaManage.vue'
-import ModelManage from './ModelManage.vue'
-import { ref } from 'vue'
-import type { OllamaModel } from './types'
-
-const activeTab = ref('model')
-// 共享状态:Ollama已下载模型,给新建模型下拉选择用
-const localModels = ref<OllamaModel[]>([])
-
-const updateLocalModels = (models: OllamaModel[]) => {
-	localModels.value = models
-}
-
-const handleOpenImport = (name: string) => {
-	activeTab.value = 'model'
-	// 触发ModelManage打开新建表单
-	window.dispatchEvent(new CustomEvent('open-import-ollama-model', { detail: name }))
-}
+import ModelPage from './ModelPage.vue'
 </script>
 </script>
-
-<style lang="less" scoped>
-.model-management-page {
-	padding: 16px;
-	background: #f5f7fa;
-	min-height: 100%;
-}
-.header {
-	display: flex;
-	justify-content: space-between;
-	align-items: center;
-	margin-bottom: 24px;
-	h1 {
-		font-size: 28px;
-		margin: 0;
-		color: var(--text-primary);
-	}
-	.subtitle {
-		margin: 6px 0 0;
-		color: var(--text-secondary);
-		font-size: 14px;
-	}
-}
-.main-tabs {
-	:deep(.el-tabs__content) {
-		padding-top: 16px;
-	}
-}
-</style>

+ 37 - 0
apps/web/src/views/resource/McpPage.vue

@@ -0,0 +1,37 @@
+<template>
+	<div class="management-page">
+		<div class="page-head">
+			<div>
+				<h1>MCP 服务</h1>
+				<p>维护 MCP 服务列表,并查看资源与工具详情。</p>
+			</div>
+		</div>
+		<McpPanel />
+	</div>
+</template>
+
+<script setup lang="ts">
+import McpPanel from './components/McpPanel.vue'
+</script>
+
+<style scoped lang="less">
+.management-page {
+	padding: 24px;
+	min-height: 100%;
+	box-sizing: border-box;
+}
+
+.page-head {
+	margin-bottom: 18px;
+
+	h1 {
+		margin: 0;
+		font-size: 28px;
+	}
+
+	p {
+		margin: 6px 0 0;
+		color: #6b7280;
+	}
+}
+</style>

+ 37 - 0
apps/web/src/views/resource/PromptPage.vue

@@ -0,0 +1,37 @@
+<template>
+	<div class="management-page">
+		<div class="page-head">
+			<div>
+				<h1>提示词</h1>
+				<p>管理系统提示词、上下文模板、改写与兜底等资源配置。</p>
+			</div>
+		</div>
+		<PromptTemplatePanel />
+	</div>
+</template>
+
+<script setup lang="ts">
+import PromptTemplatePanel from './components/PromptTemplatePanel.vue'
+</script>
+
+<style scoped lang="less">
+.management-page {
+	padding: 24px;
+	min-height: 100%;
+	box-sizing: border-box;
+}
+
+.page-head {
+	margin-bottom: 18px;
+
+	h1 {
+		margin: 0;
+		font-size: 28px;
+	}
+
+	p {
+		margin: 6px 0 0;
+		color: #6b7280;
+	}
+}
+</style>

+ 37 - 0
apps/web/src/views/resource/SkillsPage.vue

@@ -0,0 +1,37 @@
+<template>
+	<div class="management-page">
+		<div class="page-head">
+			<div>
+				<h1>Skills 技能</h1>
+				<p>查看当前系统可用的 Skills 列表,供智能体配置时选择。</p>
+			</div>
+		</div>
+		<SkillsPanel />
+	</div>
+</template>
+
+<script setup lang="ts">
+import SkillsPanel from './components/SkillsPanel.vue'
+</script>
+
+<style scoped lang="less">
+.management-page {
+	padding: 24px;
+	min-height: 100%;
+	box-sizing: border-box;
+}
+
+.page-head {
+	margin-bottom: 18px;
+
+	h1 {
+		margin: 0;
+		font-size: 28px;
+	}
+
+	p {
+		margin: 6px 0 0;
+		color: #6b7280;
+	}
+}
+</style>

+ 37 - 0
apps/web/src/views/resource/StoragePage.vue

@@ -0,0 +1,37 @@
+<template>
+	<div class="management-page">
+		<div class="page-head">
+			<div>
+				<h1>存储引擎</h1>
+				<p>管理对象存储引擎配置,支持保存与连通测试。</p>
+			</div>
+		</div>
+		<StorageManager />
+	</div>
+</template>
+
+<script setup lang="ts">
+import StorageManager from './components/StorageManager.vue'
+</script>
+
+<style scoped lang="less">
+.management-page {
+	padding: 24px;
+	min-height: 100%;
+	box-sizing: border-box;
+}
+
+.page-head {
+	margin-bottom: 18px;
+
+	h1 {
+		margin: 0;
+		font-size: 28px;
+	}
+
+	p {
+		margin: 6px 0 0;
+		color: #6b7280;
+	}
+}
+</style>

+ 37 - 0
apps/web/src/views/resource/WebSearchPage.vue

@@ -0,0 +1,37 @@
+<template>
+	<div class="management-page">
+		<div class="page-head">
+			<div>
+				<h1>网络搜索</h1>
+				<p>管理搜索引擎接入、参数配置与连通测试。</p>
+			</div>
+		</div>
+		<WebSearchPanel />
+	</div>
+</template>
+
+<script setup lang="ts">
+import WebSearchPanel from './components/WebSearchPanel.vue'
+</script>
+
+<style scoped lang="less">
+.management-page {
+	padding: 24px;
+	min-height: 100%;
+	box-sizing: border-box;
+}
+
+.page-head {
+	margin-bottom: 18px;
+
+	h1 {
+		margin: 0;
+		font-size: 28px;
+	}
+
+	p {
+		margin: 6px 0 0;
+		color: #6b7280;
+	}
+}
+</style>

+ 1 - 1
apps/web/src/views/resource/components/McpPanel.vue

@@ -60,7 +60,7 @@
 				<el-table-column label="启用" width="90">
 				<el-table-column label="启用" width="90">
 					<template #default="{ row }">
 					<template #default="{ row }">
 						<el-tag :type="row.enabled ? 'success' : 'info'" effect="light">
 						<el-tag :type="row.enabled ? 'success' : 'info'" effect="light">
-							{{ row.enabled ? '是' : '否' }}
+							{{ row.enabled ? '启用' : '禁用' }}
 						</el-tag>
 						</el-tag>
 					</template>
 					</template>
 				</el-table-column>
 				</el-table-column>

+ 24 - 8
apps/web/src/views/resource/components/PromptTemplatePanel.vue

@@ -13,6 +13,14 @@
 						<el-icon><Search /></el-icon>
 						<el-icon><Search /></el-icon>
 					</template>
 					</template>
 				</el-input>
 				</el-input>
+				<el-select
+					v-model="type"
+					clearable
+					placeholder="类型"
+					style="width: 160px"
+					:options="typeList"
+					@change="loadList(1)"
+				></el-select>
 				<el-button @click="loadList(1)">查询</el-button>
 				<el-button @click="loadList(1)">查询</el-button>
 				<el-button @click="handleReset">重置</el-button>
 				<el-button @click="handleReset">重置</el-button>
 			</div>
 			</div>
@@ -154,6 +162,7 @@ type PromptPageResult = NonNullable<PromptPageResponse['result']>
 type PromptItem = PromptPageResult['model'][number]
 type PromptItem = PromptPageResult['model'][number]
 
 
 const keyword = ref('')
 const keyword = ref('')
+const type = ref('')
 const loading = ref(false)
 const loading = ref(false)
 const submitLoading = ref(false)
 const submitLoading = ref(false)
 const drawerVisible = ref(false)
 const drawerVisible = ref(false)
@@ -190,15 +199,21 @@ const rules = {
 
 
 const visibleList = computed(() => list.value)
 const visibleList = computed(() => list.value)
 
 
+const typeMap: Record<string, string> = {
+	'system-prompt': '系统提示词',
+	'agent-system-prompt': 'Agent 系统提示词',
+	rewrite: '改写提示词',
+	'fall-back': '回退提示词',
+	'context-template': '上下文模板',
+	'generate-session-title': '生成会话标题',
+	'generate-summary': '生成概要',
+	'keywords-extraction': '关键词提取'
+}
+
+const typeList = Object.keys(typeMap).map((type) => ({ label: typeMap[type], value: type }))
+
 function formatType(type?: string) {
 function formatType(type?: string) {
-	const map: Record<string, string> = {
-		'system-prompt': '系统提示词',
-		'agent-system-prompt': 'Agent 系统提示词',
-		rewrite: '改写提示词',
-		fallback: '回退提示词',
-		'context-template': '上下文模板'
-	}
-	return map[type || ''] || type || '-'
+	return typeMap[type || ''] || type || '-'
 }
 }
 
 
 function shortText(text?: string) {
 function shortText(text?: string) {
@@ -223,6 +238,7 @@ async function loadList(pageIndex = 1) {
 	try {
 	try {
 		const res = await resource.postPromptTemplatePageList({
 		const res = await resource.postPromptTemplatePageList({
 			keyword: keyword.value,
 			keyword: keyword.value,
+			type: type.value,
 			pageIndex,
 			pageIndex,
 			pageSize: pagination.pageSize
 			pageSize: pagination.pageSize
 		})
 		})

+ 131 - 0
apps/web/src/views/resource/components/SkillsPanel.vue

@@ -0,0 +1,131 @@
+<template>
+	<div class="skills-panel">
+		<div class="toolbar">
+			<el-input
+				v-model="keyword"
+				clearable
+				placeholder="搜索技能名称或描述"
+				class="search-input"
+			>
+				<template #prefix>
+					<el-icon><Search /></el-icon>
+				</template>
+			</el-input>
+			<el-button @click="loadSkills" :loading="loading">刷新</el-button>
+		</div>
+
+		<div v-loading="loading" class="card-grid">
+			<el-empty v-if="!visibleList.length && !loading" description="暂无 Skills" class="empty" />
+			<div v-for="item in visibleList" :key="item.name" class="skill-card">
+				<div class="skill-card__top">
+					<div class="skill-card__title">{{ item.name }}</div>
+					<el-tag effect="plain" type="info">Skill</el-tag>
+				</div>
+				<div class="skill-card__desc">{{ item.description || '暂无描述' }}</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref } from 'vue'
+import { Search } from '@element-plus/icons-vue'
+import { resource } from '@repo/api-service'
+
+type SkillsListResponse = Awaited<ReturnType<typeof resource.postSkillsList>>
+type SkillItem = NonNullable<SkillsListResponse['result']>[number]
+
+const keyword = ref('')
+const loading = ref(false)
+const list = ref<SkillItem[]>([])
+
+const visibleList = computed(() => {
+	const value = keyword.value.trim().toLowerCase()
+	if (!value) return list.value
+	return list.value.filter((item) =>
+		[item.name, item.description].some((field) => field?.toLowerCase().includes(value))
+	)
+})
+
+async function loadSkills() {
+	loading.value = true
+	try {
+		const res = await resource.postSkillsList({})
+		if (res?.isSuccess) {
+			list.value = (res.result || []) as SkillItem[]
+		}
+	} finally {
+		loading.value = false
+	}
+}
+
+onMounted(() => {
+	loadSkills()
+})
+</script>
+
+<style scoped lang="less">
+.skills-panel {
+	display: flex;
+	flex-direction: column;
+	gap: 16px;
+}
+
+.toolbar {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 12px;
+	flex-wrap: wrap;
+}
+
+.search-input {
+	width: 320px;
+}
+
+.card-grid {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+	gap: 16px;
+	min-height: 180px;
+}
+
+.skill-card {
+	padding: 16px;
+	border-radius: 18px;
+	border: 1px solid #e5e7eb;
+	background: #fff;
+	box-shadow: 0 12px 24px rgba(15, 23, 42, 0.05);
+}
+
+.skill-card__top {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 12px;
+}
+
+.skill-card__title {
+	font-size: 18px;
+	font-weight: 700;
+	color: #111827;
+}
+
+.skill-card__desc {
+	margin-top: 10px;
+	font-size: 14px;
+	line-height: 1.6;
+	color: #6b7280;
+}
+
+.empty {
+	grid-column: 1 / -1;
+	padding: 24px 0;
+}
+
+@media (max-width: 768px) {
+	.search-input {
+		width: 100%;
+	}
+}
+</style>

+ 728 - 0
apps/web/src/views/resource/components/StorageManager.vue

@@ -0,0 +1,728 @@
+<template>
+	<div class="storage-manager" v-loading="pageLoading">
+		<el-alert
+			title="选择引擎查看配置,支持修改保存和连通测试。"
+			type="info"
+			:closable="false"
+			show-icon
+		/>
+
+		<div class="storage-actions">
+			<el-button type="primary" :loading="initLoading" @click="handleInit">初始化存储</el-button>
+			<el-button :loading="engineLoading" @click="loadEngines">刷新列表</el-button>
+		</div>
+
+		<div class="storage-layout">
+			<aside class="engine-list">
+				<div class="engine-list__header">引擎列表</div>
+				<div v-if="engines.length" class="engine-list__body">
+					<div
+						v-for="item in engines"
+						:key="item.name"
+						class="engine-card"
+						:class="{ 'engine-card--active': selectedProvider === item.name }"
+						@click="selectProvider(item.name)"
+					>
+						<div class="engine-card__top">
+							<div class="engine-card__name">{{ formatProviderLabel(item.name) }}</div>
+						</div>
+						<div class="engine-card__meta">
+							<el-tag size="small" :type="item.allowed ? 'success' : 'info'" effect="plain">
+								{{ item.allowed ? '允许' : '不允许' }}
+							</el-tag>
+							<el-tag size="small" :type="item.available ? 'primary' : 'warning'" effect="plain">
+								{{ item.available ? '可用' : '不可用' }}
+							</el-tag>
+						</div>
+						<div class="engine-card__desc">
+							{{ item.description || '点击加载配置信息' }}
+						</div>
+					</div>
+				</div>
+				<el-empty v-else description="暂无引擎" />
+			</aside>
+
+			<section class="config-panel">
+				<template v-if="selectedProvider">
+					<div class="config-panel__header">
+						<div>
+							<div class="config-panel__title">{{ formatProviderLabel(selectedProvider) }} 配置</div>
+							<div class="config-panel__desc">点击左侧引擎后加载配置信息,修改后保存。</div>
+						</div>
+					</div>
+
+					<el-form :model="form" label-position="top">
+						<template v-if="selectedProvider === 'local'">
+							<el-form-item label="存储前缀">
+								<el-input v-model="form.local.path_prefix" placeholder="例如 knowledge/files" />
+							</el-form-item>
+						</template>
+
+						<template v-else-if="selectedProvider === 'minio'">
+							<div class="form-grid">
+								<el-form-item label="Endpoint">
+									<el-input v-model="form.minio.endpoint" placeholder="127.0.0.1:9000" />
+								</el-form-item>
+								<el-form-item label="Mode">
+									<el-input v-model="form.minio.mode" placeholder="docker" />
+								</el-form-item>
+								<el-form-item label="Access Key ID">
+									<el-input v-model="form.minio.access_key_id" />
+								</el-form-item>
+								<el-form-item label="Secret Access Key">
+									<el-input v-model="form.minio.secret_access_key" show-password />
+								</el-form-item>
+								<el-form-item label="Bucket">
+									<el-input v-model="form.minio.bucket_name" />
+								</el-form-item>
+								<el-form-item label="Path Prefix">
+									<el-input v-model="form.minio.path_prefix" />
+								</el-form-item>
+							</div>
+							<el-form-item label="Use SSL">
+								<el-switch v-model="form.minio.use_ssl" />
+							</el-form-item>
+						</template>
+
+						<template v-else-if="selectedProvider === 'cos'">
+							<div class="form-grid">
+								<el-form-item label="App ID">
+									<el-input v-model="form.cos.app_id" />
+								</el-form-item>
+								<el-form-item label="Region">
+									<el-input v-model="form.cos.region" />
+								</el-form-item>
+								<el-form-item label="Secret ID">
+									<el-input v-model="form.cos.secret_id" />
+								</el-form-item>
+								<el-form-item label="Secret Key">
+									<el-input v-model="form.cos.secret_key" show-password />
+								</el-form-item>
+								<el-form-item label="Bucket">
+									<el-input v-model="form.cos.bucket_name" />
+								</el-form-item>
+								<el-form-item label="Path Prefix">
+									<el-input v-model="form.cos.path_prefix" />
+								</el-form-item>
+							</div>
+						</template>
+
+						<template v-else-if="selectedProvider === 'tos'">
+							<div class="form-grid">
+								<el-form-item label="Endpoint">
+									<el-input v-model="form.tos.endpoint" />
+								</el-form-item>
+								<el-form-item label="Region">
+									<el-input v-model="form.tos.region" />
+								</el-form-item>
+								<el-form-item label="Access Key">
+									<el-input v-model="form.tos.access_key" />
+								</el-form-item>
+								<el-form-item label="Secret Key">
+									<el-input v-model="form.tos.secret_key" show-password />
+								</el-form-item>
+								<el-form-item label="Bucket">
+									<el-input v-model="form.tos.bucket_name" />
+								</el-form-item>
+								<el-form-item label="Path Prefix">
+									<el-input v-model="form.tos.path_prefix" />
+								</el-form-item>
+							</div>
+						</template>
+
+						<template v-else-if="selectedProvider === 's3'">
+							<div class="form-grid">
+								<el-form-item label="Endpoint">
+									<el-input v-model="form.s3.endpoint" />
+								</el-form-item>
+								<el-form-item label="Region">
+									<el-input v-model="form.s3.region" />
+								</el-form-item>
+								<el-form-item label="Access Key">
+									<el-input v-model="form.s3.access_key" />
+								</el-form-item>
+								<el-form-item label="Secret Key">
+									<el-input v-model="form.s3.secret_key" show-password />
+								</el-form-item>
+								<el-form-item label="Bucket">
+									<el-input v-model="form.s3.bucket_name" />
+								</el-form-item>
+								<el-form-item label="Path Prefix">
+									<el-input v-model="form.s3.path_prefix" />
+								</el-form-item>
+							</div>
+						</template>
+
+						<template v-else-if="selectedProvider === 'oss'">
+							<div class="form-grid">
+								<el-form-item label="Endpoint">
+									<el-input v-model="form.oss.endpoint" />
+								</el-form-item>
+								<el-form-item label="Region">
+									<el-input v-model="form.oss.region" />
+								</el-form-item>
+								<el-form-item label="Access Key">
+									<el-input v-model="form.oss.access_key" />
+								</el-form-item>
+								<el-form-item label="Secret Key">
+									<el-input v-model="form.oss.secret_key" show-password />
+								</el-form-item>
+								<el-form-item label="Bucket">
+									<el-input v-model="form.oss.bucket_name" />
+								</el-form-item>
+								<el-form-item label="Path Prefix">
+									<el-input v-model="form.oss.path_prefix" />
+								</el-form-item>
+								<el-form-item label="Temp Bucket">
+									<el-input v-model="form.oss.temp_bucket_name" />
+								</el-form-item>
+								<el-form-item label="Temp Region">
+									<el-input v-model="form.oss.temp_region" />
+								</el-form-item>
+							</div>
+							<el-form-item label="Use Temp Bucket">
+								<el-switch v-model="form.oss.use_temp_bucket" />
+							</el-form-item>
+						</template>
+					</el-form>
+
+					<div class="config-panel__footer">
+						<el-button
+							link
+							type="primary"
+							:loading="testingProvider === selectedProvider"
+							@click="testProvider(selectedProvider)"
+						>
+							连通测试
+						</el-button>
+						<el-button @click="reloadSelectedProvider" :loading="providerLoading === selectedProvider">
+							重新加载
+						</el-button>
+						<el-button type="primary" :loading="saving" @click="saveConfig">保存配置</el-button>
+					</div>
+				</template>
+				<div v-else class="config-panel__empty">请先从左侧选择一个引擎。</div>
+			</section>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { storageProvider } from '@repo/api-service'
+
+type StorageProviderName = 'local' | 'minio' | 'cos' | 'tos' | 's3' | 'oss'
+
+interface EngineItem {
+	name: StorageProviderName
+	allowed: boolean
+	available: boolean
+	description: string
+}
+
+interface StorageFormState {
+	default_provider: StorageProviderName
+	local: { provider: 'local'; path_prefix: string }
+	minio: {
+		provider: 'minio'
+		access_key_id: string
+		bucket_name: string
+		endpoint: string
+		mode: string
+		path_prefix: string
+		secret_access_key: string
+		use_ssl: boolean
+	}
+	cos: {
+		provider: 'cos'
+		app_id: string
+		bucket_name: string
+		path_prefix: string
+		region: string
+		secret_id: string
+		secret_key: string
+	}
+	tos: {
+		provider: 'tos'
+		access_key: string
+		bucket_name: string
+		endpoint: string
+		path_prefix: string
+		region: string
+		secret_key: string
+	}
+	s3: {
+		provider: 's3'
+		access_key: string
+		bucket_name: string
+		endpoint: string
+		path_prefix: string
+		region: string
+		secret_key: string
+	}
+	oss: {
+		provider: 'oss'
+		access_key: string
+		bucket_name: string
+		endpoint: string
+		path_prefix: string
+		region: string
+		secret_key: string
+		temp_bucket_name: string
+		temp_region: string
+		use_temp_bucket: boolean
+	}
+}
+
+const providerNames: StorageProviderName[] = ['local', 'minio', 'cos', 'tos', 's3', 'oss']
+const pageLoading = ref(false)
+const engineLoading = ref(false)
+const initLoading = ref(false)
+const saving = ref(false)
+const selectedProvider = ref<StorageProviderName | ''>('')
+const providerLoading = ref<StorageProviderName | ''>('')
+const testingProvider = ref<StorageProviderName | ''>('')
+const engines = ref<EngineItem[]>([])
+const loadedProviders = ref<Record<StorageProviderName, boolean>>({
+	local: false,
+	minio: false,
+	cos: false,
+	tos: false,
+	s3: false,
+	oss: false
+})
+
+function createDefaultForm(): StorageFormState {
+	return {
+		default_provider: 'local',
+		local: { provider: 'local', path_prefix: '' },
+		minio: {
+			provider: 'minio',
+			access_key_id: '',
+			bucket_name: '',
+			endpoint: '',
+			mode: 'docker',
+			path_prefix: '',
+			secret_access_key: '',
+			use_ssl: false
+		},
+		cos: {
+			provider: 'cos',
+			app_id: '',
+			bucket_name: '',
+			path_prefix: '',
+			region: '',
+			secret_id: '',
+			secret_key: ''
+		},
+		tos: {
+			provider: 'tos',
+			access_key: '',
+			bucket_name: '',
+			endpoint: '',
+			path_prefix: '',
+			region: '',
+			secret_key: ''
+		},
+		s3: {
+			provider: 's3',
+			access_key: '',
+			bucket_name: '',
+			endpoint: '',
+			path_prefix: '',
+			region: '',
+			secret_key: ''
+		},
+		oss: {
+			provider: 'oss',
+			access_key: '',
+			bucket_name: '',
+			endpoint: '',
+			path_prefix: '',
+			region: '',
+			secret_key: '',
+			temp_bucket_name: '',
+			temp_region: '',
+			use_temp_bucket: false
+		}
+	}
+}
+
+const form = reactive<StorageFormState>(createDefaultForm())
+
+function formatProviderLabel(name: StorageProviderName) {
+	return name.toUpperCase()
+}
+
+function setLoaded(provider: StorageProviderName) {
+	loadedProviders.value[provider] = true
+}
+
+function assignConfig(provider: StorageProviderName, config: Record<string, any>) {
+	switch (provider) {
+		case 'local':
+			Object.assign(form.local, {
+				provider: 'local',
+				path_prefix: config.path_prefix ?? ''
+			})
+			break
+		case 'minio':
+			Object.assign(form.minio, {
+				provider: 'minio',
+				access_key_id: config.access_key_id ?? '',
+				bucket_name: config.bucket_name ?? '',
+				endpoint: config.endpoint ?? '',
+				mode: config.mode ?? 'docker',
+				path_prefix: config.path_prefix ?? '',
+				secret_access_key: config.secret_access_key ?? '',
+				use_ssl: Boolean(config.use_ssl)
+			})
+			break
+		case 'cos':
+			Object.assign(form.cos, {
+				provider: 'cos',
+				app_id: config.app_id ?? '',
+				bucket_name: config.bucket_name ?? '',
+				path_prefix: config.path_prefix ?? '',
+				region: config.region ?? '',
+				secret_id: config.secret_id ?? '',
+				secret_key: config.secret_key ?? ''
+			})
+			break
+		case 'tos':
+			Object.assign(form.tos, {
+				provider: 'tos',
+				access_key: config.access_key ?? '',
+				bucket_name: config.bucket_name ?? '',
+				endpoint: config.endpoint ?? '',
+				path_prefix: config.path_prefix ?? '',
+				region: config.region ?? '',
+				secret_key: config.secret_key ?? ''
+			})
+			break
+		case 's3':
+			Object.assign(form.s3, {
+				provider: 's3',
+				access_key: config.access_key ?? '',
+				bucket_name: config.bucket_name ?? '',
+				endpoint: config.endpoint ?? '',
+				path_prefix: config.path_prefix ?? '',
+				region: config.region ?? '',
+				secret_key: config.secret_key ?? ''
+			})
+			break
+		case 'oss':
+			Object.assign(form.oss, {
+				provider: 'oss',
+				access_key: config.access_key ?? '',
+				bucket_name: config.bucket_name ?? '',
+				endpoint: config.endpoint ?? '',
+				path_prefix: config.path_prefix ?? '',
+				region: config.region ?? '',
+				secret_key: config.secret_key ?? '',
+				temp_bucket_name: config.temp_bucket_name ?? '',
+				temp_region: config.temp_region ?? '',
+				use_temp_bucket: Boolean(config.use_temp_bucket)
+			})
+			break
+	}
+	setLoaded(provider)
+}
+
+function extractConfig(result: Record<string, any>) {
+	return result?.config && typeof result.config === 'object' ? result.config : result
+}
+
+async function loadEngines() {
+	engineLoading.value = true
+	try {
+		const res = await storageProvider.postStorageProviderEngines({})
+		if (res?.isSuccess) {
+			engines.value = (res.result || [])
+				.filter((item): item is EngineItem => providerNames.includes(item.name as StorageProviderName))
+				.map((item) => ({
+					name: item.name as StorageProviderName,
+					allowed: Boolean(item.allowed),
+					available: Boolean(item.available),
+					description: item.description || ''
+				}))
+		}
+		if (!selectedProvider.value && engines.value[0]) {
+			await selectProvider(engines.value[0]!.name)
+		}
+	} catch {
+		ElMessage.error('加载引擎失败')
+	} finally {
+		engineLoading.value = false
+	}
+}
+
+async function handleInit() {
+	initLoading.value = true
+	try {
+		const res = await storageProvider.postStorageProviderInitStorageProvider({})
+		if (res?.isSuccess) {
+			ElMessage.success('初始化成功')
+			await loadEngines()
+			return
+		}
+		ElMessage.error('初始化失败')
+	} catch {
+		ElMessage.error('初始化失败')
+	} finally {
+		initLoading.value = false
+	}
+}
+
+async function loadProviderConfig(provider: StorageProviderName) {
+	providerLoading.value = provider
+	try {
+		const res = await storageProvider.postStorageProviderConfig({ name: provider })
+		if (res?.isSuccess && res.result) {
+			const raw = res.result as Record<string, any>
+			assignConfig(provider, extractConfig(raw))
+			if (raw.is_default === true || raw.isDefault === true) {
+				form.default_provider = provider
+			}
+			selectedProvider.value = provider
+		}
+	} catch {
+		ElMessage.error('加载配置失败')
+	} finally {
+		providerLoading.value = ''
+	}
+}
+
+async function selectProvider(provider: StorageProviderName) {
+	selectedProvider.value = provider
+	await loadProviderConfig(provider)
+}
+
+function buildCheckPayload(provider: StorageProviderName) {
+	switch (provider) {
+		case 'local':
+			return { name: provider, config: { ...form.local } }
+		case 'minio':
+			return { name: provider, config: { ...form.minio } }
+		case 'cos':
+			return { name: provider, config: { ...form.cos } }
+		case 'tos':
+			return { name: provider, config: { ...form.tos } }
+		case 's3':
+			return { name: provider, config: { ...form.s3 } }
+		case 'oss':
+			return { name: provider, config: { ...form.oss } }
+	}
+}
+
+async function testProvider(provider: StorageProviderName) {
+	testingProvider.value = provider
+	try {
+		if (selectedProvider.value !== provider || !loadedProviders.value[provider]) {
+			await loadProviderConfig(provider)
+		}
+		const res = await storageProvider.postStorageProviderCheck(buildCheckPayload(provider) as any)
+		if (res?.isSuccess) {
+			ElMessage.success(`${formatProviderLabel(provider)} 连通成功`)
+			return
+		}
+		ElMessage.error(res?.error || `${formatProviderLabel(provider)} 连通失败`)
+	} catch {
+		ElMessage.error(`${formatProviderLabel(provider)} 连通失败`)
+	} finally {
+		testingProvider.value = ''
+	}
+}
+
+function buildUpdatePayload() {
+	return {
+		default_provider: form.default_provider,
+		cos: { ...form.cos },
+		local: { ...form.local },
+		minio: { ...form.minio },
+		oss: { ...form.oss },
+		s3: { ...form.s3 },
+		tos: { ...form.tos }
+	}
+}
+
+async function saveConfig() {
+	if (!selectedProvider.value) {
+		ElMessage.warning('请先选择一个引擎')
+		return
+	}
+	saving.value = true
+	try {
+		const res = await storageProvider.postStorageProviderUpdate(buildUpdatePayload() as any)
+		if (res?.isSuccess) {
+			ElMessage.success('配置已保存')
+			await loadEngines()
+			return
+		}
+		ElMessage.error('保存失败')
+	} catch {
+		ElMessage.error('保存失败')
+	} finally {
+		saving.value = false
+	}
+}
+
+async function reloadSelectedProvider() {
+	if (!selectedProvider.value) return
+	await loadProviderConfig(selectedProvider.value)
+}
+
+onMounted(async () => {
+	pageLoading.value = true
+	try {
+		await loadEngines()
+	} finally {
+		pageLoading.value = false
+	}
+})
+</script>
+
+<style scoped lang="less">
+.storage-manager {
+	display: flex;
+	flex-direction: column;
+	gap: 16px;
+	min-height: calc(100vh - 210px);
+}
+
+.storage-actions {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	flex-wrap: wrap;
+}
+
+.storage-layout {
+	display: grid;
+	grid-template-columns: 280px minmax(0, 1fr);
+	gap: 16px;
+	flex: 1;
+	min-height: 0;
+}
+
+.engine-list {
+	border: 1px solid #e5e7eb;
+	border-radius: 12px;
+	background: #fff;
+	padding: 12px;
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+	min-height: 0;
+	overflow: hidden;
+}
+
+.engine-list__header {
+	font-size: 14px;
+	font-weight: 700;
+	color: #111827;
+}
+
+.engine-list__body {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+	flex: 1;
+	min-height: 0;
+	overflow-y: auto;
+	padding-right: 4px;
+}
+
+.engine-card {
+	padding: 12px;
+	border: 1px solid #e5e7eb;
+	border-radius: 12px;
+	background: #f8fafc;
+	cursor: pointer;
+	transition: all 0.2s ease;
+}
+
+.engine-card:hover,
+.engine-card--active {
+	border-color: #3b82f6;
+	background: #eff6ff;
+}
+
+.engine-card__top {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 8px;
+	margin-bottom: 8px;
+}
+
+.engine-card__name {
+	font-weight: 700;
+	color: #111827;
+}
+
+.engine-card__meta {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 6px;
+	margin-bottom: 8px;
+}
+
+.engine-card__desc {
+	font-size: 12px;
+	line-height: 1.5;
+	color: #6b7280;
+}
+
+.config-panel {
+	border: 1px solid #e5e7eb;
+	border-radius: 12px;
+	background: #fff;
+	padding: 16px;
+	display: flex;
+	flex-direction: column;
+	gap: 16px;
+	min-height: 0;
+	overflow: auto;
+}
+
+.config-panel__title {
+	font-size: 16px;
+	font-weight: 700;
+	color: #111827;
+}
+
+.config-panel__desc {
+	margin-top: 6px;
+	font-size: 13px;
+	color: #6b7280;
+}
+
+.config-panel__footer {
+	display: flex;
+	justify-content: flex-end;
+	gap: 8px;
+}
+
+.config-panel__empty {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	min-height: 320px;
+	color: #9ca3af;
+}
+
+.form-grid {
+	display: grid;
+	grid-template-columns: repeat(2, minmax(0, 1fr));
+	gap: 0 12px;
+}
+
+@media (max-width: 960px) {
+	.storage-layout,
+	.form-grid {
+		grid-template-columns: minmax(0, 1fr);
+	}
+}
+</style>

+ 6 - 2
apps/web/src/views/resource/components/WebSearchPanel.vue

@@ -268,8 +268,12 @@ async function openEditById(id: string) {
 
 
 async function checkItem(id: string) {
 async function checkItem(id: string) {
 	try {
 	try {
-		await resource.postWebSearchCheckById({ id })
-		ElMessage.success('连通测试成功')
+		const res = await resource.postWebSearchCheckById({ id })
+		if (res.isSuccess) {
+			ElMessage.success('连通测试成功')
+		} else {
+			ElMessage.error(res?.error || '连通测试失败')
+		}
 	} catch {
 	} catch {
 		ElMessage.error('连通测试失败')
 		ElMessage.error('连通测试失败')
 	}
 	}

+ 2 - 76
apps/web/src/views/resource/index.vue

@@ -1,81 +1,7 @@
 <template>
 <template>
-	<div class="resource-page">
-		<div class="page-head">
-			<div class="page-title">
-				<h1>资源中心</h1>
-				<p>统一管理提示词模板、网络搜索提供者与 MCP 服务。</p>
-			</div>
-		</div>
-
-		<el-card class="resource-panel">
-			<el-tabs v-model="activeTab" class="resource-tabs">
-				<el-tab-pane label="提示词" name="prompt">
-					<PromptTemplatePanel />
-				</el-tab-pane>
-				<el-tab-pane label="网络搜索" name="web-search">
-					<WebSearchPanel />
-				</el-tab-pane>
-				<el-tab-pane label="MCP 服务" name="mcp">
-					<McpPanel />
-				</el-tab-pane>
-			</el-tabs>
-		</el-card>
-	</div>
+	<PromptPage />
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import { ref } from 'vue'
-import PromptTemplatePanel from './components/PromptTemplatePanel.vue'
-import WebSearchPanel from './components/WebSearchPanel.vue'
-import McpPanel from './components/McpPanel.vue'
-
-const activeTab = ref<'prompt' | 'web-search' | 'mcp'>('prompt')
+import PromptPage from './PromptPage.vue'
 </script>
 </script>
-
-<style scoped lang="less">
-.resource-page {
-	padding: 24px;
-}
-
-.page-head {
-	display: flex;
-	align-items: flex-start;
-	justify-content: space-between;
-	gap: 16px;
-	margin-bottom: 18px;
-}
-
-.eyebrow {
-	display: inline-flex;
-	padding: 6px 10px;
-	border-radius: 999px;
-	background: #f3f4f6;
-	font-size: 12px;
-	font-weight: 700;
-	color: #4b5563;
-}
-
-.page-head h1 {
-	margin: 8px 0 6px;
-	font-size: 28px;
-}
-
-.page-head p {
-	margin: 0;
-	color: #6b7280;
-}
-
-.resource-panel {
-	border-radius: 18px;
-}
-
-.resource-tabs :deep(.el-tabs__header) {
-	margin-bottom: 20px;
-}
-
-@media (max-width: 768px) {
-	.resource-page {
-		padding: 16px;
-	}
-}
-</style>

+ 1 - 1
packages/api-client/index.ts

@@ -1,3 +1,3 @@
-import { request } from './src/hook-fetch-client'
+import { request } from './src/request'
 
 
 export default request
 export default request

+ 25 - 0
pnpm-lock.yaml

@@ -202,6 +202,9 @@ importers:
       vue-router:
       vue-router:
         specifier: '4'
         specifier: '4'
         version: 4.6.4(vue@3.5.30(typescript@5.9.3))
         version: 4.6.4(vue@3.5.30(typescript@5.9.3))
+      vue3-emoji-picker:
+        specifier: ^1.1.8
+        version: 1.1.8(typescript@5.9.3)
     devDependencies:
     devDependencies:
       '@repo/api-client':
       '@repo/api-client':
         specifier: workspace:*
         specifier: workspace:*
@@ -2341,6 +2344,9 @@ packages:
   '@polka/url@1.0.0-next.29':
   '@polka/url@1.0.0-next.29':
     resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
     resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
 
 
+  '@popperjs/core@2.11.8':
+    resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
+
   '@preact/signals-core@1.14.0':
   '@preact/signals-core@1.14.0':
     resolution: {integrity: sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ==}
     resolution: {integrity: sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ==}
 
 
@@ -5054,6 +5060,9 @@ packages:
     resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
     resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
     engines: {node: '>=0.10.0'}
     engines: {node: '>=0.10.0'}
 
 
+  idb@7.1.1:
+    resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
+
   ignore@5.3.2:
   ignore@5.3.2:
     resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
     resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
     engines: {node: '>= 4'}
     engines: {node: '>= 4'}
@@ -7507,6 +7516,10 @@ packages:
     peerDependencies:
     peerDependencies:
       vue: '>=3.2'
       vue: '>=3.2'
 
 
+  vue3-emoji-picker@1.1.8:
+    resolution: {integrity: sha512-k9tVHeQEBVLzVCLYAkFaI1nib3FJFQwdPhWD5khJkhks3ktg3g12z5wPGOSDpIuSLNtelRGvq1qdmZuJu5khfA==}
+    engines: {node: '>=16.0.0'}
+
   vue@3.5.30:
   vue@3.5.30:
     resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==}
     resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==}
     peerDependencies:
     peerDependencies:
@@ -10375,6 +10388,8 @@ snapshots:
 
 
   '@polka/url@1.0.0-next.29': {}
   '@polka/url@1.0.0-next.29': {}
 
 
+  '@popperjs/core@2.11.8': {}
+
   '@preact/signals-core@1.14.0': {}
   '@preact/signals-core@1.14.0': {}
 
 
   '@quansync/fs@1.0.0':
   '@quansync/fs@1.0.0':
@@ -13659,6 +13674,8 @@ snapshots:
     dependencies:
     dependencies:
       safer-buffer: 2.1.2
       safer-buffer: 2.1.2
 
 
+  idb@7.1.1: {}
+
   ignore@5.3.2: {}
   ignore@5.3.2: {}
 
 
   ignore@7.0.5: {}
   ignore@7.0.5: {}
@@ -16738,6 +16755,14 @@ snapshots:
       - '@rspack/core'
       - '@rspack/core'
       - vite
       - vite
 
 
+  vue3-emoji-picker@1.1.8(typescript@5.9.3):
+    dependencies:
+      '@popperjs/core': 2.11.8
+      idb: 7.1.1
+      vue: 3.5.30(typescript@5.9.3)
+    transitivePeerDependencies:
+      - typescript
+
   vue@3.5.30(typescript@5.9.2):
   vue@3.5.30(typescript@5.9.2):
     dependencies:
     dependencies:
       '@vue/compiler-dom': 3.5.30
       '@vue/compiler-dom': 3.5.30