feat(frontend): 完成Skill与Plugin管理页面
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { usePluginStore } from '@/stores/plugin'
|
||||
import { useSkillStore } from '@/stores/skill'
|
||||
|
||||
const route = useRoute()
|
||||
const skillStore = useSkillStore()
|
||||
const pluginStore = usePluginStore()
|
||||
const isPlugin = computed(() => route.name === 'plugins')
|
||||
|
||||
onMounted(() => { if (isPlugin.value) void pluginStore.loadPlugins(); else void skillStore.loadSkills() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar-panel">
|
||||
<div v-if="isPlugin" class="sidebar-list">
|
||||
<button v-for="plugin in pluginStore.plugins" :key="plugin.plugin_id" class="sidebar-list-item extension-item"
|
||||
:class="{ active: pluginStore.selectedPluginId === plugin.plugin_id }" @click="pluginStore.selectPlugin(plugin.plugin_id)">
|
||||
<span>{{ plugin.icon || '🧩' }}</span><span><strong>{{ plugin.name }}</strong><small>{{ plugin.status }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="sidebar-list">
|
||||
<button v-for="skill in skillStore.skills" :key="skill.skill_id" class="sidebar-list-item extension-item"
|
||||
:class="{ active: skillStore.selectedSkillId === skill.skill_id }" @click="skillStore.selectSkill(skill.skill_id)">
|
||||
<span>{{ skill.icon || '⚡' }}</span><span><strong>{{ skill.name }}</strong><small>{{ skill.status }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.extension-item { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: var(--space-sm); width: 100%; text-align: left; }
|
||||
.extension-item strong, .extension-item small { display: block; }
|
||||
.extension-item small { color: var(--color-text-tertiary); }
|
||||
</style>
|
||||
@@ -246,6 +246,7 @@ export interface Plugin {
|
||||
status: PluginStatus
|
||||
enabled: boolean
|
||||
permissions: string[]
|
||||
granted_permissions?: string[]
|
||||
contributions: PluginContribution[]
|
||||
backend_type?: 'mcp' | 'internal_rpc' | 'none'
|
||||
transport?: 'stdio' | 'http' | 'none'
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { usePluginStore } from '@/stores/plugin'
|
||||
|
||||
const pluginStore = usePluginStore()
|
||||
const actionError = ref('')
|
||||
onMounted(() => { void pluginStore.loadPlugins() })
|
||||
|
||||
async function install() { const path = prompt('请输入 Plugin Package 路径')?.trim(); if (!path) return; try { await pluginStore.installPlugin(path) } catch (error) { actionError.value = error instanceof Error ? error.message : '安装失败' } }
|
||||
async function toggle(id: string, enabled: boolean) { try { enabled ? await pluginStore.disablePlugin(id) : await pluginStore.enablePlugin(id) } catch (error) { actionError.value = error instanceof Error ? error.message : '状态更新失败' } }
|
||||
async function grant(id: string, permissions: string[]) { if (!confirm(`将授权:${permissions.join('、')}。是否继续?`)) return; try { await pluginStore.grantPermissions(id, permissions) } catch (error) { actionError.value = error instanceof Error ? error.message : '授权失败' } }
|
||||
async function uninstall(id: string, name: string) { if (!confirm(`卸载“${name}”将移除其全部 Contribution,是否继续?`)) return; try { await pluginStore.uninstallPlugin(id) } catch (error) { actionError.value = error instanceof Error ? error.message : '卸载失败' } }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="feature-page">
|
||||
<header class="feature-header"><div><h1>Plugin 管理</h1><p>管理插件生命周期、权限和受控 Contribution。</p></div><button class="button-primary" @click="install">安装 Plugin</button></header>
|
||||
<div v-if="pluginStore.error || actionError" class="error-banner">{{ pluginStore.error || actionError }}</div>
|
||||
<div v-if="pluginStore.selectedPlugin" class="panel">
|
||||
<div class="detail-head"><div><span class="badge" :class="{ success: pluginStore.selectedPlugin.status === 'ready', error: pluginStore.selectedPlugin.status === 'error', warning: pluginStore.selectedPlugin.status === 'permission_required' }">{{ pluginStore.selectedPlugin.status }}</span><h2>{{ pluginStore.selectedPlugin.icon }} {{ pluginStore.selectedPlugin.name }}</h2><p class="muted">v{{ pluginStore.selectedPlugin.version }} · {{ pluginStore.selectedPlugin.backend_type || 'none' }}/{{ pluginStore.selectedPlugin.transport || 'none' }}</p></div><div class="inline-actions"><button v-if="pluginStore.selectedPlugin.status === 'permission_required'" class="button-primary" @click="grant(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.permissions)">授权权限</button><button class="button-secondary" @click="toggle(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.enabled)">{{ pluginStore.selectedPlugin.enabled ? '停用' : '启用' }}</button><button class="button-danger" @click="uninstall(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.name)">卸载</button></div></div>
|
||||
<p class="description">{{ pluginStore.selectedPlugin.description }}</p>
|
||||
<div class="detail-grid"><div><h3>权限</h3><div class="tag-list"><span v-for="permission in pluginStore.selectedPlugin.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>Contribution</h3><div class="contribution-list"><div v-for="item in pluginStore.selectedPlugin.contributions" :key="item.id" class="item-card"><span class="badge info">{{ item.type }}</span><strong>{{ item.name }}</strong><p class="subtle">{{ item.description || item.id }}</p></div></div></div></div>
|
||||
<div v-if="pluginStore.selectedPlugin.last_error" class="error-banner last-error">{{ pluginStore.selectedPlugin.last_error }}</div>
|
||||
<div v-if="pluginStore.selectedPlugin.dependent_skills?.length" class="notice-banner last-error">依赖此插件的 Skill:{{ pluginStore.selectedPlugin.dependent_skills.join('、') }}</div>
|
||||
</div>
|
||||
<div v-else class="feature-grid"><article v-for="plugin in pluginStore.plugins" :key="plugin.plugin_id" class="item-card extension-card" @click="pluginStore.selectPlugin(plugin.plugin_id)"><div class="extension-title"><span class="icon">{{ plugin.icon || '🧩' }}</span><div><strong>{{ plugin.name }}</strong><p>v{{ plugin.version }}</p></div><span class="badge" :class="{ success: plugin.status === 'ready', error: plugin.status === 'error', warning: plugin.status === 'permission_required' }">{{ plugin.status }}</span></div><p class="muted">{{ plugin.description }}</p><p class="subtle">{{ plugin.permissions.length }} 项权限 · {{ plugin.contributions.length }} 项 Contribution</p></article></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-head, .extension-title { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-md); }
|
||||
.detail-head h2 { margin-top: var(--space-sm); }
|
||||
.description { margin: var(--space-xl) 0; line-height: var(--line-height-relaxed); }
|
||||
.detail-grid { display: grid; grid-template-columns: minmax(220px, .7fr) minmax(320px, 1.3fr); gap: var(--space-xl); }
|
||||
.detail-grid h3 { margin-bottom: var(--space-sm); }
|
||||
.contribution-list { display: grid; gap: var(--space-sm); }
|
||||
.contribution-list .item-card { display: grid; gap: var(--space-xs); }
|
||||
.last-error { margin: var(--space-xl) 0 0; }
|
||||
.extension-card { cursor: pointer; }
|
||||
.extension-card > p { margin-top: var(--space-md); }
|
||||
.extension-title { align-items: center; }
|
||||
.extension-title .icon { font-size: 28px; }
|
||||
.extension-title p { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||
@media (max-width: 800px) { .detail-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useSkillStore } from '@/stores/skill'
|
||||
|
||||
const skillStore = useSkillStore()
|
||||
const actionError = ref('')
|
||||
onMounted(() => { void skillStore.loadSkills() })
|
||||
|
||||
async function install() {
|
||||
const path = prompt('请输入 Skill Package 路径')?.trim()
|
||||
if (!path) return
|
||||
try { await skillStore.installSkill(path) } catch (error) { actionError.value = error instanceof Error ? error.message : '安装失败' }
|
||||
}
|
||||
async function toggle(skillId: string, enabled: boolean) {
|
||||
try { enabled ? await skillStore.disableSkill(skillId) : await skillStore.enableSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : '状态更新失败' }
|
||||
}
|
||||
async function uninstall(skillId: string, name: string) {
|
||||
if (!confirm(`确定卸载 Skill“${name}”吗?`)) return
|
||||
try { await skillStore.uninstallSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : '卸载失败' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="feature-page">
|
||||
<header class="feature-header"><div><h1>Skill 管理</h1><p>查看工作流使用的 Tool、权限、检索配置和模型要求。</p></div><button class="button-primary" @click="install">安装 Skill</button></header>
|
||||
<div v-if="skillStore.error || actionError" class="error-banner">{{ skillStore.error || actionError }}</div>
|
||||
<div v-if="skillStore.selectedSkill" class="panel detail-panel">
|
||||
<div class="detail-head"><div><span class="badge" :class="{ success: skillStore.selectedSkill.status === 'ready', error: skillStore.selectedSkill.status === 'error', warning: skillStore.selectedSkill.status.includes('missing') }">{{ skillStore.selectedSkill.status }}</span><h2>{{ skillStore.selectedSkill.icon }} {{ skillStore.selectedSkill.name }}</h2><p class="muted">v{{ skillStore.selectedSkill.version }} · {{ skillStore.selectedSkill.author || '未知作者' }}</p></div><div class="inline-actions"><button class="button-secondary" @click="toggle(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.enabled)">{{ skillStore.selectedSkill.enabled ? '停用' : '启用' }}</button><button class="button-danger" @click="uninstall(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.name)">卸载</button></div></div>
|
||||
<p class="description">{{ skillStore.selectedSkill.description }}</p>
|
||||
<div class="detail-grid"><div><h3>工具</h3><div class="tag-list"><span v-for="tool in skillStore.selectedSkill.tools" :key="tool" class="badge info">{{ tool }}</span></div></div><div><h3>权限</h3><div class="tag-list"><span v-for="permission in skillStore.selectedSkill.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>检索配置</h3><pre>{{ JSON.stringify(skillStore.selectedSkill.retrieval_config, null, 2) }}</pre></div><div><h3>模型能力</h3><div class="tag-list"><span v-for="cap in skillStore.selectedSkill.model_requirements?.capabilities" :key="cap" class="badge">{{ cap }}</span></div></div></div>
|
||||
<div v-if="skillStore.selectedSkill.missing_dependencies?.length" class="error-banner dependencies">缺失依赖:{{ skillStore.selectedSkill.missing_dependencies.join('、') }}</div>
|
||||
</div>
|
||||
<div v-else class="feature-grid"><article v-for="skill in skillStore.skills" :key="skill.skill_id" class="item-card extension-card" @click="skillStore.selectSkill(skill.skill_id)"><div class="extension-title"><span class="icon">{{ skill.icon || '⚡' }}</span><div><strong>{{ skill.name }}</strong><p>v{{ skill.version }}</p></div><span class="badge" :class="{ success: skill.status === 'ready', warning: skill.status === 'dependency_missing' }">{{ skill.status }}</span></div><p class="muted">{{ skill.description }}</p><div class="tag-list"><span v-for="permission in skill.permissions.slice(0, 3)" :key="permission" class="badge">{{ permission }}</span></div></article></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-head, .extension-title { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-md); }
|
||||
.detail-head h2 { margin-top: var(--space-sm); }
|
||||
.description { margin: var(--space-xl) 0; line-height: var(--line-height-relaxed); }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-xl); }
|
||||
.detail-grid h3 { margin-bottom: var(--space-sm); }
|
||||
pre { padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-background-secondary); }
|
||||
.dependencies { margin: var(--space-xl) 0 0; }
|
||||
.extension-card { cursor: pointer; }
|
||||
.extension-card > p { margin: var(--space-md) 0; }
|
||||
.extension-title { align-items: center; }
|
||||
.extension-title .icon { font-size: 28px; }
|
||||
.extension-title p { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
|
||||
</style>
|
||||
@@ -20,7 +20,8 @@ function toPlugin(plugin: ApiPlugin): Plugin {
|
||||
description: manifest.description,
|
||||
status: plugin.status,
|
||||
enabled: plugin.enabled,
|
||||
permissions: plugin.granted_permissions,
|
||||
permissions: manifest.permissions,
|
||||
granted_permissions: plugin.granted_permissions,
|
||||
contributions,
|
||||
backend_type: manifest.backend.type,
|
||||
transport: manifest.backend.transport,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Plugin } from '@/contracts'
|
||||
import { mockPlugins } from '@/services/pluginService'
|
||||
import * as pluginService from '@/services/pluginService'
|
||||
|
||||
export const usePluginStore = defineStore('plugin', () => {
|
||||
const plugins = ref<Plugin[]>(mockPlugins)
|
||||
const plugins = ref<Plugin[]>(pluginService.mockPlugins)
|
||||
const selectedPluginId = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const selectedPlugin = computed(() =>
|
||||
plugins.value.find((p) => p.plugin_id === selectedPluginId.value) || null
|
||||
@@ -19,8 +20,10 @@ export const usePluginStore = defineStore('plugin', () => {
|
||||
async function loadPlugins() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const { listPlugins } = await import('@/services/pluginService')
|
||||
plugins.value = await listPlugins()
|
||||
plugins.value = await pluginService.listPlugins()
|
||||
error.value = null
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : 'Plugin 加载失败'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -30,23 +33,34 @@ export const usePluginStore = defineStore('plugin', () => {
|
||||
selectedPluginId.value = pluginId
|
||||
}
|
||||
|
||||
async function enablePlugin(pluginId: string) {
|
||||
const plugin = plugins.value.find((p) => p.plugin_id === pluginId)
|
||||
if (plugin) {
|
||||
plugin.enabled = true
|
||||
plugin.status = 'ready'
|
||||
async function installPlugin(packagePath: string) {
|
||||
const installed = await pluginService.installPlugin(packagePath)
|
||||
const index = plugins.value.findIndex((plugin) => plugin.plugin_id === installed.plugin_id)
|
||||
if (index >= 0) plugins.value[index] = installed
|
||||
else plugins.value.unshift(installed)
|
||||
selectedPluginId.value = installed.plugin_id
|
||||
}
|
||||
|
||||
async function grantPermissions(pluginId: string, permissions: string[]) {
|
||||
const updated = await pluginService.grantPluginPermissions(pluginId, permissions)
|
||||
const index = plugins.value.findIndex((plugin) => plugin.plugin_id === pluginId)
|
||||
if (index >= 0) plugins.value[index] = updated
|
||||
}
|
||||
|
||||
async function enablePlugin(pluginId: string) {
|
||||
const updated = await pluginService.enablePlugin(pluginId)
|
||||
const index = plugins.value.findIndex((plugin) => plugin.plugin_id === pluginId)
|
||||
if (index >= 0) plugins.value[index] = updated
|
||||
}
|
||||
|
||||
async function disablePlugin(pluginId: string) {
|
||||
const plugin = plugins.value.find((p) => p.plugin_id === pluginId)
|
||||
if (plugin) {
|
||||
plugin.enabled = false
|
||||
plugin.status = 'disabled'
|
||||
}
|
||||
const updated = await pluginService.disablePlugin(pluginId)
|
||||
const index = plugins.value.findIndex((plugin) => plugin.plugin_id === pluginId)
|
||||
if (index >= 0) plugins.value[index] = updated
|
||||
}
|
||||
|
||||
async function uninstallPlugin(pluginId: string) {
|
||||
await pluginService.uninstallPlugin(pluginId)
|
||||
const idx = plugins.value.findIndex((p) => p.plugin_id === pluginId)
|
||||
if (idx > -1) plugins.value.splice(idx, 1)
|
||||
if (selectedPluginId.value === pluginId) selectedPluginId.value = null
|
||||
@@ -60,8 +74,11 @@ export const usePluginStore = defineStore('plugin', () => {
|
||||
readyPlugins,
|
||||
errorPlugins,
|
||||
isLoading,
|
||||
error,
|
||||
loadPlugins,
|
||||
selectPlugin,
|
||||
installPlugin,
|
||||
grantPermissions,
|
||||
enablePlugin,
|
||||
disablePlugin,
|
||||
uninstallPlugin,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Skill } from '@/contracts'
|
||||
import { mockSkills } from '@/services/skillService'
|
||||
import * as skillService from '@/services/skillService'
|
||||
|
||||
export const useSkillStore = defineStore('skill', () => {
|
||||
const skills = ref<Skill[]>(mockSkills)
|
||||
const skills = ref<Skill[]>(skillService.mockSkills)
|
||||
const selectedSkillId = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const selectedSkill = computed(() =>
|
||||
skills.value.find((s) => s.skill_id === selectedSkillId.value) || null
|
||||
@@ -19,8 +20,10 @@ export const useSkillStore = defineStore('skill', () => {
|
||||
async function loadSkills() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const { listSkills } = await import('@/services/skillService')
|
||||
skills.value = await listSkills()
|
||||
skills.value = await skillService.listSkills()
|
||||
error.value = null
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : 'Skill 加载失败'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -30,23 +33,28 @@ export const useSkillStore = defineStore('skill', () => {
|
||||
selectedSkillId.value = skillId
|
||||
}
|
||||
|
||||
async function enableSkill(skillId: string) {
|
||||
const skill = skills.value.find((s) => s.skill_id === skillId)
|
||||
if (skill) {
|
||||
skill.enabled = true
|
||||
skill.status = 'ready'
|
||||
async function installSkill(packagePath: string) {
|
||||
const installed = await skillService.installSkill(packagePath)
|
||||
const index = skills.value.findIndex((skill) => skill.skill_id === installed.skill_id)
|
||||
if (index >= 0) skills.value[index] = installed
|
||||
else skills.value.unshift(installed)
|
||||
selectedSkillId.value = installed.skill_id
|
||||
}
|
||||
|
||||
async function enableSkill(skillId: string) {
|
||||
const updated = await skillService.enableSkill(skillId)
|
||||
const index = skills.value.findIndex((skill) => skill.skill_id === skillId)
|
||||
if (index >= 0) skills.value[index] = updated
|
||||
}
|
||||
|
||||
async function disableSkill(skillId: string) {
|
||||
const skill = skills.value.find((s) => s.skill_id === skillId)
|
||||
if (skill) {
|
||||
skill.enabled = false
|
||||
skill.status = 'disabled'
|
||||
}
|
||||
const updated = await skillService.disableSkill(skillId)
|
||||
const index = skills.value.findIndex((skill) => skill.skill_id === skillId)
|
||||
if (index >= 0) skills.value[index] = updated
|
||||
}
|
||||
|
||||
async function uninstallSkill(skillId: string) {
|
||||
await skillService.uninstallSkill(skillId)
|
||||
const idx = skills.value.findIndex((s) => s.skill_id === skillId)
|
||||
if (idx > -1) skills.value.splice(idx, 1)
|
||||
if (selectedSkillId.value === skillId) selectedSkillId.value = null
|
||||
@@ -60,8 +68,10 @@ export const useSkillStore = defineStore('skill', () => {
|
||||
installedSkills,
|
||||
readySkills,
|
||||
isLoading,
|
||||
error,
|
||||
loadSkills,
|
||||
selectSkill,
|
||||
installSkill,
|
||||
enableSkill,
|
||||
disableSkill,
|
||||
uninstallSkill,
|
||||
|
||||
Reference in New Issue
Block a user