feat: 扩展主题界面适配与 Agent 服务工具
This commit is contained in:
@@ -55,11 +55,22 @@ const toolLabels: Record<string, string> = {
|
||||
'notes.update': '更新笔记',
|
||||
'notes.list': '列出笔记',
|
||||
'notes.move': '移动笔记',
|
||||
'notes.rename': '重命名笔记',
|
||||
'notes.delete': '删除笔记',
|
||||
'tasks.create': '创建任务',
|
||||
'tasks.update': '更新任务',
|
||||
'tasks.list': '列出任务',
|
||||
'tasks.read': '读取任务',
|
||||
'tasks.delete': '删除任务',
|
||||
'attachments.read': '读取附件',
|
||||
'audio.transcribe': '音频转写',
|
||||
'audio.transcription_status': '读取转写结果',
|
||||
'function_plot.compose': '生成函数图',
|
||||
'skills.list': '列出自定义 Skill',
|
||||
'skills.create': '创建自定义 Skill',
|
||||
'skills.update': '更新自定义 Skill',
|
||||
'plugins.list': '列出 Plugin',
|
||||
'plugins.create': '创建声明式 Plugin',
|
||||
'text.uppercase': '文本转大写',
|
||||
}
|
||||
|
||||
@@ -76,11 +87,22 @@ const toolDescriptions: Record<string, string> = {
|
||||
'notes.update': '更新已有 Markdown 笔记。',
|
||||
'notes.list': '按文件夹和标签筛选并列出笔记摘要。',
|
||||
'notes.move': '移动笔记到其他文件夹并保留笔记 ID。',
|
||||
'notes.rename': '重命名 Markdown 文件并保留笔记 ID 和索引身份。',
|
||||
'notes.delete': '删除当前知识库中的指定笔记。',
|
||||
'tasks.create': '创建并持久化任务。',
|
||||
'tasks.update': '更新已有任务。',
|
||||
'tasks.list': '列出已持久化的任务。',
|
||||
'tasks.read': '根据任务 ID 读取完整任务。',
|
||||
'tasks.delete': '删除指定的持久化任务。',
|
||||
'attachments.read': '读取由宿主管理的 UTF-8 附件。',
|
||||
'audio.transcribe': '将音频转写为文本,按模型路由使用 API 或本地后端。',
|
||||
'audio.transcription_status': '读取转写任务状态、文本、分段和错误信息。',
|
||||
'function_plot.compose': '根据表达式生成并校验安全的 function-plot Markdown 代码块。',
|
||||
'skills.list': '列出当前知识库中的自定义 Skill 及依赖状态。',
|
||||
'skills.create': '把提示词、工具和权限声明保存为当前知识库的自定义 Skill。',
|
||||
'skills.update': '使用当前版本号更新自定义 Skill,防止覆盖并发修改。',
|
||||
'plugins.list': '列出已安装 Plugin 及生命周期状态。',
|
||||
'plugins.create': '生成并安装仅使用宿主白名单处理器的声明式 Plugin;创建后需在 Plugin 页面检查并启用。',
|
||||
'text.uppercase': '将输入文本中的字母转换为大写。',
|
||||
}
|
||||
|
||||
@@ -105,9 +127,12 @@ const permissionLabels: Record<string, string> = {
|
||||
'notes.search': '搜索笔记',
|
||||
'notes.read': '读取笔记',
|
||||
'notes.write': '修改笔记',
|
||||
'notes.delete': '删除笔记',
|
||||
'tasks.read': '读取任务',
|
||||
'tasks.write': '修改任务',
|
||||
'attachments.read': '读取附件',
|
||||
'skills.write': '创建或更新自定义 Skill',
|
||||
'plugins.write': '创建声明式 Plugin',
|
||||
'network.request': '访问网络',
|
||||
'secrets.use': '使用密钥',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import FunctionPlotHelpView from './FunctionPlotHelpView.vue'
|
||||
|
||||
const render = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/services/functionPlotService', () => ({ renderFunctionPlot: render }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
setActivePinia(createPinia())
|
||||
render.mockReset().mockResolvedValue({ svg: '<svg class="function-plot-svg"></svg>', warnings: [], nodeCount: 8 })
|
||||
})
|
||||
|
||||
describe('Function Plot tutorial', () => {
|
||||
it('documents the fenced syntax and renders the editable example', async () => {
|
||||
const wrapper = mount(FunctionPlotHelpView)
|
||||
expect(wrapper.text()).toContain('Function Plot 教程')
|
||||
expect(wrapper.text()).toContain('domain: -10, 10')
|
||||
expect(wrapper.text()).toContain('sin(x)')
|
||||
expect(wrapper.text()).toContain('最多 16 条表达式')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await flushPromises()
|
||||
expect(render).toHaveBeenCalledWith(expect.stringContaining('y = sin(x)'), 'light')
|
||||
expect(wrapper.get('.svg-host').html()).toContain('function-plot-svg')
|
||||
wrapper.unmount()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { renderFunctionPlot } from '@/services/functionPlotService'
|
||||
|
||||
const theme = useThemeStore()
|
||||
const source = ref(`domain: -6, 6
|
||||
range: -2, 8
|
||||
xlabel: x
|
||||
ylabel: y
|
||||
grid: true
|
||||
y = sin(x)
|
||||
y = x^2 / 5`)
|
||||
const svg = ref('')
|
||||
const warnings = ref<string[]>([])
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let generation = 0
|
||||
|
||||
async function renderPreview() {
|
||||
const current = ++generation
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await renderFunctionPlot(source.value, theme.currentThemeId)
|
||||
if (current !== generation) return
|
||||
svg.value = result.svg
|
||||
warnings.value = result.warnings
|
||||
} catch (cause) {
|
||||
if (current !== generation) return
|
||||
svg.value = ''
|
||||
warnings.value = []
|
||||
error.value = cause instanceof Error ? cause.message : t('函数图渲染失败', 'Function Plot rendering failed')
|
||||
} finally {
|
||||
if (current === generation) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([source, () => theme.currentThemeId], () => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => void renderPreview(), 180)
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(() => { clearTimeout(timer); generation += 1 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="feature-page function-plot-help">
|
||||
<header class="feature-header">
|
||||
<div>
|
||||
<span class="badge info">Markdown · Function Plot</span>
|
||||
<h1>{{ t('Function Plot 教程', 'Function Plot Tutorial') }}</h1>
|
||||
<p>{{ t('用安全的数学表达式在笔记中绘制静态函数图。预览、HTML 和 PDF 导出使用同一套解析规则。', 'Draw static function graphs in notes with safe mathematical expressions. Preview, HTML, and PDF export share the same parser.') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="tutorial-layout">
|
||||
<main class="tutorial-content">
|
||||
<section class="panel lesson">
|
||||
<span class="step">01</span>
|
||||
<h2>{{ t('插入代码围栏', 'Insert a fenced block') }}</h2>
|
||||
<p>{{ t('语言标记使用 function-plot。每个非空表达式行都绘制一条曲线。', 'Use function-plot as the language tag. Every non-empty expression line draws one curve.') }}</p>
|
||||
<pre><code>```function-plot
|
||||
domain: -10, 10
|
||||
y = sin(x)
|
||||
y = x^2 / 8
|
||||
```</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="panel lesson">
|
||||
<span class="step">02</span>
|
||||
<h2>{{ t('设置坐标范围', 'Configure the axes') }}</h2>
|
||||
<div class="directive-grid">
|
||||
<div class="surface-nested"><code>domain: -10, 10</code><span>{{ t('横轴范围', 'X-axis range') }}</span></div>
|
||||
<div class="surface-nested"><code>range: -5, 20</code><span>{{ t('可选纵轴范围', 'Optional Y-axis range') }}</span></div>
|
||||
<div class="surface-nested"><code>xlabel: 时间</code><span>{{ t('横轴名称', 'X-axis label') }}</span></div>
|
||||
<div class="surface-nested"><code>ylabel: 距离</code><span>{{ t('纵轴名称', 'Y-axis label') }}</span></div>
|
||||
<div class="surface-nested"><code>grid: false</code><span>{{ t('显示或隐藏网格', 'Show or hide the grid') }}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel lesson">
|
||||
<span class="step">03</span>
|
||||
<h2>{{ t('可用数学语法', 'Supported math syntax') }}</h2>
|
||||
<p>{{ t('支持 +、-、*、/、^,变量 x,常量 pi、e,以及以下单参数函数。2x、2(x+1) 等隐式乘法也可使用。', 'Use +, -, *, /, ^, variable x, constants pi and e, and the single-argument functions below. Implicit multiplication such as 2x and 2(x+1) is also supported.') }}</p>
|
||||
<div class="tag-list function-list">
|
||||
<code v-for="name in ['sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'sinh', 'cosh', 'tanh', 'exp', 'log', 'ln', 'log10', 'log2', 'sqrt', 'abs']" :key="name">{{ name }}(x)</code>
|
||||
</div>
|
||||
<p class="notice-banner safety-note">{{ t('表达式由白名单解析器计算,不执行 JavaScript、Python、属性访问或任意函数调用。单个图块最多 16 条表达式。', 'Expressions are evaluated by an allowlist parser. JavaScript, Python, property access, and arbitrary calls are never executed. Each plot supports up to 16 expressions.') }}</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside class="panel playground">
|
||||
<div class="playground-heading"><div><span class="step">04</span><h2>{{ t('即时练习', 'Try it live') }}</h2></div><span v-if="loading" class="badge">{{ t('渲染中', 'Rendering') }}</span></div>
|
||||
<label class="field">
|
||||
<span>{{ t('函数图源码', 'Function Plot source') }}</span>
|
||||
<textarea v-model="source" class="textarea plot-source" spellcheck="false" />
|
||||
</label>
|
||||
<div class="plot-preview" :aria-busy="loading">
|
||||
<div v-if="svg" class="svg-host" v-html="svg" />
|
||||
<div v-else class="empty-state"><strong>{{ t('暂无预览', 'No preview') }}</strong><span>{{ error || t('输入有效表达式后会在这里显示。', 'Enter a valid expression to render it here.') }}</span></div>
|
||||
</div>
|
||||
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
|
||||
<ul v-if="warnings.length" class="warning-list">
|
||||
<li v-for="warning in warnings" :key="warning">{{ warning }}</li>
|
||||
</ul>
|
||||
<p class="subtle">{{ t('也可以在智能体中启用 function_plot.compose,让 Agent 生成并校验同样的代码块。', 'Enable function_plot.compose in an Agent to generate and validate the same fenced block.') }}</p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.function-plot-help { container-type: inline-size; }
|
||||
.feature-header h1 { margin-top: var(--space-sm); }
|
||||
.tutorial-layout { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(340px, .85fr); gap: var(--space-lg); max-width: 1180px; margin-inline: auto; align-items: start; }
|
||||
.tutorial-content { display: grid; gap: var(--space-lg); }
|
||||
.lesson { position: relative; display: grid; gap: var(--space-md); }
|
||||
.step { color: var(--color-accent-primary); font: 700 var(--font-size-xs)/1 var(--font-ui-mono); letter-spacing: .12em; }
|
||||
.lesson p { color: var(--color-text-secondary); line-height: var(--line-height-relaxed); }
|
||||
pre { overflow: auto; padding: var(--space-lg); border: 1px solid var(--color-code-border); border-radius: var(--radius-md); background: var(--color-code-background); color: var(--color-code-text); }
|
||||
code { font-family: var(--font-ui-mono); }
|
||||
.directive-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: var(--space-sm); }
|
||||
.directive-grid > div { display: grid; gap: var(--space-xs); padding: var(--space-md); }
|
||||
.directive-grid span { color: var(--color-text-secondary); font-size: var(--font-size-sm); }
|
||||
.function-list code { padding: 5px 8px; border: 1px solid var(--color-border-subtle); border-radius: var(--radius-sm); background: var(--color-background-secondary); color: var(--color-accent-primary); }
|
||||
.safety-note { margin: 0; }
|
||||
.playground { position: sticky; top: var(--space-lg); display: grid; gap: var(--space-md); }
|
||||
.playground-heading, .playground-heading > div { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); }
|
||||
.playground-heading > div { justify-content: flex-start; }
|
||||
.plot-source { min-height: 190px; font-family: var(--font-ui-mono); }
|
||||
.plot-preview { min-height: 280px; overflow: auto; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-plot-background); }
|
||||
.svg-host { min-width: 620px; line-height: 0; }
|
||||
.svg-host :deep(svg) { display: block; width: 100%; height: auto; }
|
||||
.plot-preview .empty-state { min-height: 278px; border: 0; border-radius: 0; }
|
||||
.warning-list { display: grid; gap: var(--space-xs); color: var(--color-warning); font-size: var(--font-size-sm); }
|
||||
@container (max-width: 860px) { .tutorial-layout { grid-template-columns: 1fr; } .playground { position: static; } }
|
||||
</style>
|
||||
@@ -355,7 +355,7 @@ watch(() => props.pluginId, load)
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-inverse);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 1px 3px color-mix(in srgb, var(--color-text-primary) 20%, transparent);
|
||||
transition: transform var(--motion-fast);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ const source = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.provider-logo { display: inline-flex; flex: 0 0 28px; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 7px; background: #fff; color: #252b36; }
|
||||
.provider-logo { display: inline-flex; flex: 0 0 28px; align-items: center; justify-content: center; width: 28px; height: 28px; border: 1px solid var(--color-border-subtle); border-radius: 7px; background: var(--color-brand-surface); color: var(--color-brand-ink); }
|
||||
img { display: block; object-fit: contain; }
|
||||
.dark-logo { background: #111; }
|
||||
.dark-logo { background: var(--color-brand-surface-dark); }
|
||||
.custom-logo { font-size: 23px; line-height: 1; }
|
||||
</style>
|
||||
|
||||
@@ -90,7 +90,7 @@ const maximum = computed(() => Math.max(1, ...props.buckets.flatMap(bucket => so
|
||||
<style scoped>
|
||||
.usage-bar.stacked { display: flex; flex-direction: column-reverse; background: transparent; overflow: hidden; }
|
||||
.model-segment { width: 100%; flex-shrink: 0; border-top: 1px solid var(--color-surface-primary); box-sizing: border-box; }
|
||||
.api .model-segment { background-image: repeating-linear-gradient(45deg, transparent 0 4px, #ffffff30 4px 7px); }
|
||||
.api .model-segment { background-image: repeating-linear-gradient(45deg, transparent 0 4px, var(--color-highlight-overlay) 4px 7px); }
|
||||
.model-legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 11px; margin-top: 14px; }.model-legend span { display: inline-flex; align-items: center; gap: 5px; overflow-wrap: anywhere; }.model-legend i { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }.model-readout { display: block; }
|
||||
|
||||
.chart-layout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(220px, 1fr); gap: 24px; margin-top: 16px; }
|
||||
|
||||
@@ -9,7 +9,7 @@ import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
const permissions = [
|
||||
'notes.read', 'notes.search', 'notes.write', 'notes.delete', 'tasks.read', 'tasks.write',
|
||||
'attachments.read', 'network.request', 'secrets.use', 'ui.command', 'ui.settings', 'ui.sidebar',
|
||||
'attachments.read', 'skills.write', 'plugins.write', 'network.request', 'secrets.use', 'ui.command', 'ui.settings', 'ui.sidebar',
|
||||
]
|
||||
const capabilities = [
|
||||
'chat', 'vision', 'tool_calling', 'reasoning', 'streaming', 'structured_output',
|
||||
|
||||
@@ -68,7 +68,7 @@ async function remove(task: TaskItem) {
|
||||
.task-card { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: var(--space-md); }
|
||||
.status-check { width: 28px; height: 28px; border: 2px solid var(--color-border-default); border-radius: var(--radius-full); transition: border-color var(--motion-fast), background-color var(--motion-fast), color var(--motion-fast), transform var(--motion-fast); }
|
||||
.status-check:hover { border-color: var(--color-success); transform: scale(1.06); }
|
||||
.status-check.done { border-color: var(--color-success); background: var(--color-success); color: white; box-shadow: 0 3px 10px color-mix(in srgb, var(--color-success) 24%, transparent); }
|
||||
.status-check.done { border-color: var(--color-success); background: var(--color-success); color: var(--color-on-success); box-shadow: 0 3px 10px color-mix(in srgb, var(--color-success) 24%, transparent); }
|
||||
.task-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-sm); }
|
||||
.task-content p { margin: var(--space-xs) 0; }
|
||||
.task-content .subtle { display: flex; flex-wrap: wrap; gap: var(--space-md); }
|
||||
|
||||
Reference in New Issue
Block a user