feat(extensions): add ZIP installation and unify action dialogs
This commit is contained in:
@@ -187,3 +187,19 @@ css_entry: styles/theme.css
|
||||
模型设置页将提供商、本地模型、用量统计分成独立卡片。用量趋势支持近 7 天、30 天、90 天及自定义时间,沿用提供商/模型/来源筛选;按本机 UTC 偏移分组(长区间自动合并到最多 90 组)。可切换输入、输出、总 Token 和请求次数,本地为芯片实色图例,提供商为连接斜纹图例。仅汇总已报告值,并提供覆盖数与可展开的数据表,缺失不补零。
|
||||
|
||||
纸间时光更新至 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 使用其独立的导入规则。
|
||||
|
||||
@@ -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
@@ -5,11 +5,13 @@ from contextlib import aclosing
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Header, Query
|
||||
from fastapi import APIRouter, Header, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.agent import AgentCapacityError, AgentRunNotFoundError
|
||||
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.contracts import (
|
||||
AgentRun,
|
||||
@@ -659,6 +661,27 @@ async def install_skill(request: ExtensionInstallRequest) -> Skill:
|
||||
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(
|
||||
"/skills/{skill_id}/enable",
|
||||
response_model=Skill,
|
||||
|
||||
@@ -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
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import AppDialog from './AppDialog.vue'
|
||||
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')
|
||||
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 keydown(event: KeyboardEvent) {
|
||||
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(() => {
|
||||
previousFocus = document.activeElement as HTMLElement | null
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<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 { useRouter } from 'vue-router'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
@@ -79,6 +83,7 @@ function hide() { open.value = false }
|
||||
async function execute(command: Command | undefined) {
|
||||
if (!command) return
|
||||
hide()
|
||||
await nextTick()
|
||||
try {
|
||||
await command.run()
|
||||
} catch (error) {
|
||||
@@ -87,7 +92,7 @@ async function execute(command: Command | undefined) {
|
||||
}
|
||||
|
||||
async function createNote() {
|
||||
const rawName = window.prompt(t('笔记名称', 'Note name'))?.trim()
|
||||
const rawName = (await askPrompt(t('笔记名称', 'Note name')))?.trim()
|
||||
if (!rawName) return
|
||||
const name = rawName.endsWith('.md') ? rawName : `${rawName}.md`
|
||||
const file = await workspaceService.createFile('/', name, `# ${rawName}\n\n`)
|
||||
@@ -148,6 +153,7 @@ async function applyPluginEffect(effect: PluginCommandEffect) {
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'p') {
|
||||
if (!open.value && document.querySelector('dialog[open]')) return
|
||||
event.preventDefault()
|
||||
open.value ? hide() : show()
|
||||
} else if (event.key === 'Escape' && open.value) {
|
||||
@@ -160,12 +166,13 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<div v-if="commandNotice" class="command-toast" role="status">
|
||||
<span>{{ commandNotice }}</span><button :aria-label="t('关闭通知', 'Close notification')" @click="commandNotice = ''">×</button>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="command-backdrop" @click.self="hide">
|
||||
<section class="command-palette" role="dialog" aria-modal="true" :aria-label="t('命令面板', 'Command palette')">
|
||||
<AppDialog v-if="open" :label="t('命令面板', 'Command palette')" @close="hide">
|
||||
<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])" />
|
||||
<p v-if="commandError" class="command-error">{{ commandError }}</p>
|
||||
<div class="command-list">
|
||||
@@ -176,15 +183,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
</div>
|
||||
<footer><span>Enter · {{ t('执行', 'Run') }}</span><span>Esc · {{ t('关闭', 'Close') }}</span></footer>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<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 { 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 { 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-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:hover, .command-list button:focus { outline: 0; background: var(--color-accent-soft); color: var(--color-accent-primary); }
|
||||
.command-list button:hover { transform: translateX(2px); }
|
||||
|
||||
@@ -6,12 +6,39 @@ import ExtensionInstallDialog from './ExtensionInstallDialog.vue'
|
||||
let wrapper: VueWrapper
|
||||
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 => {
|
||||
let complete!: () => void
|
||||
const install = vi.fn(() => new Promise<void>(resolve => { complete = resolve }))
|
||||
wrapper = mount(ExtensionInstallDialog, { props: { kind, install } })
|
||||
expect(wrapper.text()).toContain(`${kind.toLowerCase()}.yaml`)
|
||||
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('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 () => {
|
||||
const install = vi.fn().mockRejectedValueOnce(new Error('Manifest does not exist')).mockResolvedValueOnce(undefined)
|
||||
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('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
@@ -5,20 +5,38 @@ import AppDialog from './AppDialog.vue'
|
||||
import AppIcon from './AppIcon.vue'
|
||||
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 path = ref('')
|
||||
const mode = ref<'path' | 'zip'>('zip')
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const file = ref<File | null>(null)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const title = computed(() => t(`安装 ${props.kind}`, `Install ${props.kind}`))
|
||||
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() {
|
||||
if (busy.value || !path.value.trim()) return
|
||||
if (busy.value || !ready.value) return
|
||||
error.value = ''
|
||||
busy.value = true
|
||||
try {
|
||||
await props.install(path.value.trim())
|
||||
await props.install(mode.value === 'zip' ? file.value! : path.value.trim())
|
||||
emit('installed')
|
||||
} catch (reason) {
|
||||
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">
|
||||
<span class="badge info">{{ t('扩展安装', 'Extension installation') }}</span>
|
||||
<h2>{{ title }}</h2>
|
||||
<p class="muted">{{ t('从本地包目录安装,安装时会校验清单与依赖。', 'Install from a local package directory. The manifest and dependencies are checked during installation.') }}</p>
|
||||
<div class="package-source">
|
||||
<p class="muted">{{ t('导入 ZIP 或使用本地包目录,安装时会校验清单与依赖。', 'Import a ZIP or use a local directory. The manifest and dependencies are checked during installation.') }}</p>
|
||||
<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" />
|
||||
<strong>{{ t('本地包目录', 'Local package directory') }}</strong>
|
||||
<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>
|
||||
<input v-model="path" class="input" autofocus required :disabled="busy" :placeholder="t('粘贴本地包目录的完整路径', 'Paste the full package directory path')" aria-describedby="extension-path-help" />
|
||||
</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 v-if="error" class="error-banner" role="alert">{{ error }}</div>
|
||||
<p v-if="busy" class="muted" role="status">{{ t('正在校验并安装,请稍候…', 'Validating and installing…') }}</p>
|
||||
<footer class="install-actions">
|
||||
<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>
|
||||
</form>
|
||||
</AppDialog>
|
||||
@@ -63,4 +92,8 @@ h2 { margin: var(--space-sm) 0 var(--space-md); }
|
||||
.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); }
|
||||
.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>
|
||||
|
||||
@@ -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">
|
||||
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 { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Link } from '@element-plus/icons-vue'
|
||||
@@ -119,20 +122,28 @@ function runCommand(command: ToolbarCommand) {
|
||||
editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')?.focus()
|
||||
}
|
||||
|
||||
function applyLink() {
|
||||
async function applyLink() {
|
||||
if (!crepe) return
|
||||
// TODO(editor): 用受控 Element Plus 对话框替换 prompt,补充 URL 校验和键盘焦点管理。
|
||||
const href = window.prompt(t('请输入链接地址', 'Enter link address'), 'https://')?.trim()
|
||||
if (!href) return
|
||||
|
||||
crepe.editor.action((ctx) => {
|
||||
const editor = crepe
|
||||
const snapshot = editor.editor.action(ctx => {
|
||||
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)
|
||||
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 transaction = view.state.tr.insertText(label, from)
|
||||
transaction.setSelection(TextSelection.create(transaction.doc, from, from + label.length))
|
||||
const transaction = view.state.tr.insertText(text, from)
|
||||
transaction.setSelection(TextSelection.create(transaction.doc, from, from + text.length))
|
||||
view.dispatch(transaction)
|
||||
}
|
||||
return commands.call(toggleLinkCommand.key, { href })
|
||||
@@ -278,6 +289,7 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
|
||||
<template>
|
||||
<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')">
|
||||
<label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')">
|
||||
<span class="format-glyph heading-glyph">H</span>
|
||||
|
||||
@@ -29,7 +29,6 @@ async function render(items: McpServer[] = []) {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
})
|
||||
|
||||
describe('McpServersView', () => {
|
||||
@@ -78,7 +77,9 @@ describe('McpServersView', () => {
|
||||
vi.mocked(service.deleteMcpServer).mockResolvedValue({ status: 'completed' })
|
||||
await wrapper.findAll('button').find(button => button.text().includes('删除'))!.trigger('click')
|
||||
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')
|
||||
})
|
||||
|
||||
@@ -89,7 +90,10 @@ describe('McpServersView', () => {
|
||||
await wrapper.get('input[placeholder="network.request, notes.read"]').setValue('notes.read')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -125,6 +129,8 @@ describe('McpServersView', () => {
|
||||
expect(wrapper.get('.modal-card [role="alert"]').text()).toContain('服务器配置已保存,但密钥保存失败')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
await wrapper.get('.action-dialog').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(service.createMcpServer).toHaveBeenCalledTimes(1)
|
||||
expect(service.updateMcpServer).toHaveBeenCalledWith('new-server', expect.objectContaining({ version: 1 }))
|
||||
expect(service.putMcpServerSecret).toHaveBeenCalledTimes(2)
|
||||
@@ -144,6 +150,8 @@ describe('McpServersView', () => {
|
||||
vi.mocked(service.updateMcpServer).mockResolvedValue(server)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
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.putMcpServerSecret).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<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 { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Connection, Delete, EditPen, Plus, Refresh, VideoPlay } from '@element-plus/icons-vue'
|
||||
@@ -143,7 +146,7 @@ async function save() {
|
||||
error.value = ''
|
||||
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 (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'
|
||||
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
|
||||
@@ -192,7 +195,7 @@ function executionChanged(server: McpServer, input: McpServerInput) {
|
||||
async function approve(server: McpServer): Promise<McpServer | null> {
|
||||
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.')
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -206,7 +209,7 @@ async function act(server: McpServer, action: string, operation: (server: McpSer
|
||||
}
|
||||
|
||||
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() }
|
||||
catch (cause) { error.value = message(cause, t('删除失败', 'Delete failed')) } finally { busy.value = '' }
|
||||
}
|
||||
@@ -226,6 +229,7 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<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 { useRoute } from 'vue-router'
|
||||
import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService'
|
||||
@@ -48,7 +51,7 @@ async function refresh() {
|
||||
if (!stopped) timer = setTimeout(refresh, 2000)
|
||||
}
|
||||
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 = []
|
||||
}
|
||||
async function action(work: () => Promise<void>) {
|
||||
@@ -77,7 +80,7 @@ async function purge() {
|
||||
if (!selected.value) return
|
||||
await action(async () => {
|
||||
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)
|
||||
selected.value = await mediaService.get(selected.value!.job_id)
|
||||
dirty.value = false; history.value = []; notice.value = t('附件与转写内容已清理', 'Attachment and transcript content were removed')
|
||||
@@ -110,6 +113,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<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">
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<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 { computed, ref, watch } from '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 = '' }
|
||||
}
|
||||
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
|
||||
feedback()
|
||||
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 }
|
||||
secrets.value[field.key] = ''
|
||||
notice.value = field.label + t('已删除。', ' deleted.')
|
||||
@@ -130,6 +136,7 @@ async function deleteSecret(field: PluginSettingField) {
|
||||
|
||||
<template>
|
||||
<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')">
|
||||
<button v-for="tab in tabs" :key="tab.id" :class="{ active: activeTab === tab.id }" @click="selectTab(tab.id)">{{ tab.label }}</button>
|
||||
</nav>
|
||||
|
||||
@@ -54,13 +54,17 @@ it.each(['save', 'delete'] as const)('ignores old secret %s responses after swit
|
||||
let finish!: () => void
|
||||
vi.mocked(service.putPluginSecret).mockReturnValue(new Promise(resolve => { finish = () => resolve({ plugin_id: 'demo', key: 'token', configured: true }) }))
|
||||
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 }) }))
|
||||
}
|
||||
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
|
||||
await flushPromises()
|
||||
await wrapper.get('input[type="password"]').setValue('old-fixture-value')
|
||||
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))
|
||||
await wrapper.setProps({ pluginId: 'other' })
|
||||
await flushPromises()
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<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 type { PluginSettingsSchema, PluginSettingField } from '@/contracts'
|
||||
import {
|
||||
@@ -106,9 +109,10 @@ async function saveSecret(key: string) {
|
||||
|
||||
async function clearSecret(key: string) {
|
||||
if (!schema.value || isSaving.value) return
|
||||
if (!confirm(`确认删除 " ${key} " 的配置?`)) return
|
||||
const version = loadVersion
|
||||
const pluginId = props.pluginId
|
||||
if (!(await askConfirm(`确认删除 " ${key} " 的配置?`))) return
|
||||
if (version !== loadVersion || pluginId !== props.pluginId) return
|
||||
isSaving.value = true
|
||||
saveError.value = ''
|
||||
try {
|
||||
@@ -145,6 +149,7 @@ watch(() => props.pluginId, load)
|
||||
|
||||
<template>
|
||||
<div class="plugin-settings-panel">
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<div v-if="isLoading" class="loading">加载设置中…</div>
|
||||
|
||||
<template v-else-if="schema && schema.fields.length > 0">
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<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 AppIcon from '@/components/common/AppIcon.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[]) {
|
||||
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) }
|
||||
catch (error) { actionError.value = error instanceof Error ? error.message : t('授权失败', 'Authorization failed') }
|
||||
}
|
||||
|
||||
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) }
|
||||
catch (error) { actionError.value = error instanceof Error ? error.message : t('卸载失败', 'Uninstall failed') }
|
||||
}
|
||||
@@ -61,6 +64,7 @@ const hasCommandContribution = computed(() =>
|
||||
|
||||
<template>
|
||||
<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 = ''" />
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<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 type { ProviderConfig } from '@/contracts'
|
||||
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)
|
||||
}
|
||||
|
||||
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 refreshModels(provider: ProviderConfig) { await providerStore.loadModels(provider.provider_id).catch(() => undefined) }
|
||||
async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
||||
@@ -74,6 +77,7 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<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 AppIcon from '@/components/common/AppIcon.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') }
|
||||
}
|
||||
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') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 = ''" />
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<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 { onMounted, reactive, ref } from 'vue'
|
||||
import type { TaskItem, TaskStatus } from '@/contracts'
|
||||
@@ -30,13 +33,14 @@ async function setStatus(task: TaskItem, status: TaskStatus) {
|
||||
}
|
||||
|
||||
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') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<div v-if="taskStore.error || actionError" class="error-banner">{{ taskStore.error || actionError }}</div>
|
||||
<div v-if="taskStore.filteredTasks.length" class="task-list">
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<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 { noteOutline } from './outline'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -168,7 +171,7 @@ function closeContextMenu() { contextTarget.value = null }
|
||||
async function renameTarget() {
|
||||
const node = contextTarget.value
|
||||
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) {
|
||||
const normalizedName = node.type === 'file' && !newName.toLowerCase().endsWith('.md') ? `${newName}.md` : newName
|
||||
const oldPath = node.path
|
||||
@@ -190,7 +193,7 @@ async function renameTarget() {
|
||||
async function deleteTarget() {
|
||||
const node = contextTarget.value
|
||||
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)
|
||||
const activeWasRemoved = workspaceStore.closePath(node.path)
|
||||
workspaceStore.removeFromTree(node.path)
|
||||
@@ -213,6 +216,7 @@ function containingFolder(path: string): string {
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -84,6 +84,9 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
}
|
||||
|
||||
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'>) {
|
||||
return request<T>(path, { ...options, method: 'GET' })
|
||||
},
|
||||
|
||||
@@ -50,8 +50,11 @@ export async function getPlugin(pluginId: string): Promise<Plugin> {
|
||||
return toPlugin(await apiClient.get<ApiPlugin>(`/api/plugins/${pluginId}`))
|
||||
}
|
||||
|
||||
export async function installPlugin(packagePath: string): Promise<Plugin> {
|
||||
return toPlugin(await apiClient.post<ApiPlugin>('/api/plugins/install', { package_path: packagePath }))
|
||||
export async function installPlugin(source: string | File): Promise<Plugin> {
|
||||
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> {
|
||||
|
||||
@@ -27,8 +27,11 @@ export async function getSkill(skillId: string): Promise<Skill> {
|
||||
return toSkill(await apiClient.get<ApiSkill>(`/api/skills/${skillId}`))
|
||||
}
|
||||
|
||||
export async function installSkill(packagePath: string): Promise<Skill> {
|
||||
return toSkill(await apiClient.post<ApiSkill>('/api/skills/install', { package_path: packagePath }))
|
||||
export async function installSkill(source: string | File): Promise<Skill> {
|
||||
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> {
|
||||
|
||||
@@ -34,7 +34,7 @@ export const usePluginStore = defineStore('plugin', () => {
|
||||
selectedPluginId.value = pluginId
|
||||
}
|
||||
|
||||
async function installPlugin(packagePath: string) {
|
||||
async function installPlugin(packagePath: string | File) {
|
||||
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
|
||||
|
||||
@@ -34,7 +34,7 @@ export const useSkillStore = defineStore('skill', () => {
|
||||
selectedSkillId.value = skillId
|
||||
}
|
||||
|
||||
async function installSkill(packagePath: string) {
|
||||
async function installSkill(packagePath: string | File) {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user