feat(extensions): add ZIP installation and unify action dialogs

This commit is contained in:
2026-09-06 00:52:59 +08:00
parent ba66b182af
commit 99a92e9eb1
29 changed files with 554 additions and 50 deletions
+16
View File
@@ -187,3 +187,19 @@ css_entry: styles/theme.css
模型设置页将提供商、本地模型、用量统计分成独立卡片。用量趋势支持近 7 天、30 天、90 天及自定义时间,沿用提供商/模型/来源筛选;按本机 UTC 偏移分组(长区间自动合并到最多 90 组)。可切换输入、输出、总 Token 和请求次数,本地为芯片实色图例,提供商为连接斜纹图例。仅汇总已报告值,并提供覆盖数与可展开的数据表,缺失不补零。 模型设置页将提供商、本地模型、用量统计分成独立卡片。用量趋势支持近 7 天、30 天、90 天及自定义时间,沿用提供商/模型/来源筛选;按本机 UTC 偏移分组(长区间自动合并到最多 90 组)。可切换输入、输出、总 Token 和请求次数,本地为芯片实色图例,提供商为连接斜纹图例。仅汇总已报告值,并提供覆盖数与可展开的数据表,缺失不补零。
纸间时光更新至 1.5.0,通用卡片、执行事件、引用、模型路由及弹窗统一使用纸张、虚线、胶带和叠纸阴影。已安装旧版本时,在主题社区点击“更新”应用新版样式。 纸间时光更新至 1.5.0,通用卡片、执行事件、引用、模型路由及弹窗统一使用纸张、虚线、胶带和叠纸阴影。已安装旧版本时,在主题社区点击“更新”应用新版样式。
## Skill / Plugin ZIP 安装(临时规范)
安装弹窗支持 ZIP 文件和 AI Core 主机上的本地目录。ZIP 根目录须包含 `skill.yaml``plugin.yaml`;也支持整个包放在唯一的顶层文件夹中。每个 ZIP 安装一个扩展,清单字段沿用现有 Skill / Plugin 契约。
```text
my-skill.zip my-plugin.zip
└─ my-skill/ ├─ plugin.yaml
├─ skill.yaml ├─ 后端入口及资源文件
└─ prompt.md(可选) └─ 其他包内资源
```
ZIP 最大 10 MiB,解压总大小最大 50 MiB,最多 2048 个条目;支持 stored/deflate。拒绝加密条目、符号链接、特殊文件、越界路径以及重复或大小写冲突路径。选择文件后点击安装才上传;后端解压并沿用现有清单、依赖及权限校验,不自动授予权限或启动 Plugin 进程。
解压文件保存在 AI Core 数据目录的 `extension-packages/` 下,安装失败会清理本次目录。此功能不改变扩展运行时现有的安装记录持久化机制;目前重启后仍需重新注册包。扩展 ZIP 暂不支持 URL 下载;主题 ZIP 使用其独立的导入规则。
+99
View File
@@ -0,0 +1,99 @@
"""Bounded ZIP extraction for packages uploaded to the AI Core host."""
from __future__ import annotations
import io
import re
import shutil
import stat
import tempfile
import zipfile
import zlib
from pathlib import Path
from collections.abc import Callable
from typing import TypeVar
from app.errors import ApiError
from app.extensions.errors import ExtensionError
MAX_ZIP_BYTES = 10 * 1024 * 1024
MAX_EXPANDED_BYTES = 50 * 1024 * 1024
MAX_ENTRIES = 2048
T = TypeVar('T')
def invalid(message: str) -> ApiError:
return ApiError(422, 'EXTENSION_ZIP_INVALID', message)
def install_zip(data: bytes, kind: str, storage: Path, install: Callable[[Path], T]) -> T:
if len(data) > MAX_ZIP_BYTES:
raise ApiError(413, 'EXTENSION_ZIP_TOO_LARGE', 'ZIP 文件不能超过 10 MiB。')
if kind not in ('skill', 'plugin'):
raise ValueError('Unknown extension kind')
storage.mkdir(parents=True, exist_ok=True)
# Retain successful extraction: Plugin commands and resources use this directory.
destination = Path(tempfile.mkdtemp(prefix=f'{kind}-', dir=storage))
try:
with zipfile.ZipFile(io.BytesIO(data)) as archive:
entries = archive.infolist()
if not entries or len(entries) > MAX_ENTRIES:
raise invalid('ZIP 为空或文件条目超过 2048 个。')
seen: set[str] = set()
spellings: dict[str, str] = {}
total = 0
for entry in entries:
name = entry.filename.rstrip('/')
parts = name.split('/')
if (entry.orig_filename != entry.filename or '\\' in name
or any(not p or p in ('.', '..') or any(c in p for c in ':*?<>|"') or p.endswith((' ', '.'))
or any(ord(c) < 32 for c in p)
or re.match(r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)', p, re.I)
for p in parts)):
raise invalid('ZIP 包含不安全的文件路径。')
mode = stat.S_IFMT(entry.external_attr >> 16)
if mode not in (0, stat.S_IFREG, stat.S_IFDIR) or entry.flag_bits & 1:
raise invalid('ZIP 不支持链接、特殊文件或加密条目。')
if entry.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
raise invalid('ZIP 仅支持 stored/deflate 压缩。')
key = name.casefold()
if key in seen:
raise invalid('ZIP 包含重复或大小写冲突的路径。')
seen.add(key)
for index in range(1, len(parts) + 1):
prefix = '/'.join(parts[:index])
if spellings.setdefault(prefix.casefold(), prefix) != prefix:
raise invalid('ZIP 包含大小写冲突的目录。')
total += entry.file_size
if total > MAX_EXPANDED_BYTES:
raise ApiError(413, 'EXTENSION_ZIP_TOO_LARGE', 'ZIP 解压后不能超过 50 MiB。')
target = destination.joinpath(*parts)
if not target.resolve().is_relative_to(destination.resolve()):
raise invalid('ZIP 路径超出包目录。')
written = 0
for entry in entries:
target = destination.joinpath(*entry.filename.rstrip('/').split('/'))
if entry.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with archive.open(entry) as source, target.open('xb') as output:
while chunk := source.read(64 * 1024):
written += len(chunk)
if written > MAX_EXPANDED_BYTES:
raise ApiError(413, 'EXTENSION_ZIP_TOO_LARGE', 'ZIP 解压后不能超过 50 MiB。')
output.write(chunk)
manifest = f'{kind}.yaml'
root = destination
if not (root / manifest).is_file():
children = list(root.iterdir())
if len(children) != 1 or not children[0].is_dir() or not (children[0] / manifest).is_file():
raise invalid(f'ZIP 根目录或唯一顶层文件夹中须包含 {manifest}')
root = children[0]
return install(root)
except BaseException as error:
shutil.rmtree(destination)
if isinstance(error, ExtensionError):
raise
if isinstance(error, (zipfile.BadZipFile, OSError, RuntimeError, NotImplementedError, zlib.error, EOFError, UnicodeError)):
raise invalid('ZIP 损坏、路径冲突或无法解压。') from error
raise
+24 -1
View File
@@ -5,11 +5,13 @@ from contextlib import aclosing
from datetime import datetime, timezone from datetime import datetime, timezone
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, Header, Query from fastapi import APIRouter, Header, Query, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.agent import AgentCapacityError, AgentRunNotFoundError from app.agent import AgentCapacityError, AgentRunNotFoundError
from app.container import container from app.container import container
from app.config import get_settings
from app.extensions.archive import MAX_ZIP_BYTES, install_zip
from app.services.persona_settings import PersonaSettings, load_persona, save_persona from app.services.persona_settings import PersonaSettings, load_persona, save_persona
from app.contracts import ( from app.contracts import (
AgentRun, AgentRun,
@@ -659,6 +661,27 @@ async def install_skill(request: ExtensionInstallRequest) -> Skill:
return extension_call(lambda: container.skills.install(request.package_path)) return extension_call(lambda: container.skills.install(request.package_path))
async def read_extension_zip(request: Request) -> bytes:
data = bytearray()
async for chunk in request.stream():
if len(data) + len(chunk) > MAX_ZIP_BYTES:
raise ApiError(413, 'EXTENSION_ZIP_TOO_LARGE', 'ZIP 文件不能超过 10 MiB。')
data.extend(chunk)
return bytes(data)
@router.post('/skills/install-zip', response_model=Skill, status_code=202, tags=['Skills'])
async def install_skill_zip(request: Request) -> Skill:
data = await read_extension_zip(request)
return extension_call(lambda: install_zip(data, 'skill', get_settings().data_dir / 'extension-packages', container.skills.install))
@router.post('/plugins/install-zip', response_model=Plugin, status_code=202, tags=['Plugins'])
async def install_plugin_zip(request: Request) -> Plugin:
data = await read_extension_zip(request)
return extension_call(lambda: install_zip(data, 'plugin', get_settings().data_dir / 'extension-packages', container.plugins.install))
@router.post( @router.post(
"/skills/{skill_id}/enable", "/skills/{skill_id}/enable",
response_model=Skill, response_model=Skill,
+87
View File
@@ -0,0 +1,87 @@
import asyncio
import io
import stat
import zipfile
import pytest
from starlette.requests import Request
from app.errors import ApiError
from app.extensions import ExtensionError
from app.extensions.archive import install_zip
from app.extensions import archive as module
def zipped(files):
output = io.BytesIO()
with zipfile.ZipFile(output, 'w', zipfile.ZIP_DEFLATED) as archive:
for name, value in files:
if isinstance(name, str) and '\\' in name:
entry = zipfile.ZipInfo()
entry.filename = name # Keep malicious separators on Windows too.
name = entry
archive.writestr(name, value)
return output.getvalue()
@pytest.mark.parametrize('kind', ['skill', 'plugin'])
@pytest.mark.parametrize('prefix', ['', 'package/'])
def test_install_keeps_package_resources(tmp_path, kind, prefix):
data = zipped([(prefix + kind + '.yaml', 'name: test'), (prefix + 'assets/说明.txt', 'hello')])
root = install_zip(data, kind, tmp_path, lambda root: root)
assert (root / 'assets/说明.txt').read_text() == 'hello'
@pytest.mark.parametrize('path', ['../outside', '/outside', 'C:/outside', 'a\\b', 'NUL.txt', 'a/../b', 'a./x'])
def test_unsafe_paths_rejected_and_cleaned(tmp_path, path):
with pytest.raises(ApiError):
install_zip(zipped([('skill.yaml', 'name: x'), (path, 'x')]), 'skill', tmp_path, lambda _: pytest.fail('must not install'))
assert list(tmp_path.iterdir()) == []
def test_links_duplicates_and_size_limits(tmp_path, monkeypatch):
link = zipfile.ZipInfo('link')
link.create_system = 3
link.external_attr = (stat.S_IFLNK | 0o777) << 16
cases = [zipped([(link, '../outside')]), zipped([('skill.yaml', 'x'), ('SKILL.yaml', 'x')]), b'not a zip']
for data in cases:
with pytest.raises(ApiError):
install_zip(data, 'skill', tmp_path, lambda _: pytest.fail('must not install'))
assert list(tmp_path.iterdir()) == []
monkeypatch.setattr(module, 'MAX_EXPANDED_BYTES', 3)
with pytest.raises(ApiError, match='50 MiB'):
install_zip(zipped([('skill.yaml', 'xxxxx')]), 'skill', tmp_path, lambda _: None)
assert list(tmp_path.iterdir()) == []
def test_manifest_validation_failure_preserved_and_cleaned(tmp_path):
def reject(_):
raise ExtensionError('BAD_MANIFEST', 'invalid manifest')
with pytest.raises(ExtensionError, match='invalid manifest'):
install_zip(zipped([('plugin.yaml', 'x')]), 'plugin', tmp_path, reject)
assert list(tmp_path.iterdir()) == []
with pytest.raises(ApiError, match='plugin.yaml'):
install_zip(zipped([('skill.yaml', 'x')]), 'plugin', tmp_path, reject)
assert list(tmp_path.iterdir()) == []
@pytest.mark.parametrize('kind', ['skill', 'plugin'])
def test_upload_route_uses_real_manifest_validation(tmp_path, monkeypatch, kind):
from app import routes
from app.container import build_container
runtime = build_container()
monkeypatch.setattr(routes, 'container', runtime)
data = zipped([(kind + '.yaml', f'id: zip-example\nname: ZIP example\nversion: 1.0.0\ndescription: test\n')])
sent = False
async def receive():
nonlocal sent
assert not sent
sent = True
return {'type': 'http.request', 'body': data, 'more_body': False}
request = Request({'type': 'http', 'method': 'POST', 'headers': []}, receive)
try:
result = asyncio.run(getattr(routes, f'install_{kind}_zip')(request))
assert getattr(result.manifest, kind + '_id') == 'zip-example'
assert not result.enabled
finally:
runtime.plugins.shutdown()
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, expect, it, vi } from 'vitest'
import ActionDialog from './ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
let wrapper: ReturnType<typeof mount>
afterEach(() => wrapper?.unmount())
function setup() {
let api!: ReturnType<typeof useActionDialog>
wrapper = mount(defineComponent({
components: { ActionDialog },
setup() { api = useActionDialog(); return api },
template: '<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />',
}), { attachTo: document.body })
return api
}
it('requires explicit confirmation and treats Escape as cancellation', async () => {
const api = setup()
const action = vi.fn()
const result = api.askConfirm('删除所有配置?').then(ok => { if (ok) action() })
await flushPromises()
expect(document.activeElement?.textContent).toBe('取消')
await wrapper.get('dialog').trigger('cancel')
await result
expect(action).not.toHaveBeenCalled()
const confirmed = api.askConfirm('继续?')
await flushPromises()
await wrapper.get('form').trigger('submit')
expect(await confirmed).toBe(true)
})
it('preserves the default input and distinguishes empty submission from cancel', async () => {
const api = setup()
const input = api.askPrompt('新名称', '旧名称')
await flushPromises()
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('旧名称')
await wrapper.get('input').setValue('')
await wrapper.get('form').trigger('submit')
expect(await input).toBe('')
const cancelled = api.askPrompt('名称')
await flushPromises()
await wrapper.get('button[type="button"]').trigger('click')
expect(await cancelled).toBeNull()
})
it('cancels duplicate requests and pending operations when their view unmounts', async () => {
const api = setup()
const first = api.askConfirm('继续?')
expect(await api.askConfirm('重复')).toBe(false)
wrapper.unmount()
expect(await first).toBe(false)
expect(await api.askPrompt('已离开')).toBeNull()
})
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { ref } from 'vue'
import AppDialog from './AppDialog.vue'
import type { ActionDialogRequest } from '@/composables/useActionDialog'
import { t } from '@/i18n'
const props = defineProps<ActionDialogRequest>()
const emit = defineEmits<{ resolve: [value: string | null] }>()
const value = ref(props.initialValue)
</script>
<template>
<AppDialog :label="mode === 'confirm' ? t('确认操作', 'Confirm action') : message" @close="emit('resolve', null)">
<form class="modal action-dialog" @submit.prevent="emit('resolve', mode === 'prompt' ? value : '')">
<span class="badge info">{{ mode === 'confirm' ? t('操作确认', 'Confirmation') : t('填写信息', 'Enter information') }}</span>
<h2>{{ mode === 'confirm' ? t('确认操作', 'Confirm action') : t('请输入', 'Enter a value') }}</h2>
<label v-if="mode === 'prompt'" class="action-field"><span>{{ message }}</span><input v-model="value" class="input" autofocus /></label>
<p v-else class="action-message">{{ message }}</p>
<footer>
<button type="button" class="button-secondary" :autofocus="mode === 'confirm'" @click="emit('resolve', null)">{{ t('取消', 'Cancel') }}</button>
<button type="submit" class="button-primary">{{ t('确定', 'Confirm') }}</button>
</footer>
</form>
</AppDialog>
</template>
<style scoped>
.action-dialog { width: min(520px, 100%); }
h2 { margin: var(--space-sm) 0 var(--space-lg); }
.action-field { display: grid; gap: var(--space-md); }
.action-message, .action-field span { white-space: pre-wrap; overflow-wrap: anywhere; line-height: var(--line-height-relaxed); }
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-sm); margin-top: var(--space-xl); }
</style>
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest' import { afterEach, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils' import { mount, type VueWrapper } from '@vue/test-utils'
import AppDialog from './AppDialog.vue' import AppDialog from './AppDialog.vue'
const mounted: VueWrapper[] = [] const mounted: VueWrapper[] = []
@@ -34,3 +34,17 @@ it('does not dismiss permission or busy dialogs through Escape or backdrop', asy
await w.get('dialog').trigger('click') await w.get('dialog').trigger('click')
expect(w.emitted('close')).toBeUndefined() expect(w.emitted('close')).toBeUndefined()
}) })
it('cycles Tab between the first and last visible controls', async () => {
const w = mount(AppDialog, {props:{label:'键盘'}, slots:{default:'<section class="modal"><input /><button>取消</button><button disabled>禁用</button></section>'},attachTo:document.body}); mounted.push(w)
const input = w.get('input').element
const button = w.get('button').element
const rects = [new DOMRect(0, 0, 50, 30)] as unknown as DOMRectList
const spies = [input, button].map(element => vi.spyOn(element, 'getClientRects').mockReturnValue(rects))
input.focus()
await w.get('dialog').trigger('keydown', {key:'Tab', shiftKey:true})
expect(document.activeElement).toBe(button)
await w.get('dialog').trigger('keydown', {key:'Tab'})
expect(document.activeElement).toBe(input)
spies.forEach(spy => spy.mockRestore())
})
@@ -9,6 +9,18 @@ let previousFocus: HTMLElement | null = null
function dismiss() { if (props.dismissible) emit('close') } function dismiss() { if (props.dismissible) emit('close') }
function keydown(event: KeyboardEvent) { function keydown(event: KeyboardEvent) {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); dismiss() } if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); dismiss() }
if (event.key === 'Tab' && dialog.value) {
const items = Array.from(dialog.value.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled):not([type="hidden"]), textarea:not(:disabled), select:not(:disabled), a[href], [tabindex]'))
.filter(element => element.tabIndex >= 0 && element.getClientRects().length > 0)
const first = items[0]
const last = items.at(-1)
if (!first) { event.preventDefault(); dialog.value.focus(); return }
if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog.value)) {
event.preventDefault(); last?.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault(); first.focus()
}
}
} }
onMounted(() => { onMounted(() => {
previousFocus = document.activeElement as HTMLElement | null previousFocus = document.activeElement as HTMLElement | null
@@ -1,4 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import AppDialog from './AppDialog.vue'
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askPrompt } = useActionDialog()
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
@@ -79,6 +83,7 @@ function hide() { open.value = false }
async function execute(command: Command | undefined) { async function execute(command: Command | undefined) {
if (!command) return if (!command) return
hide() hide()
await nextTick()
try { try {
await command.run() await command.run()
} catch (error) { } catch (error) {
@@ -87,7 +92,7 @@ async function execute(command: Command | undefined) {
} }
async function createNote() { async function createNote() {
const rawName = window.prompt(t('笔记名称', 'Note name'))?.trim() const rawName = (await askPrompt(t('笔记名称', 'Note name')))?.trim()
if (!rawName) return if (!rawName) return
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md` const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
const file = await workspaceService.createFile('/', name, `# ${rawName}\n\n`) const file = await workspaceService.createFile('/', name, `# ${rawName}\n\n`)
@@ -148,6 +153,7 @@ async function applyPluginEffect(effect: PluginCommandEffect) {
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'p') { if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'p') {
if (!open.value && document.querySelector('dialog[open]')) return
event.preventDefault() event.preventDefault()
open.value ? hide() : show() open.value ? hide() : show()
} else if (event.key === 'Escape' && open.value) { } else if (event.key === 'Escape' && open.value) {
@@ -160,12 +166,13 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
</script> </script>
<template> <template>
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div v-if="commandNotice" class="command-toast" role="status"> <div v-if="commandNotice" class="command-toast" role="status">
<span>{{ commandNotice }}</span><button :aria-label="t('关闭通知', 'Close notification')" @click="commandNotice = ''">×</button> <span>{{ commandNotice }}</span><button :aria-label="t('关闭通知', 'Close notification')" @click="commandNotice = ''">×</button>
</div> </div>
<Teleport to="body"> <Teleport to="body">
<div v-if="open" class="command-backdrop" @click.self="hide"> <AppDialog v-if="open" :label="t('命令面板', 'Command palette')" @close="hide">
<section class="command-palette" role="dialog" aria-modal="true" :aria-label="t('命令面板', 'Command palette')"> <section class="modal command-palette">
<input ref="input" v-model="query" class="command-input" :placeholder="t('输入命令…', 'Enter a command…')" @keydown.enter.prevent="execute(filteredCommands[0])" /> <input ref="input" v-model="query" class="command-input" :placeholder="t('输入命令…', 'Enter a command…')" @keydown.enter.prevent="execute(filteredCommands[0])" />
<p v-if="commandError" class="command-error">{{ commandError }}</p> <p v-if="commandError" class="command-error">{{ commandError }}</p>
<div class="command-list"> <div class="command-list">
@@ -176,15 +183,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
</div> </div>
<footer><span>Enter · {{ t('执行', 'Run') }}</span><span>Esc · {{ t('关闭', 'Close') }}</span></footer> <footer><span>Enter · {{ t('执行', 'Run') }}</span><span>Esc · {{ t('关闭', 'Close') }}</span></footer>
</section> </section>
</div> </AppDialog>
</Teleport> </Teleport>
</template> </template>
<style scoped> <style scoped>
.command-backdrop { position: fixed; inset: 0; z-index: var(--z-modal); display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; background: var(--color-background-overlay); animation: command-backdrop-in var(--motion-fast) both; } .command-palette { padding: 0; display: flex; flex-direction: column; width: min(620px, calc(100vw - 32px)); overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); animation: command-palette-in var(--motion-normal) both; }
.command-palette { width: min(620px, calc(100vw - 32px)); overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); background: var(--color-surface-elevated); box-shadow: var(--shadow-xl); animation: command-palette-in var(--motion-normal) both; }
.command-input { width: 100%; padding: var(--space-xl); border: 0; border-bottom: 1px solid var(--color-border-default); outline: 0; background: transparent; color: var(--color-text-primary); font-size: var(--font-size-xl); } .command-input { width: 100%; padding: var(--space-xl); border: 0; border-bottom: 1px solid var(--color-border-default); outline: 0; background: transparent; color: var(--color-text-primary); font-size: var(--font-size-xl); }
.command-list { max-height: 360px; overflow: auto; padding: var(--space-sm); } .command-list { min-height: 0; max-height: 360px; overflow: auto; padding: var(--space-sm); }
.command-list button { display: flex; justify-content: space-between; width: 100%; padding: var(--space-md) var(--space-lg); border-radius: var(--radius-md); text-align: left; transition: color var(--motion-fast), background-color var(--motion-fast), transform var(--motion-fast); } .command-list button { display: flex; justify-content: space-between; width: 100%; padding: var(--space-md) var(--space-lg); border-radius: var(--radius-md); text-align: left; transition: color var(--motion-fast), background-color var(--motion-fast), transform var(--motion-fast); }
.command-list button:hover, .command-list button:focus { outline: 0; background: var(--color-accent-soft); color: var(--color-accent-primary); } .command-list button:hover, .command-list button:focus { outline: 0; background: var(--color-accent-soft); color: var(--color-accent-primary); }
.command-list button:hover { transform: translateX(2px); } .command-list button:hover { transform: translateX(2px); }
@@ -6,12 +6,39 @@ import ExtensionInstallDialog from './ExtensionInstallDialog.vue'
let wrapper: VueWrapper let wrapper: VueWrapper
afterEach(() => { wrapper?.unmount() }) afterEach(() => { wrapper?.unmount() })
it.each(['Skill', 'Plugin'] as const)('uploads a selected %s ZIP only on confirmation', async kind => {
const install = vi.fn().mockResolvedValue(undefined)
wrapper = mount(ExtensionInstallDialog, { props: { kind, install } })
const file = new File(['zip fixture'], 'package.zip', {type:'application/zip'})
Object.defineProperty(wrapper.get('input[type="file"]').element, 'files', {value:[file], configurable:true})
await wrapper.get('input[type="file"]').trigger('change')
expect(wrapper.text()).toContain('package.zip')
expect(install).not.toHaveBeenCalled()
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(install).toHaveBeenCalledExactlyOnceWith(file)
expect(wrapper.emitted('installed')).toHaveLength(1)
})
it('rejects oversized ZIP files before upload', async () => {
const install = vi.fn()
wrapper = mount(ExtensionInstallDialog, {props:{kind:'Skill',install}})
const file = new File(['zip'], 'large.zip')
Object.defineProperty(file, 'size', {value:10 * 1024 * 1024 + 1})
Object.defineProperty(wrapper.get('input[type="file"]').element, 'files', {value:[file]})
await wrapper.get('input[type="file"]').trigger('change')
expect(wrapper.get('[role="alert"]').text()).toContain('10 MiB')
await wrapper.get('form').trigger('submit')
expect(install).not.toHaveBeenCalled()
})
it.each(['Skill', 'Plugin'] as const)('installs %s from a trimmed directory and prevents duplicate submissions', async kind => { it.each(['Skill', 'Plugin'] as const)('installs %s from a trimmed directory and prevents duplicate submissions', async kind => {
let complete!: () => void let complete!: () => void
const install = vi.fn(() => new Promise<void>(resolve => { complete = resolve })) const install = vi.fn(() => new Promise<void>(resolve => { complete = resolve }))
wrapper = mount(ExtensionInstallDialog, { props: { kind, install } }) wrapper = mount(ExtensionInstallDialog, { props: { kind, install } })
expect(wrapper.text()).toContain(`${kind.toLowerCase()}.yaml`) expect(wrapper.text()).toContain(`${kind.toLowerCase()}.yaml`)
expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined() expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined()
await wrapper.findAll('button').find(button => button.text() === '本地目录')!.trigger('click')
await wrapper.get('input').setValue(' G:\\packages\\example ') await wrapper.get('input').setValue(' G:\\packages\\example ')
await wrapper.get('form').trigger('submit') await wrapper.get('form').trigger('submit')
await wrapper.get('form').trigger('submit') await wrapper.get('form').trigger('submit')
@@ -27,6 +54,7 @@ it.each(['Skill', 'Plugin'] as const)('installs %s from a trimmed directory and
it('keeps the path and displays validation errors for retry', async () => { it('keeps the path and displays validation errors for retry', async () => {
const install = vi.fn().mockRejectedValueOnce(new Error('Manifest does not exist')).mockResolvedValueOnce(undefined) const install = vi.fn().mockRejectedValueOnce(new Error('Manifest does not exist')).mockResolvedValueOnce(undefined)
wrapper = mount(ExtensionInstallDialog, { props: { kind: 'Plugin', install } }) wrapper = mount(ExtensionInstallDialog, { props: { kind: 'Plugin', install } })
await wrapper.findAll('button').find(button => button.text() === '本地目录')!.trigger('click')
await wrapper.get('input').setValue('G:\\packages\\example') await wrapper.get('input').setValue('G:\\packages\\example')
await wrapper.get('form').trigger('submit') await wrapper.get('form').trigger('submit')
await flushPromises() await flushPromises()
@@ -5,20 +5,38 @@ import AppDialog from './AppDialog.vue'
import AppIcon from './AppIcon.vue' import AppIcon from './AppIcon.vue'
import { t } from '@/i18n' import { t } from '@/i18n'
const props = defineProps<{ kind: 'Skill' | 'Plugin'; install: (path: string) => Promise<unknown> }>() const props = defineProps<{ kind: 'Skill' | 'Plugin'; install: (source: string | File) => Promise<unknown> }>()
const emit = defineEmits<{ close: []; installed: [] }>() const emit = defineEmits<{ close: []; installed: [] }>()
const path = ref('') const path = ref('')
const mode = ref<'path' | 'zip'>('zip')
const fileInput = ref<HTMLInputElement>()
const file = ref<File | null>(null)
const busy = ref(false) const busy = ref(false)
const error = ref('') const error = ref('')
const title = computed(() => t(`安装 ${props.kind}`, `Install ${props.kind}`)) const title = computed(() => t(`安装 ${props.kind}`, `Install ${props.kind}`))
const manifest = computed(() => `${props.kind.toLowerCase()}.yaml`) const manifest = computed(() => `${props.kind.toLowerCase()}.yaml`)
const ready = computed(() => mode.value === 'zip' ? Boolean(file.value) : Boolean(path.value.trim()))
function chooseFile(event: Event) {
const input = event.target as HTMLInputElement
file.value = null
error.value = ''
const selected = input.files?.[0]
input.value = ''
if (!selected) return
if (!selected.name.toLowerCase().endsWith('.zip') || !selected.size || selected.size > 10 * 1024 * 1024) {
error.value = t('请选择非空 ZIP 文件,大小不超过 10 MiB。', 'Choose a nonempty ZIP file up to 10 MiB.')
return
}
file.value = selected
}
async function submit() { async function submit() {
if (busy.value || !path.value.trim()) return if (busy.value || !ready.value) return
error.value = '' error.value = ''
busy.value = true busy.value = true
try { try {
await props.install(path.value.trim()) await props.install(mode.value === 'zip' ? file.value! : path.value.trim())
emit('installed') emit('installed')
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : t('安装失败,请检查包目录后重试。', 'Installation failed. Check the package directory and retry.') error.value = reason instanceof Error ? reason.message : t('安装失败,请检查包目录后重试。', 'Installation failed. Check the package directory and retry.')
@@ -33,8 +51,19 @@ async function submit() {
<form class="modal extension-install-modal" :aria-busy="busy" @submit.prevent="submit"> <form class="modal extension-install-modal" :aria-busy="busy" @submit.prevent="submit">
<span class="badge info">{{ t('扩展安装', 'Extension installation') }}</span> <span class="badge info">{{ t('扩展安装', 'Extension installation') }}</span>
<h2>{{ title }}</h2> <h2>{{ title }}</h2>
<p class="muted">{{ t('本地包目录安装,安装时会校验清单与依赖。', 'Install from a local package directory. The manifest and dependencies are checked during installation.') }}</p> <p class="muted">{{ t('导入 ZIP 或使用本地包目录,安装时会校验清单与依赖。', 'Import a ZIP or use a local directory. The manifest and dependencies are checked during installation.') }}</p>
<div class="package-source"> <div class="source-tabs" :aria-label="t('安装来源', 'Installation source')">
<button v-for="item in (['zip', 'path'] as const)" :key="item" type="button" class="button-secondary" :aria-pressed="mode === item" :disabled="busy" @click="mode = item; error = ''">{{ item === 'zip' ? t('ZIP 文件', 'ZIP file') : t('本地目录', 'Local directory') }}</button>
</div>
<div v-if="mode === 'zip'" class="package-source">
<AppIcon :icon="FolderOpened" :size="30" />
<input ref="fileInput" class="zip-input" type="file" accept=".zip,application/zip" :disabled="busy" :aria-label="t('选择 ZIP 扩展包', 'Choose a ZIP extension package')" @change="chooseFile" />
<button type="button" class="button-secondary" :disabled="busy" @click="fileInput?.click()">{{ file ? t('重新选择 ZIP', 'Choose another ZIP') : t('选择 ZIP 文件', 'Choose ZIP file') }}</button>
<strong v-if="file" class="package-name">{{ file.name }} · {{ (file.size / 1024).toFixed(1) }} KiB</strong>
<p class="muted">{{ t('根目录或唯一顶层文件夹中须包含', 'The root or single top-level folder must contain') }} <code>{{ manifest }}</code></p>
<p class="subtle">{{ t('ZIP 最大 10 MiB,解压后最大 50 MiB,最多 2048 个条目。', 'Up to 10 MiB compressed, 50 MiB extracted, and 2048 entries.') }}</p>
</div>
<div v-else class="package-source">
<AppIcon :icon="FolderOpened" :size="30" /> <AppIcon :icon="FolderOpened" :size="30" />
<strong>{{ t('本地包目录', 'Local package directory') }}</strong> <strong>{{ t('本地包目录', 'Local package directory') }}</strong>
<p class="muted">{{ t('选择包含以下清单的完整解压目录:', 'Use the extracted directory containing:') }} <code>{{ manifest }}</code></p> <p class="muted">{{ t('选择包含以下清单的完整解压目录:', 'Use the extracted directory containing:') }} <code>{{ manifest }}</code></p>
@@ -42,13 +71,13 @@ async function submit() {
<span>{{ t('目录路径', 'Directory path') }}</span> <span>{{ t('目录路径', 'Directory path') }}</span>
<input v-model="path" class="input" autofocus required :disabled="busy" :placeholder="t('粘贴本地包目录的完整路径', 'Paste the full package directory path')" aria-describedby="extension-path-help" /> <input v-model="path" class="input" autofocus required :disabled="busy" :placeholder="t('粘贴本地包目录的完整路径', 'Paste the full package directory path')" aria-describedby="extension-path-help" />
</label> </label>
<p id="extension-path-help" class="subtle">{{ t('路径须位于 AI Core 所在电脑。ZIP 请先解压,再填写目录路径。', 'The directory must be on the AI Core computer. Extract ZIP packages before entering the directory path.') }}</p> <p id="extension-path-help" class="subtle">{{ t('路径须位于 AI Core 所在电脑。', 'The directory must be on the AI Core computer.') }}</p>
</div> </div>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div> <div v-if="error" class="error-banner" role="alert">{{ error }}</div>
<p v-if="busy" class="muted" role="status">{{ t('正在校验并安装请稍候', 'Validating and installing') }}</p> <p v-if="busy" class="muted" role="status">{{ t('正在校验并安装请稍候', 'Validating and installing') }}</p>
<footer class="install-actions"> <footer class="install-actions">
<button type="button" class="button-secondary" :disabled="busy" @click="emit('close')">{{ t('取消', 'Cancel') }}</button> <button type="button" class="button-secondary" :disabled="busy" @click="emit('close')">{{ t('取消', 'Cancel') }}</button>
<button type="submit" class="button-primary" :disabled="busy || !path.trim()">{{ busy ? t('安装中…', 'Installing…') : title }}</button> <button type="submit" class="button-primary" :disabled="busy || !ready">{{ busy ? t('安装中…', 'Installing…') : title }}</button>
</footer> </footer>
</form> </form>
</AppDialog> </AppDialog>
@@ -63,4 +92,8 @@ h2 { margin: var(--space-sm) 0 var(--space-md); }
.package-field input { min-width: 0; } .package-field input { min-width: 0; }
.install-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-sm); margin-top: var(--space-lg); } .install-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-sm); margin-top: var(--space-lg); }
.error-banner { overflow-wrap: anywhere; } .error-banner { overflow-wrap: anywhere; }
.source-tabs { display: flex; gap: var(--space-sm); margin-top: var(--space-lg); }
.source-tabs [aria-pressed="true"] { border-color: var(--color-accent-primary); color: var(--color-accent-primary); background: var(--color-accent-soft); }
.zip-input { display: none; }
.package-name { overflow-wrap: anywhere; max-width: 100%; }
</style> </style>
@@ -0,0 +1,28 @@
import { nextTick, onBeforeUnmount, shallowRef } from 'vue'
export interface ActionDialogRequest { message: string; mode: 'confirm' | 'prompt'; initialValue: string }
/** Requests belong to the invoking view; leaving it cancels pending work. */
export function useActionDialog() {
const actionDialog = shallowRef<ActionDialogRequest | null>(null)
let pending: ((value: string | null) => void) | undefined
let disposed = false
async function resolveAction(value: string | null) {
const resolve = pending
pending = undefined
actionDialog.value = null
await nextTick() // Restore focus and release the modal before the caller continues.
resolve?.(disposed ? null : value)
}
function request(mode: ActionDialogRequest['mode'], message: string, initialValue = '') {
if (disposed || pending) return Promise.resolve(null)
actionDialog.value = { mode, message, initialValue }
return new Promise<string | null>(resolve => { pending = resolve })
}
onBeforeUnmount(() => { disposed = true; pending?.(null); pending = undefined; actionDialog.value = null })
return {
actionDialog, resolveAction,
askConfirm: async (message: string) => (await request('confirm', message)) !== null,
askPrompt: (message: string, initialValue = '') => request('prompt', message, initialValue),
}
}
@@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askPrompt } = useActionDialog()
import DiagramInteractions from '@/components/common/DiagramInteractions.vue' import DiagramInteractions from '@/components/common/DiagramInteractions.vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Link } from '@element-plus/icons-vue' import { Link } from '@element-plus/icons-vue'
@@ -119,20 +122,28 @@ function runCommand(command: ToolbarCommand) {
editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus() editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus()
} }
function applyLink() { async function applyLink() {
if (!crepe) return if (!crepe) return
// TODO(editor): Element Plus prompt URL const editor = crepe
const href = window.prompt(t('请输入链接地址', 'Enter link address'), 'https://')?.trim() const snapshot = editor.editor.action(ctx => {
if (!href) return
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx) const view = ctx.get(editorViewCtx)
return { doc: view.state.doc, selection: view.state.selection }
})
const href = (await askPrompt(t('请输入链接地址', 'Enter link address'), 'https://'))?.trim()
if (!href || crepe !== editor) return
const label = snapshot.selection.empty ? await askPrompt(t('请输入链接文字', 'Enter link text'), href) : ''
if (label === null || crepe !== editor) return
editor.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
if (!view.state.doc.eq(snapshot.doc)) return
view.dispatch(view.state.tr.setSelection(snapshot.selection))
const commands = ctx.get(commandsCtx) const commands = ctx.get(commandsCtx)
if (view.state.selection.empty) { if (view.state.selection.empty) {
const label = window.prompt(t('请输入链接文字', 'Enter link text'), href)?.trim() || href const text = label.trim() || href
const from = view.state.selection.from const from = view.state.selection.from
const transaction = view.state.tr.insertText(label, from) const transaction = view.state.tr.insertText(text, from)
transaction.setSelection(TextSelection.create(transaction.doc, from, from + label.length)) transaction.setSelection(TextSelection.create(transaction.doc, from, from + text.length))
view.dispatch(transaction) view.dispatch(transaction)
} }
return commands.call(toggleLinkCommand.key, { href }) return commands.call(toggleLinkCommand.key, { href })
@@ -278,6 +289,7 @@ defineExpose({ getEditor: () => crepe?.editor })
<template> <template>
<DiagramInteractions class="visual-editor"> <DiagramInteractions class="visual-editor">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')"> <div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')">
<label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')"> <label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')">
<span class="format-glyph heading-glyph">H</span> <span class="format-glyph heading-glyph">H</span>
@@ -29,7 +29,6 @@ async function render(items: McpServer[] = []) {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.stubGlobal('confirm', vi.fn(() => true))
}) })
describe('McpServersView', () => { describe('McpServersView', () => {
@@ -78,7 +77,9 @@ describe('McpServersView', () => {
vi.mocked(service.deleteMcpServer).mockResolvedValue({ status: 'completed' }) vi.mocked(service.deleteMcpServer).mockResolvedValue({ status: 'completed' })
await wrapper.findAll('button').find(button => button.text().includes('删除'))!.trigger('click') await wrapper.findAll('button').find(button => button.text().includes('删除'))!.trigger('click')
await flushPromises() await flushPromises()
expect(confirm).toHaveBeenCalled() expect(service.deleteMcpServer).not.toHaveBeenCalled()
await wrapper.get('.action-dialog').trigger('submit')
await flushPromises()
expect(service.deleteMcpServer).toHaveBeenCalledWith('server-1') expect(service.deleteMcpServer).toHaveBeenCalledWith('server-1')
}) })
@@ -89,7 +90,10 @@ describe('McpServersView', () => {
await wrapper.get('input[placeholder="network.request, notes.read"]').setValue('notes.read') await wrapper.get('input[placeholder="network.request, notes.read"]').setValue('notes.read')
await wrapper.get('form').trigger('submit') await wrapper.get('form').trigger('submit')
await flushPromises() await flushPromises()
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('旧测试与授权会失效')) expect(wrapper.get('.action-dialog').text()).toContain('旧测试与授权会失效')
expect(service.updateMcpServer).not.toHaveBeenCalled()
await wrapper.get('.action-dialog').trigger('submit')
await flushPromises()
expect(service.updateMcpServer).toHaveBeenCalled() expect(service.updateMcpServer).toHaveBeenCalled()
}) })
@@ -125,6 +129,8 @@ describe('McpServersView', () => {
expect(wrapper.get('.modal-card [role="alert"]').text()).toContain('服务器配置已保存,但密钥保存失败') expect(wrapper.get('.modal-card [role="alert"]').text()).toContain('服务器配置已保存,但密钥保存失败')
await wrapper.get('form').trigger('submit') await wrapper.get('form').trigger('submit')
await flushPromises() await flushPromises()
await wrapper.get('.action-dialog').trigger('submit')
await flushPromises()
expect(service.createMcpServer).toHaveBeenCalledTimes(1) expect(service.createMcpServer).toHaveBeenCalledTimes(1)
expect(service.updateMcpServer).toHaveBeenCalledWith('new-server', expect.objectContaining({ version: 1 })) expect(service.updateMcpServer).toHaveBeenCalledWith('new-server', expect.objectContaining({ version: 1 }))
expect(service.putMcpServerSecret).toHaveBeenCalledTimes(2) expect(service.putMcpServerSecret).toHaveBeenCalledTimes(2)
@@ -144,6 +150,8 @@ describe('McpServersView', () => {
vi.mocked(service.updateMcpServer).mockResolvedValue(server) vi.mocked(service.updateMcpServer).mockResolvedValue(server)
await wrapper.get('form').trigger('submit') await wrapper.get('form').trigger('submit')
await flushPromises() await flushPromises()
await wrapper.get('.action-dialog').trigger('submit')
await flushPromises()
expect(service.updateMcpServer).toHaveBeenCalledWith('server-1', expect.objectContaining({ version: 2, headers: {}, args: [] })) expect(service.updateMcpServer).toHaveBeenCalledWith('server-1', expect.objectContaining({ version: 2, headers: {}, args: [] }))
expect(service.putMcpServerSecret).not.toHaveBeenCalled() expect(service.putMcpServerSecret).not.toHaveBeenCalled()
}) })
+7 -3
View File
@@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import AppDialog from '@/components/common/AppDialog.vue' import AppDialog from '@/components/common/AppDialog.vue'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { Connection, Delete, EditPen, Plus, Refresh, VideoPlay } from '@element-plus/icons-vue' import { Connection, Delete, EditPen, Plus, Refresh, VideoPlay } from '@element-plus/icons-vue'
@@ -143,7 +146,7 @@ async function save() {
error.value = '' error.value = ''
const input = payload() const input = payload()
if (!input.name || (input.transport === 'stdio' ? !input.command : !input.url)) throw new Error(t('请填写服务器名称和连接地址', 'Enter a server name and connection address')) if (!input.name || (input.transport === 'stdio' ? !input.command : !input.url)) throw new Error(t('请填写服务器名称和连接地址', 'Enter a server name and connection address'))
if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !confirm(t('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?', 'The command, address, or authentication settings changed. Previous tests and authorization will be invalidated. Save?'))) return if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !(await askConfirm(t('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?', 'The command, address, or authentication settings changed. Previous tests and authorization will be invalidated. Save?')))) return
busy.value = 'save' busy.value = 'save'
saved = editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input) saved = editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
// Commit the returned ID/version before saving secrets so a partial failure can // Commit the returned ID/version before saving secrets so a partial failure can
@@ -192,7 +195,7 @@ function executionChanged(server: McpServer, input: McpServerInput) {
async function approve(server: McpServer): Promise<McpServer | null> { async function approve(server: McpServer): Promise<McpServer | null> {
if (server.trusted) return server if (server.trusted) return server
const localWarning = server.transport === 'stdio' ? t('\n\n本机进程尚无系统级沙箱,仅应运行可信服务器。', '\n\nLocal processes have no system-level sandbox. Run trusted servers only.') : t('\n\n连接可能向该地址发送配置的 Header。', '\n\nThe connection may send configured headers to this address.') const localWarning = server.transport === 'stdio' ? t('\n\n本机进程尚无系统级沙箱,仅应运行可信服务器。', '\n\nLocal processes have no system-level sandbox. Run trusted servers only.') : t('\n\n连接可能向该地址发送配置的 Header。', '\n\nThe connection may send configured headers to this address.')
if (!confirm(`${t('请确认 MCP 连接:', 'Confirm MCP connection:')}\n\n${server.command_summary}${localWarning}\n\n${t('是否继续?', 'Continue?')}`)) return null if (!(await askConfirm(`${t('请确认 MCP 连接:', 'Confirm MCP connection:')}\n\n${server.command_summary}${localWarning}\n\n${t('是否继续?', 'Continue?')}`))) return null
return service.trustMcpServer(server) return service.trustMcpServer(server)
} }
@@ -206,7 +209,7 @@ async function act(server: McpServer, action: string, operation: (server: McpSer
} }
async function remove(server: McpServer) { async function remove(server: McpServer) {
if (!confirm(t(`删除“${server.name}”及其加密凭据?`, `Delete “${server.name}” and its encrypted credentials?`))) return if (!(await askConfirm(t(`删除“${server.name}”及其加密凭据?`, `Delete “${server.name}” and its encrypted credentials?`)))) return
try { busy.value = `delete:${server.server_id}`; await service.deleteMcpServer(server.server_id); await load() } try { busy.value = `delete:${server.server_id}`; await service.deleteMcpServer(server.server_id); await load() }
catch (cause) { error.value = message(cause, t('删除失败', 'Delete failed')) } finally { busy.value = '' } catch (cause) { error.value = message(cause, t('删除失败', 'Delete failed')) } finally { busy.value = '' }
} }
@@ -226,6 +229,7 @@ onMounted(load)
<template> <template>
<section class="feature-page mcp-page"> <section class="feature-page mcp-page">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<header class="feature-header"><div><h1>{{ t('MCP 服务器', 'MCP Servers') }}</h1><p>{{ t('管理独立 MCP Server 的连接、凭据与工具生命周期。', 'Manage standalone MCP server connections, credentials, and tool lifecycles.') }}</p></div><div class="inline-actions"><button class="button-secondary" :disabled="!!busy" @click="load"><AppIcon :icon="Refresh" /> {{ t('刷新', 'Refresh') }}</button><button class="button-primary" @click="openCreate"><AppIcon :icon="Plus" /> {{ t('新增服务器', 'Add server') }}</button></div></header> <header class="feature-header"><div><h1>{{ t('MCP 服务器', 'MCP Servers') }}</h1><p>{{ t('管理独立 MCP Server 的连接、凭据与工具生命周期。', 'Manage standalone MCP server connections, credentials, and tool lifecycles.') }}</p></div><div class="inline-actions"><button class="button-secondary" :disabled="!!busy" @click="load"><AppIcon :icon="Refresh" /> {{ t('刷新', 'Refresh') }}</button><button class="button-primary" @click="openCreate"><AppIcon :icon="Plus" /> {{ t('新增服务器', 'Add server') }}</button></div></header>
<div class="notice-banner">{{ t('stdio 本机进程仅在开发环境开放;Streamable HTTP 为首选远程传输,SSE 仅用于兼容旧服务器。uvx 隔离依赖但不是安全沙箱。', 'Local stdio processes are available only in development. Streamable HTTP is the preferred remote transport; SSE supports legacy servers. uvx isolates dependencies but is not a security sandbox.') }}</div> <div class="notice-banner">{{ t('stdio 本机进程仅在开发环境开放;Streamable HTTP 为首选远程传输,SSE 仅用于兼容旧服务器。uvx 隔离依赖但不是安全沙箱。', 'Local stdio processes are available only in development. Streamable HTTP is the preferred remote transport; SSE supports legacy servers. uvx isolates dependencies but is not a security sandbox.') }}</div>
<div v-if="error" class="error-banner">{{ error }}</div> <div v-if="error" class="error-banner">{{ error }}</div>
+6 -2
View File
@@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService' import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService'
@@ -48,7 +51,7 @@ async function refresh() {
if (!stopped) timer = setTimeout(refresh, 2000) if (!stopped) timer = setTimeout(refresh, 2000)
} }
async function choose(job: MediaJob) { async function choose(job: MediaJob) {
if (dirty.value && !window.confirm(t('当前校对尚未保存,切换后放弃修改?', 'The current corrections are unsaved. Discard them and switch?'))) return if (dirty.value && !(await askConfirm(t('当前校对尚未保存,切换后放弃修改?', 'The current corrections are unsaved. Discard them and switch?')))) return
selected.value = JSON.parse(JSON.stringify(job)); dirty.value = false; history.value = [] selected.value = JSON.parse(JSON.stringify(job)); dirty.value = false; history.value = []
} }
async function action(work: () => Promise<void>) { async function action(work: () => Promise<void>) {
@@ -77,7 +80,7 @@ async function purge() {
if (!selected.value) return if (!selected.value) return
await action(async () => { await action(async () => {
const impact = await mediaService.impact(selected.value!.attachment_id) const impact = await mediaService.impact(selected.value!.attachment_id)
if (!window.confirm(`${impact.message}\n${t('将保留', 'Will retain')} ${impact.retained_note_ids.length} ${t('篇已保存笔记。确定清理?', 'saved notes. Continue cleanup?')}`)) return if (!(await askConfirm(`${impact.message}\n${t('将保留', 'Will retain')} ${impact.retained_note_ids.length} ${t('篇已保存笔记。确定清理?', 'saved notes. Continue cleanup?')}`))) return
await mediaService.purge(selected.value!.attachment_id) await mediaService.purge(selected.value!.attachment_id)
selected.value = await mediaService.get(selected.value!.job_id) selected.value = await mediaService.get(selected.value!.job_id)
dirty.value = false; history.value = []; notice.value = t('附件与转写内容已清理', 'Attachment and transcript content were removed') dirty.value = false; history.value = []; notice.value = t('附件与转写内容已清理', 'Attachment and transcript content were removed')
@@ -110,6 +113,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<template> <template>
<section class="media-page"> <section class="media-page">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<header class="feature-header"><div><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t('上传音频或视频音轨,转写、校对后保存到知识库。最多 128 MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to 128 MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.') }}</p></div></header> <header class="feature-header"><div><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t('上传音频或视频音轨,转写、校对后保存到知识库。最多 128 MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to 128 MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.') }}</p></div></header>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p> <div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p>
<form class="panel upload" @submit.prevent="submit"> <form class="panel upload" @submit.prevent="submit">
@@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import { Key, Refresh } from '@element-plus/icons-vue' import { Key, Refresh } from '@element-plus/icons-vue'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import AppIcon from '@/components/common/AppIcon.vue' import AppIcon from '@/components/common/AppIcon.vue'
@@ -116,11 +119,14 @@ async function saveSecret(field: PluginSettingField) {
} catch (reason) { feedback(message(reason, t('密钥保存失败', 'Failed to save secret'))) } finally { busy.value = '' } } catch (reason) { feedback(message(reason, t('密钥保存失败', 'Failed to save secret'))) } finally { busy.value = '' }
} }
async function deleteSecret(field: PluginSettingField) { async function deleteSecret(field: PluginSettingField) {
if (!confirm(t('删除已保存的', 'Delete saved ') + field.label + '')) return const pluginId = props.plugin.plugin_id
if (!(await askConfirm(t('删除已保存的', 'Delete saved ') + field.label + ''))) return
if (pluginId !== props.plugin.plugin_id) return
busy.value = 'secret:' + field.key busy.value = 'secret:' + field.key
feedback() feedback()
try { try {
const state = await pluginService.deletePluginSecret(props.plugin.plugin_id, field.key) const state = await pluginService.deletePluginSecret(pluginId, field.key)
if (pluginId !== props.plugin.plugin_id) return
if (schema.value) schema.value.secrets[field.key] = { configured: state.configured } if (schema.value) schema.value.secrets[field.key] = { configured: state.configured }
secrets.value[field.key] = '' secrets.value[field.key] = ''
notice.value = field.label + t('已删除。', ' deleted.') notice.value = field.label + t('已删除。', ' deleted.')
@@ -130,6 +136,7 @@ async function deleteSecret(field: PluginSettingField) {
<template> <template>
<section class="mcp-panel"> <section class="mcp-panel">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<nav class="mcp-tabs" :aria-label="t('MCP Plugin 配置', 'MCP and Plugin settings')"> <nav class="mcp-tabs" :aria-label="t('MCP Plugin 配置', 'MCP and Plugin settings')">
<button v-for="tab in tabs" :key="tab.id" :class="{ active: activeTab === tab.id }" @click="selectTab(tab.id)">{{ tab.label }}</button> <button v-for="tab in tabs" :key="tab.id" :class="{ active: activeTab === tab.id }" @click="selectTab(tab.id)">{{ tab.label }}</button>
</nav> </nav>
@@ -54,13 +54,17 @@ it.each(['save', 'delete'] as const)('ignores old secret %s responses after swit
let finish!: () => void let finish!: () => void
vi.mocked(service.putPluginSecret).mockReturnValue(new Promise(resolve => { finish = () => resolve({ plugin_id: 'demo', key: 'token', configured: true }) })) vi.mocked(service.putPluginSecret).mockReturnValue(new Promise(resolve => { finish = () => resolve({ plugin_id: 'demo', key: 'token', configured: true }) }))
if (action === 'delete') { if (action === 'delete') {
vi.stubGlobal('confirm', vi.fn(() => true))
vi.mocked(service.deletePluginSecret).mockReturnValue(new Promise(resolve => { finish = () => resolve({ plugin_id: 'demo', key: 'token', configured: false }) })) vi.mocked(service.deletePluginSecret).mockReturnValue(new Promise(resolve => { finish = () => resolve({ plugin_id: 'demo', key: 'token', configured: false }) }))
} }
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } }) wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises() await flushPromises()
await wrapper.get('input[type="password"]').setValue('old-fixture-value') await wrapper.get('input[type="password"]').setValue('old-fixture-value')
await wrapper.get(action === 'save' ? '.secret-row button' : '.secret-row .danger').trigger('click') await wrapper.get(action === 'save' ? '.secret-row button' : '.secret-row .danger').trigger('click')
if (action === 'delete') {
await wrapper.get('.action-dialog').trigger('submit')
await flushPromises()
}
vi.mocked(service.getPluginSettings).mockResolvedValue(secretSchema(false)) vi.mocked(service.getPluginSettings).mockResolvedValue(secretSchema(false))
await wrapper.setProps({ pluginId: 'other' }) await wrapper.setProps({ pluginId: 'other' })
await flushPromises() await flushPromises()
@@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import type { PluginSettingsSchema, PluginSettingField } from '@/contracts' import type { PluginSettingsSchema, PluginSettingField } from '@/contracts'
import { import {
@@ -106,9 +109,10 @@ async function saveSecret(key: string) {
async function clearSecret(key: string) { async function clearSecret(key: string) {
if (!schema.value || isSaving.value) return if (!schema.value || isSaving.value) return
if (!confirm(`确认删除 " ${key} " 的配置?`)) return
const version = loadVersion const version = loadVersion
const pluginId = props.pluginId const pluginId = props.pluginId
if (!(await askConfirm(`确认删除 " ${key} " 的配置?`))) return
if (version !== loadVersion || pluginId !== props.pluginId) return
isSaving.value = true isSaving.value = true
saveError.value = '' saveError.value = ''
try { try {
@@ -145,6 +149,7 @@ watch(() => props.pluginId, load)
<template> <template>
<div class="plugin-settings-panel"> <div class="plugin-settings-panel">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div v-if="isLoading" class="loading">加载设置中</div> <div v-if="isLoading" class="loading">加载设置中</div>
<template v-else-if="schema && schema.fields.length > 0"> <template v-else-if="schema && schema.fields.length > 0">
@@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import { Connection } from '@element-plus/icons-vue' import { Connection } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue' import AppIcon from '@/components/common/AppIcon.vue'
import ExtensionInstallDialog from '@/components/common/ExtensionInstallDialog.vue' import ExtensionInstallDialog from '@/components/common/ExtensionInstallDialog.vue'
@@ -39,13 +42,13 @@ async function toggle(id: string, enabled: boolean) {
} }
async function grant(id: string, permissions: string[]) { async function grant(id: string, permissions: string[]) {
if (!confirm(`${t('将授权:', 'Grant permissions: ')}${permissions.join(', ')}${t('是否继续?', 'Continue?')}`)) return if (!(await askConfirm(`${t('将授权:', 'Grant permissions: ')}${permissions.join(', ')}${t('是否继续?', 'Continue?')}`))) return
try { await pluginStore.grantPermissions(id, permissions) } try { await pluginStore.grantPermissions(id, permissions) }
catch (error) { actionError.value = error instanceof Error ? error.message : t('授权失败', 'Authorization failed') } catch (error) { actionError.value = error instanceof Error ? error.message : t('授权失败', 'Authorization failed') }
} }
async function uninstall(id: string, name: string) { async function uninstall(id: string, name: string) {
if (!confirm(t(`卸载「${name}」将移除其全部 Contribution,是否继续?`, `Uninstalling “${name}” removes all its contributions. Continue?`))) return if (!(await askConfirm(t(`卸载「${name}」将移除其全部 Contribution,是否继续?`, `Uninstalling “${name}” removes all its contributions. Continue?`)))) return
try { await pluginStore.uninstallPlugin(id) } try { await pluginStore.uninstallPlugin(id) }
catch (error) { actionError.value = error instanceof Error ? error.message : t('卸载失败', 'Uninstall failed') } catch (error) { actionError.value = error instanceof Error ? error.message : t('卸载失败', 'Uninstall failed') }
} }
@@ -61,6 +64,7 @@ const hasCommandContribution = computed(() =>
<template> <template>
<section class="feature-page"> <section class="feature-page">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<ExtensionInstallDialog v-if="showInstall" kind="Plugin" :install="pluginStore.installPlugin" @close="showInstall = false" @installed="showInstall = false; actionError = ''" /> <ExtensionInstallDialog v-if="showInstall" kind="Plugin" :install="pluginStore.installPlugin" @close="showInstall = false" @installed="showInstall = false; actionError = ''" />
<header class="feature-header"> <header class="feature-header">
<div><h1>{{ t('Plugin 与 MCP', 'Plugins and MCP') }}</h1><p>{{ t('管理插件生命周期、MCP Host、权限和受控 Contribution。', 'Manage plugin lifecycles, MCP hosts, permissions, and controlled contributions.') }}</p></div> <div><h1>{{ t('Plugin 与 MCP', 'Plugins and MCP') }}</h1><p>{{ t('管理插件生命周期、MCP Host、权限和受控 Contribution。', 'Manage plugin lifecycles, MCP hosts, permissions, and controlled contributions.') }}</p></div>
@@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import type { ProviderConfig } from '@/contracts' import type { ProviderConfig } from '@/contracts'
import ProviderForm from './ProviderForm.vue' import ProviderForm from './ProviderForm.vue'
@@ -62,7 +65,7 @@ async function providerSaved(provider: ProviderConfig) {
if (provider.enabled) void providerStore.loadModels(provider.provider_id).catch(() => undefined) if (provider.enabled) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
} }
async function removeProvider(provider: ProviderConfig) { if (!confirm(`${t('确定删除 Provider', 'Delete Provider')}${provider.name}”?`)) return; try { await providerStore.deleteProvider(provider.provider_id) } catch (error) { providerAction.value = error instanceof Error ? error.message : t('删除失败', 'Delete failed') } } async function removeProvider(provider: ProviderConfig) { if (!(await askConfirm(`${t('确定删除 Provider', 'Delete Provider')}${provider.name}”?`))) return; try { await providerStore.deleteProvider(provider.provider_id) } catch (error) { providerAction.value = error instanceof Error ? error.message : t('删除失败', 'Delete failed') } }
async function testProvider(provider: ProviderConfig) { testResults.value[provider.provider_id] = t('测试中…', 'Testing…'); const result = await providerStore.testProvider(provider.provider_id); testResults.value[provider.provider_id] = result.success ? `${t('连接成功', 'Connection succeeded')}${result.latency_ms ? ` · ${result.latency_ms}ms` : ''}` : `${t('连接失败:', 'Connection failed: ')}${result.error}` } async function testProvider(provider: ProviderConfig) { testResults.value[provider.provider_id] = t('测试中…', 'Testing…'); const result = await providerStore.testProvider(provider.provider_id); testResults.value[provider.provider_id] = result.success ? `${t('连接成功', 'Connection succeeded')}${result.latency_ms ? ` · ${result.latency_ms}ms` : ''}` : `${t('连接失败:', 'Connection failed: ')}${result.error}` }
async function refreshModels(provider: ProviderConfig) { await providerStore.loadModels(provider.provider_id).catch(() => undefined) } async function refreshModels(provider: ProviderConfig) { await providerStore.loadModels(provider.provider_id).catch(() => undefined) }
async function chooseDefaultModel(provider: ProviderConfig, event: Event) { async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
@@ -74,6 +77,7 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<template> <template>
<section class="feature-page settings-page"> <section class="feature-page settings-page">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<header class="feature-header"><div><h1>{{ t('设置', 'Settings') }}</h1><p>{{ t('管理应用偏好、模型、索引、权限和本地 AI Core。', 'Manage application preferences, models, indexing, permissions, and the local AI Core.') }}</p></div></header> <header class="feature-header"><div><h1>{{ t('设置', 'Settings') }}</h1><p>{{ t('管理应用偏好、模型、索引、权限和本地 AI Core。', 'Manage application preferences, models, indexing, permissions, and the local AI Core.') }}</p></div></header>
<nav class="settings-nav"><button v-for="section in sections" :key="section.id" :class="{ active: activeSection === section.id }" @click="activeSection = section.id">{{ section.label }}</button></nav> <nav class="settings-nav"><button v-for="section in sections" :key="section.id" :class="{ active: activeSection === section.id }" @click="activeSection = section.id">{{ section.label }}</button></nav>
+5 -1
View File
@@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import { Lightning } from '@element-plus/icons-vue' import { Lightning } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue' import AppIcon from '@/components/common/AppIcon.vue'
import ExtensionInstallDialog from '@/components/common/ExtensionInstallDialog.vue' import ExtensionInstallDialog from '@/components/common/ExtensionInstallDialog.vue'
@@ -16,13 +19,14 @@ 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 : t('状态更新失败', 'Status update failed') } try { enabled ? await skillStore.disableSkill(skillId) : await skillStore.enableSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : t('状态更新失败', 'Status update failed') }
} }
async function uninstall(skillId: string, name: string) { async function uninstall(skillId: string, name: string) {
if (!confirm(`${t('确定卸载 Skill', 'Uninstall Skill')}${name}”?`)) return if (!(await askConfirm(`${t('确定卸载 Skill', 'Uninstall Skill')}${name}”?`))) return
try { await skillStore.uninstallSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : t('卸载失败', 'Uninstall failed') } try { await skillStore.uninstallSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : t('卸载失败', 'Uninstall failed') }
} }
</script> </script>
<template> <template>
<section class="feature-page"> <section class="feature-page">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<ExtensionInstallDialog v-if="showInstall" kind="Skill" :install="skillStore.installSkill" @close="showInstall = false" @installed="showInstall = false; actionError = ''" /> <ExtensionInstallDialog v-if="showInstall" kind="Skill" :install="skillStore.installSkill" @close="showInstall = false" @installed="showInstall = false; actionError = ''" />
<header class="feature-header"><div><h1>{{ t('Skill 管理', 'Skill Management') }}</h1><p>{{ t('查看工作流使用的 Tool、权限、检索配置和模型要求。', 'Review the tools, permissions, retrieval settings, and model requirements used by workflows.') }}</p></div><button class="button-primary" @click="showInstall = true">{{ t('安装 Skill', 'Install Skill') }}</button></header> <header class="feature-header"><div><h1>{{ t('Skill 管理', 'Skill Management') }}</h1><p>{{ t('查看工作流使用的 Tool、权限、检索配置和模型要求。', 'Review the tools, permissions, retrieval settings, and model requirements used by workflows.') }}</p></div><button class="button-primary" @click="showInstall = true">{{ t('安装 Skill', 'Install Skill') }}</button></header>
<div v-if="skillStore.error || actionError" class="error-banner">{{ skillStore.error || actionError }}</div> <div v-if="skillStore.error || actionError" class="error-banner">{{ skillStore.error || actionError }}</div>
+5 -1
View File
@@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import AppDialog from '@/components/common/AppDialog.vue' import AppDialog from '@/components/common/AppDialog.vue'
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import type { TaskItem, TaskStatus } from '@/contracts' import type { TaskItem, TaskStatus } from '@/contracts'
@@ -30,13 +33,14 @@ async function setStatus(task: TaskItem, status: TaskStatus) {
} }
async function remove(task: TaskItem) { async function remove(task: TaskItem) {
if (!confirm(`${t('确定删除任务', 'Delete task')}${task.title}”?`)) return if (!(await askConfirm(`${t('确定删除任务', 'Delete task')}${task.title}”?`))) return
try { await taskStore.deleteTask(task.task_id) } catch (error) { actionError.value = error instanceof Error ? error.message : t('任务删除失败', 'Failed to delete task') } try { await taskStore.deleteTask(task.task_id) } catch (error) { actionError.value = error instanceof Error ? error.message : t('任务删除失败', 'Failed to delete task') }
} }
</script> </script>
<template> <template>
<section class="feature-page tasks-page"> <section class="feature-page tasks-page">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<header class="feature-header"><div><h1>{{ t('任务', 'Tasks') }}</h1><p>{{ t('管理用户、笔记和 Agent 产生的行动项。', 'Manage action items created by users, notes, and agents.') }}</p></div><button class="button-primary" @click="resetForm(); showForm = true"> {{ t('新建任务', 'New task') }}</button></header> <header class="feature-header"><div><h1>{{ t('任务', 'Tasks') }}</h1><p>{{ t('管理用户、笔记和 Agent 产生的行动项。', 'Manage action items created by users, notes, and agents.') }}</p></div><button class="button-primary" @click="resetForm(); showForm = true"> {{ t('新建任务', 'New task') }}</button></header>
<div v-if="taskStore.error || actionError" class="error-banner">{{ taskStore.error || actionError }}</div> <div v-if="taskStore.error || actionError" class="error-banner">{{ taskStore.error || actionError }}</div>
<div v-if="taskStore.filteredTasks.length" class="task-list"> <div v-if="taskStore.filteredTasks.length" class="task-list">
@@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm, askPrompt } = useActionDialog()
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { noteOutline } from './outline' import { noteOutline } from './outline'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -168,7 +171,7 @@ function closeContextMenu() { contextTarget.value = null }
async function renameTarget() { async function renameTarget() {
const node = contextTarget.value const node = contextTarget.value
if (!node) return if (!node) return
const newName = window.prompt(t('新名称', 'New name'), node.name)?.trim() const newName = (await askPrompt(t('新名称', 'New name'), node.name))?.trim()
if (newName && newName !== node.name) { if (newName && newName !== node.name) {
const normalizedName = node.type === 'file' && !newName.toLowerCase().endsWith('.md') ? `${newName}.md` : newName const normalizedName = node.type === 'file' && !newName.toLowerCase().endsWith('.md') ? `${newName}.md` : newName
const oldPath = node.path const oldPath = node.path
@@ -190,7 +193,7 @@ async function renameTarget() {
async function deleteTarget() { async function deleteTarget() {
const node = contextTarget.value const node = contextTarget.value
if (!node) return if (!node) return
if (!window.confirm(`${t('确定要删除', 'Delete')}${node.name}”?`)) return closeContextMenu() if (!(await askConfirm(`${t('确定要删除', 'Delete')}${node.name}”?`))) return closeContextMenu()
await workspaceService.deleteFile(node.path) await workspaceService.deleteFile(node.path)
const activeWasRemoved = workspaceStore.closePath(node.path) const activeWasRemoved = workspaceStore.closePath(node.path)
workspaceStore.removeFromTree(node.path) workspaceStore.removeFromTree(node.path)
@@ -213,6 +216,7 @@ function containingFolder(path: string): string {
<template> <template>
<section class="file-tree-panel" @click="closeContextMenu" @keydown.esc="closeContextMenu" @wheel.passive="revealSearch"> <section class="file-tree-panel" @click="closeContextMenu" @keydown.esc="closeContextMenu" @wheel.passive="revealSearch">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<div class="workspace-tabs" role="tablist" :aria-label="t('工作区导航', 'Workspace navigation')" @keydown="navigateTabs"> <div class="workspace-tabs" role="tablist" :aria-label="t('工作区导航', 'Workspace navigation')" @keydown="navigateTabs">
<button id="workspace-files-tab" role="tab" aria-controls="workspace-files-panel" :aria-selected="activeTab === 'files'" :tabindex="activeTab === 'files' ? 0 : -1" @click="switchTab('files')">{{ t('文件', 'Files') }}</button> <button id="workspace-files-tab" role="tab" aria-controls="workspace-files-panel" :aria-selected="activeTab === 'files'" :tabindex="activeTab === 'files' ? 0 : -1" @click="switchTab('files')">{{ t('文件', 'Files') }}</button>
<button id="workspace-outline-tab" role="tab" aria-controls="workspace-outline-panel" :aria-selected="activeTab === 'outline'" :tabindex="activeTab === 'outline' ? 0 : -1" @click="switchTab('outline')">{{ t('大纲', 'Outline') }}</button> <button id="workspace-outline-tab" role="tab" aria-controls="workspace-outline-panel" :aria-selected="activeTab === 'outline'" :tabindex="activeTab === 'outline' ? 0 : -1" @click="switchTab('outline')">{{ t('大纲', 'Outline') }}</button>
+3
View File
@@ -84,6 +84,9 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
} }
export const apiClient = { export const apiClient = {
postBinary<T>(path: string, body: Blob) {
return request<T>(path, { method: 'POST', body, headers: { 'Content-Type': 'application/zip' } })
},
get<T>(path: string, options?: Omit<RequestOptions, 'method'>) { get<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
return request<T>(path, { ...options, method: 'GET' }) return request<T>(path, { ...options, method: 'GET' })
}, },
+5 -2
View File
@@ -50,8 +50,11 @@ export async function getPlugin(pluginId: string): Promise<Plugin> {
return toPlugin(await apiClient.get<ApiPlugin>(`/api/plugins/${pluginId}`)) return toPlugin(await apiClient.get<ApiPlugin>(`/api/plugins/${pluginId}`))
} }
export async function installPlugin(packagePath: string): Promise<Plugin> { export async function installPlugin(source: string | File): Promise<Plugin> {
return toPlugin(await apiClient.post<ApiPlugin>('/api/plugins/install', { package_path: packagePath })) const installed = typeof source === 'string'
? await apiClient.post<ApiPlugin>('/api/plugins/install', { package_path: source })
: await apiClient.postBinary<ApiPlugin>('/api/plugins/install-zip', source)
return toPlugin(installed)
} }
export async function enablePlugin(pluginId: string): Promise<Plugin> { export async function enablePlugin(pluginId: string): Promise<Plugin> {
+5 -2
View File
@@ -27,8 +27,11 @@ export async function getSkill(skillId: string): Promise<Skill> {
return toSkill(await apiClient.get<ApiSkill>(`/api/skills/${skillId}`)) return toSkill(await apiClient.get<ApiSkill>(`/api/skills/${skillId}`))
} }
export async function installSkill(packagePath: string): Promise<Skill> { export async function installSkill(source: string | File): Promise<Skill> {
return toSkill(await apiClient.post<ApiSkill>('/api/skills/install', { package_path: packagePath })) const installed = typeof source === 'string'
? await apiClient.post<ApiSkill>('/api/skills/install', { package_path: source })
: await apiClient.postBinary<ApiSkill>('/api/skills/install-zip', source)
return toSkill(installed)
} }
export async function enableSkill(skillId: string): Promise<Skill> { export async function enableSkill(skillId: string): Promise<Skill> {
+1 -1
View File
@@ -34,7 +34,7 @@ export const usePluginStore = defineStore('plugin', () => {
selectedPluginId.value = pluginId selectedPluginId.value = pluginId
} }
async function installPlugin(packagePath: string) { async function installPlugin(packagePath: string | File) {
const installed = await pluginService.installPlugin(packagePath) const installed = await pluginService.installPlugin(packagePath)
const index = plugins.value.findIndex((plugin) => plugin.plugin_id === installed.plugin_id) const index = plugins.value.findIndex((plugin) => plugin.plugin_id === installed.plugin_id)
if (index >= 0) plugins.value[index] = installed if (index >= 0) plugins.value[index] = installed
+1 -1
View File
@@ -34,7 +34,7 @@ export const useSkillStore = defineStore('skill', () => {
selectedSkillId.value = skillId selectedSkillId.value = skillId
} }
async function installSkill(packagePath: string) { async function installSkill(packagePath: string | File) {
const installed = await skillService.installSkill(packagePath) const installed = await skillService.installSkill(packagePath)
const index = skills.value.findIndex((skill) => skill.skill_id === installed.skill_id) const index = skills.value.findIndex((skill) => skill.skill_id === installed.skill_id)
if (index >= 0) skills.value[index] = installed if (index >= 0) skills.value[index] = installed