diff --git a/backend/app/agent/permissions.py b/backend/app/agent/permissions.py index b0d7c8c..7451bcd 100644 --- a/backend/app/agent/permissions.py +++ b/backend/app/agent/permissions.py @@ -21,6 +21,8 @@ KNOWN_PERMISSIONS = frozenset( "tasks.read", "tasks.write", "attachments.read", + "skills.write", + "plugins.write", "network.request", "secrets.use", "ui.command", @@ -40,6 +42,8 @@ class PermissionPolicy: "tasks.read": PermissionMode.allow, "tasks.write": PermissionMode.confirm, "attachments.read": PermissionMode.allow, + "skills.write": PermissionMode.confirm, + "plugins.write": PermissionMode.confirm, "network.request": PermissionMode.confirm, "secrets.use": PermissionMode.confirm, "ui.command": PermissionMode.allow, diff --git a/backend/app/agent/service_tools.py b/backend/app/agent/service_tools.py new file mode 100644 index 0000000..eeaa846 --- /dev/null +++ b/backend/app/agent/service_tools.py @@ -0,0 +1,306 @@ +"""Agent tools backed by existing OpenNexus application services. + +The tools in this module stay inside the same validation, permission and audit +pipeline as the original note tools. Plugin authoring is deliberately limited +to the host's declarative handlers: it cannot write or launch arbitrary code. +""" + +from __future__ import annotations + +import json +import shutil +from typing import Literal + +import yaml +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from app.agent.permissions import KNOWN_PERMISSIONS +from app.agent.tools import ToolExecutionContext, ToolExecutionError, ToolRegistry +from app.contracts import ModelCapability, RetrievalConfig, ToolDefinition, UserSkillWriteRequest +from app.extensions.errors import ExtensionError +from app.plot.parser import parse_source +from app.services import note_service, task_service, transcription_service, user_skills + + +class ServiceToolArguments(BaseModel): + model_config = ConfigDict(extra="forbid", allow_inf_nan=False) + + +class NoteRenameArguments(ServiceToolArguments): + note_id: str = Field(min_length=1) + file_name: str = Field(min_length=1, max_length=255) + + +class NoteDeleteArguments(ServiceToolArguments): + note_id: str = Field(min_length=1) + + +class TaskReadArguments(ServiceToolArguments): + task_id: str = Field(min_length=1) + + +class TaskDeleteArguments(ServiceToolArguments): + task_id: str = Field(min_length=1) + + +class TranscriptionStatusArguments(ServiceToolArguments): + job_id: str = Field(min_length=1, max_length=128) + + +class FunctionPlotComposeArguments(ServiceToolArguments): + expressions: list[str] = Field(min_length=1, max_length=16) + domain: tuple[float, float] = (-10.0, 10.0) + y_range: tuple[float, float] | None = None + xlabel: str | None = Field(default=None, max_length=80) + ylabel: str | None = Field(default=None, max_length=80) + grid: bool = True + + @field_validator("expressions") + @classmethod + def validate_expressions(cls, values: list[str]) -> list[str]: + cleaned = [value.strip() for value in values] + if any(not value or len(value) > 2000 for value in cleaned): + raise ValueError("each expression must contain 1 to 2000 characters") + return cleaned + + @model_validator(mode="after") + def validate_ranges(self): + for name, value in (("domain", self.domain), ("y_range", self.y_range)): + if value is not None and (value[0] >= value[1] or max(abs(value[0]), abs(value[1])) > 1_000_000): + raise ValueError(f"{name} must be an increasing finite range within ±1000000") + return self + + +class SkillListArguments(ServiceToolArguments): + limit: int = Field(default=50, ge=1, le=100) + offset: int = Field(default=0, ge=0) + + +class SkillWriteFields(ServiceToolArguments): + name: str = Field(min_length=1, max_length=128) + description: str = Field(default="", max_length=2000) + prompt: str = Field(default="", max_length=64000) + tools: list[str] = Field(default_factory=list, max_length=64) + permissions: list[str] = Field(default_factory=list, max_length=32) + retrieval_top_k: int = Field(default=10, ge=1, le=50) + retrieval_rerank: bool = True + retrieval_citation: bool = True + required_capabilities: list[ModelCapability] = Field(default_factory=list, max_length=16) + + +class SkillCreateArguments(SkillWriteFields): + pass + + +class SkillUpdateArguments(SkillWriteFields): + skill_id: str = Field(pattern=r"^user_skill_[0-9a-f]{32}$") + revision: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class PluginToolDraft(ServiceToolArguments): + name: str = Field(pattern=r"^[a-z0-9][a-z0-9._-]*$", max_length=128) + description: str = Field(min_length=1, max_length=1000) + handler: Literal["echo", "uppercase"] = "echo" + permission: str | None = None + + @field_validator("permission") + @classmethod + def validate_permission(cls, value: str | None) -> str | None: + if value is not None and value not in KNOWN_PERMISSIONS: + raise ValueError("unknown permission") + return value + + +class PluginCreateArguments(ServiceToolArguments): + plugin_id: str = Field(pattern=r"^[a-z0-9][a-z0-9._-]*$", max_length=80) + name: str = Field(min_length=1, max_length=128) + version: str = Field(default="1.0.0", pattern=r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") + description: str = Field(default="", max_length=2000) + tools: list[PluginToolDraft] = Field(min_length=1, max_length=8) + + @model_validator(mode="after") + def validate_tools(self): + names = [tool.name for tool in self.tools] + if len(names) != len(set(names)): + raise ValueError("plugin tool names must be unique") + prefix = f"{self.plugin_id}." + if any(not name.startswith(prefix) for name in names): + raise ValueError(f"plugin tool names must start with {prefix}") + return self + + +class PluginListArguments(ServiceToolArguments): + pass + + +async def compose_function_plot(arguments: FunctionPlotComposeArguments, _: ToolExecutionContext) -> dict: + lines = [f"domain: {arguments.domain[0]:g}, {arguments.domain[1]:g}"] + if arguments.y_range is not None: + lines.append(f"range: {arguments.y_range[0]:g}, {arguments.y_range[1]:g}") + if arguments.xlabel: + lines.append(f"xlabel: {arguments.xlabel}") + if arguments.ylabel: + lines.append(f"ylabel: {arguments.ylabel}") + lines.append(f"grid: {'true' if arguments.grid else 'false'}") + lines.extend(f"y = {expression}" for expression in arguments.expressions) + source = "\n".join(lines) + parsed = parse_source(source) + if parsed.plot is None: + message = "; ".join(item.message for item in parsed.diagnostics) or "Function Plot validation failed" + raise ToolExecutionError("FUNCTION_PLOT_INVALID", message) + return { + "markdown": f"```function-plot\n{source}\n```", + "source": source, + "expression_count": len(parsed.plot.expressions), + "node_count": parsed.plot.node_count, + "diagnostics": [item.model_dump(mode="json") for item in parsed.diagnostics], + "persisted": False, + } + + +def _skill_request(arguments: SkillWriteFields, revision: str = "") -> UserSkillWriteRequest: + return UserSkillWriteRequest( + revision=revision, + name=arguments.name, + description=arguments.description, + prompt=arguments.prompt, + tools=arguments.tools, + permissions=arguments.permissions, + retrieval=RetrievalConfig( + top_k=arguments.retrieval_top_k, + rerank=arguments.retrieval_rerank, + citation=arguments.retrieval_citation, + ), + required_capabilities=arguments.required_capabilities, + ) + + +def _register(registry: ToolRegistry, name: str, description: str, model: type[BaseModel], executor, permission: str | None = None) -> None: + registry.register( + ToolDefinition(name=name, description=description, parameters=model.model_json_schema(), permission=permission), + model, + executor, + ) + + +def register_service_tools(registry: ToolRegistry, plugins) -> None: + """Register tools that need the completed Plugin runtime or the current registry.""" + + async def rename_note(arguments: NoteRenameArguments, _: ToolExecutionContext) -> dict: + return (await note_service.rename_note(arguments.note_id, file_name=arguments.file_name)).model_dump(mode="json") + + async def delete_note(arguments: NoteDeleteArguments, _: ToolExecutionContext) -> dict: + return {"deleted": await note_service.delete_note(arguments.note_id), "note_id": arguments.note_id} + + def read_task(arguments: TaskReadArguments, _: ToolExecutionContext) -> dict: + task = task_service.get_task(arguments.task_id) + if task is None: + raise LookupError(f"Task does not exist: {arguments.task_id}") + return task.model_dump(mode="json") + + def delete_task(arguments: TaskDeleteArguments, _: ToolExecutionContext) -> dict: + return {"deleted": task_service.delete_task(arguments.task_id), "task_id": arguments.task_id} + + def transcription_status(arguments: TranscriptionStatusArguments, _: ToolExecutionContext) -> dict: + return transcription_service.require_job(arguments.job_id).model_dump(mode="json") + + def list_skills(arguments: SkillListArguments, _: ToolExecutionContext) -> dict: + items, total = user_skills.list_user_skills(registry, limit=arguments.limit, offset=arguments.offset) + return { + "items": [item.model_dump(mode="json") for item in items], + "page": {"total": total, "limit": arguments.limit, "offset": arguments.offset}, + "scope": "current_vault", + } + + def create_skill(arguments: SkillCreateArguments, _: ToolExecutionContext) -> dict: + return user_skills.create_user_skill(_skill_request(arguments), registry).model_dump(mode="json") + + def update_skill(arguments: SkillUpdateArguments, _: ToolExecutionContext) -> dict: + return user_skills.update_user_skill( + arguments.skill_id, _skill_request(arguments, arguments.revision), registry + ).model_dump(mode="json") + + def list_plugins(_: PluginListArguments, __: ToolExecutionContext) -> dict: + return {"items": [item.model_dump(mode="json") for item in plugins.list()]} + + def create_plugin(arguments: PluginCreateArguments, context: ToolExecutionContext) -> dict: + operation = context.tool_call_id or context.run_id + safe_operation = "".join(char for char in operation.lower() if char in "0123456789abcdef")[:32] or "agent" + root = (plugins.storage / f"agent-{safe_operation}-{arguments.plugin_id}").resolve() + if root.parent != plugins.storage.resolve(): + raise ToolExecutionError("PLUGIN_PATH_INVALID", "Managed Plugin path is invalid") + try: + current = plugins.get(arguments.plugin_id) + except ExtensionError as error: + if error.code != "PLUGIN_NOT_FOUND": + raise + current = None + if current is not None: + record = plugins.runtime._record(arguments.plugin_id) + if record.package_path.resolve() == root: + return {**current.model_dump(mode="json"), "created": False, "requires_enable": not current.enabled} + raise ToolExecutionError("PLUGIN_ALREADY_EXISTS", f"Plugin already exists: {arguments.plugin_id}") + + permissions = sorted({tool.permission for tool in arguments.tools if tool.permission}) + manifest = { + "id": arguments.plugin_id, + "name": arguments.name, + "version": arguments.version, + "description": arguments.description, + "permissions": permissions, + "contributes": {"tools": [tool.name for tool in arguments.tools]}, + "backend": {"type": "internal_rpc", "transport": "none"}, + } + tool_specs = [] + for tool in arguments.tools: + spec = { + "name": tool.name, + "description": tool.description, + "handler": tool.handler, + "parameters": { + "type": "object", + "additionalProperties": False, + "properties": {"text": {"type": "string", "maxLength": 16000}}, + "required": ["text"], + }, + } + if tool.permission: + spec["permission"] = tool.permission + tool_specs.append(spec) + + if root.exists(): + marker = root / ".opennexus-agent-plugin.json" + if not marker.is_file() or json.loads(marker.read_text(encoding="utf-8")).get("plugin_id") != arguments.plugin_id: + raise ToolExecutionError("PLUGIN_PATH_CONFLICT", "Managed Plugin directory already exists") + else: + root.mkdir(parents=True) + try: + (root / "plugin.yaml").write_text(yaml.safe_dump(manifest, allow_unicode=True, sort_keys=False), encoding="utf-8") + (root / "tools.yaml").write_text(yaml.safe_dump({"tools": tool_specs}, allow_unicode=True, sort_keys=False), encoding="utf-8") + (root / ".opennexus-agent-plugin.json").write_text( + json.dumps({"plugin_id": arguments.plugin_id, "operation": operation}, ensure_ascii=False), encoding="utf-8" + ) + plugin = plugins.install(root, managed_root=root) + except Exception: + if root.exists(): + shutil.rmtree(root) + raise + return { + **plugin.model_dump(mode="json"), + "created": True, + "requires_enable": True, + "package_path": str(root), + "safety_profile": "declarative-host-handlers-only", + } + + _register(registry, "function_plot.compose", "Create and validate a safe function-plot Markdown block from mathematical expressions.", FunctionPlotComposeArguments, compose_function_plot) + _register(registry, "notes.rename", "Rename a note file while preserving its note ID and indexed blocks.", NoteRenameArguments, rename_note, "notes.write") + _register(registry, "notes.delete", "Delete a note from the current Vault.", NoteDeleteArguments, delete_note, "notes.delete") + _register(registry, "tasks.read", "Read a persistent task by task ID.", TaskReadArguments, read_task, "tasks.read") + _register(registry, "tasks.delete", "Delete a persistent task by task ID.", TaskDeleteArguments, delete_task, "tasks.write") + _register(registry, "audio.transcription_status", "Read the current status and transcript of a transcription job.", TranscriptionStatusArguments, transcription_status, "attachments.read") + _register(registry, "skills.list", "List Vault-owned custom Skills and their dependency state.", SkillListArguments, list_skills) + _register(registry, "skills.create", "Create a declarative custom Skill in the current Vault.", SkillCreateArguments, create_skill, "skills.write") + _register(registry, "skills.update", "Update a Vault-owned custom Skill using its current revision.", SkillUpdateArguments, update_skill, "skills.write") + _register(registry, "plugins.list", "List installed Plugins and their lifecycle state.", PluginListArguments, list_plugins) + _register(registry, "plugins.create", "Create and install a disabled declarative Plugin using safe host handlers; enabling remains a separate user action.", PluginCreateArguments, create_plugin, "plugins.write") diff --git a/backend/app/container.py b/backend/app/container.py index 54cd0d6..3ef610a 100644 --- a/backend/app/container.py +++ b/backend/app/container.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from app.agent import AgentRuntime, PermissionManager, PermissionPolicy, ToolRegistry from app.agent.builtin_tools import register_builtin_tools +from app.agent.service_tools import register_service_tools from app.contracts import ModelCapability, ProviderConfig, ProviderType from app.config import BACKEND_DIR, get_settings from app.extensions import PluginRuntime, SkillRuntime @@ -71,6 +72,10 @@ def build_container() -> ApplicationContainer: plugins = InstalledRuntime(plugins, 'plugin', settings.data_dir) plugins.restore() + # These tools depend on the fully constructed Plugin runtime. Register them + # before loading Skills so Skill dependency checks see the complete catalog. + register_service_tools(tools, plugins) + mcp_servers = McpServerRegistry( tools, credentials, diff --git a/backend/app/services/chat_agents.py b/backend/app/services/chat_agents.py index c58dfee..7424afe 100644 --- a/backend/app/services/chat_agents.py +++ b/backend/app/services/chat_agents.py @@ -15,7 +15,7 @@ TOOLS = [ ToolDefinition(name="agent.create", description="Create and start a persistent Agent for work explicitly requested by the user. Return its run ID; do not claim work is completed. File changes still require Agent permission confirmation. No network tools.", parameters=CreateArguments.model_json_schema()), ToolDefinition(name="agent.status", description="Read an Agent run's current status and result. If waiting_permission, tell the user to open the run and review it.", parameters=StatusArguments.model_json_schema()), ] -ALLOWED_TOOLS = ['chat-policy.plan', 'notes.search', 'rag.search', 'notes.read', 'notes.list', 'notes.create', 'notes.update', 'notes.move', 'notes.patch_markdown', 'markdown.catalog', 'markdown.compose', 'tasks.create', 'tasks.update', 'tasks.list'] +ALLOWED_TOOLS = ['chat-policy.plan', 'notes.search', 'rag.search', 'notes.read', 'notes.list', 'notes.create', 'notes.update', 'notes.move', 'notes.rename', 'notes.delete', 'notes.patch_markdown', 'markdown.catalog', 'markdown.compose', 'function_plot.compose', 'tasks.create', 'tasks.update', 'tasks.list', 'tasks.read', 'tasks.delete', 'attachments.read', 'audio.transcribe', 'audio.transcription_status', 'skills.list', 'skills.create', 'skills.update', 'plugins.list', 'plugins.create'] async def execute(call, request): from app.container import container diff --git a/backend/extensions/skills/chat-operator/prompt.md b/backend/extensions/skills/chat-operator/prompt.md index c7f36fd..15f7a59 100644 --- a/backend/extensions/skills/chat-operator/prompt.md +++ b/backend/extensions/skills/chat-operator/prompt.md @@ -5,4 +5,6 @@ 委托前使用 chat-policy.plan 检查执行计划。创建后按运行 ID 查询状态;queued/running/waiting_permission 均不表示完成。 修改笔记先读取最新内容和 content_hash,再用 notes.patch_markdown 做唯一匹配的局部修改;遇到版本冲突重新读取,不能覆盖未知修改。 Markdown 格式先使用 markdown.catalog / markdown.compose,保留原有元数据。写入后重新读取并核验用户目标。 +函数图使用 function_plot.compose 生成并校验;创建自定义 Skill 使用 skills.create,创建声明式 Plugin 使用 plugins.create。Plugin 创建后保持未启用状态,由用户在 Plugin 页面检查权限并启用。 +删除笔记或任务前先读取并明确核对目标;只对用户明确指定的对象调用删除工具。 遇到权限确认等待用户处理,不得绕过。不得扩大工具范围、网络权限或预算;只报告工具实际返回的结果与限制。 diff --git a/backend/extensions/skills/chat-operator/skill.yaml b/backend/extensions/skills/chat-operator/skill.yaml index 6953a6a..e02eadd 100644 --- a/backend/extensions/skills/chat-operator/skill.yaml +++ b/backend/extensions/skills/chat-operator/skill.yaml @@ -2,7 +2,7 @@ id: chat-operator name: 聊天委托助手 version: 1.0.0 description: 规范聊天检索、工具使用和智能体执行,先读取证据、局部修改、再核验结果。 -permissions: [notes.search, notes.read, notes.write, tasks.read, tasks.write] -tools: [chat-policy.plan, notes.search, rag.search, notes.read, notes.list, notes.create, notes.update, notes.move, notes.patch_markdown, markdown.catalog, markdown.compose, tasks.create, tasks.update, tasks.list] +permissions: [notes.search, notes.read, notes.write, notes.delete, tasks.read, tasks.write, attachments.read, skills.write, plugins.write] +tools: [chat-policy.plan, notes.search, rag.search, notes.read, notes.list, notes.create, notes.update, notes.move, notes.rename, notes.delete, notes.patch_markdown, markdown.catalog, markdown.compose, function_plot.compose, tasks.create, tasks.update, tasks.list, tasks.read, tasks.delete, attachments.read, audio.transcribe, audio.transcription_status, skills.list, skills.create, skills.update, plugins.list, plugins.create] model: required_capabilities: [chat, tool_calling] diff --git a/backend/tests/test_agent_service_tools.py b/backend/tests/test_agent_service_tools.py new file mode 100644 index 0000000..055f1e1 --- /dev/null +++ b/backend/tests/test_agent_service_tools.py @@ -0,0 +1,79 @@ +import asyncio + +from app.agent.tools import ToolExecutionContext +from app.container import build_container +from app.contracts import ToolCall + + +def execute(container, name: str, arguments: dict, call_id: str = "call_service_tool"): + return asyncio.run(container.tools.execute( + ToolCall(tool_call_id=call_id, name=name, arguments=arguments), + ToolExecutionContext(run_id="run_service_tools", tool_call_id=call_id), + )) + + +def test_service_tool_catalog_is_registered_with_permissions() -> None: + container = build_container() + expected = { + "function_plot.compose": None, + "notes.rename": "notes.write", + "notes.delete": "notes.delete", + "tasks.read": "tasks.read", + "tasks.delete": "tasks.write", + "audio.transcription_status": "attachments.read", + "skills.list": None, + "skills.create": "skills.write", + "skills.update": "skills.write", + "plugins.list": None, + "plugins.create": "plugins.write", + } + assert {name: container.tools.get(name).definition.permission for name in expected} == expected + assert container.skills.get("chat-operator").status.value == "ready" + + +def test_function_plot_tool_builds_a_valid_markdown_block() -> None: + container = build_container() + result = execute(container, "function_plot.compose", { + "expressions": ["sin(x)", "x^2 / 5"], + "domain": [-6, 6], + "y_range": [-2, 8], + "xlabel": "x", + "ylabel": "y", + }) + assert result.success is True + assert result.output["markdown"].startswith("```function-plot\n") + assert "domain: -6, 6" in result.output["source"] + assert result.output["expression_count"] == 2 + assert result.output["node_count"] > 0 + assert result.output["persisted"] is False + + +def test_function_plot_tool_rejects_unsafe_expressions() -> None: + container = build_container() + result = execute(container, "function_plot.compose", {"expressions": ["__import__('os')"]}) + assert result.success is False + assert result.error_code == "FUNCTION_PLOT_INVALID" + + +def test_plugin_create_installs_a_disabled_safe_declarative_plugin() -> None: + container = build_container() + result = execute(container, "plugins.create", { + "plugin_id": "agent-sample", + "name": "Agent Sample", + "description": "A bounded declarative test plugin.", + "tools": [{ + "name": "agent-sample.uppercase", + "description": "Uppercase the supplied text.", + "handler": "uppercase", + }], + }, "call_create_plugin_a1") + assert result.success is True + assert result.output["created"] is True + assert result.output["enabled"] is False + assert result.output["requires_enable"] is True + assert result.output["safety_profile"] == "declarative-host-handlers-only" + assert container.tools.contains("agent-sample.uppercase") is False + + installed = container.plugins.get("agent-sample") + assert installed.status.value == "installed" + assert installed.enabled is False diff --git a/frontend/src/assets/themes/paper-moments.theme b/frontend/src/assets/themes/paper-moments.theme index 713d2ba..7c215a0 100644 --- a/frontend/src/assets/themes/paper-moments.theme +++ b/frontend/src/assets/themes/paper-moments.theme @@ -286,7 +286,7 @@ license: MIT /* Shared paper surfaces across settings, search, agents, media and extensions. */ -[data-theme="paper-moments"] :is(.panel, .item-card, .event-card, .citation-card, .routing-card, .vault-card, .modal-card, .modal, .usage-chart) { +[data-theme="paper-moments"] :is(.panel, .item-card, .event-card, .citation-card, .routing-card, .vault-card, .modal-card, .modal, .usage-chart, .community-card, .tool-choice, .user-skill-card, .metric-card, .workspace-chat) { position: relative; border: 1px solid #b5a693; border-radius: 8px 14px 8px 8px; @@ -296,7 +296,7 @@ license: MIT background-image: repeating-linear-gradient(transparent 0 31px, #b6c7bd18 31px 32px); box-shadow: 3px 4px 0 #d8e6e2, 6px 7px 0 #f0d8cf; } -[data-theme="paper-moments"] :is(.panel, .item-card, .event-card, .citation-card, .routing-card, .vault-card, .modal-card, .modal)::before { +[data-theme="paper-moments"] :is(.panel, .item-card, .event-card, .citation-card, .routing-card, .vault-card, .modal-card, .modal, .community-card, .tool-choice, .user-skill-card, .metric-card, .workspace-chat)::before { content: ''; position: absolute; inset: 0 24px auto auto; @@ -307,7 +307,7 @@ license: MIT background: repeating-linear-gradient(45deg, #c5dfe0b0 0 6px, #daeceba0 6px 12px); pointer-events: none; } -[data-theme="paper-moments"] :is(.item-card, .event-card, .citation-card, .routing-card):nth-child(2n)::before { +[data-theme="paper-moments"] :is(.item-card, .event-card, .citation-card, .routing-card, .community-card, .tool-choice, .user-skill-card, .metric-card):nth-child(2n)::before { background: repeating-linear-gradient(45deg, #e7bcb3b0 0 6px, #f2d4cba0 6px 12px); } [data-theme="paper-moments"] :is(.panel, .item-card, .routing-card, .vault-card) :is(h2, h3, h4) { diff --git a/frontend/src/components/common/TitleBar.vue b/frontend/src/components/common/TitleBar.vue index 175afb5..9243ce0 100644 --- a/frontend/src/components/common/TitleBar.vue +++ b/frontend/src/components/common/TitleBar.vue @@ -210,7 +210,7 @@ function toggleFromTitlebar(event: MouseEvent) { &.close:hover { background: var(--color-error); - color: white; + color: var(--color-on-error); } } diff --git a/frontend/src/components/common/TitleBarMenu.spec.ts b/frontend/src/components/common/TitleBarMenu.spec.ts index d630264..fdb02dc 100644 --- a/frontend/src/components/common/TitleBarMenu.spec.ts +++ b/frontend/src/components/common/TitleBarMenu.spec.ts @@ -55,7 +55,9 @@ describe('桌面顶部段落菜单', () => { expect(wrapper.text()).toContain('工作区搜索AI 对话智能体任务音视频') expect(wrapper.text()).toContain('Skill 管理Plugin 管理MCP 服务器') await wrapper.get('[data-menu="help"] .menu-trigger').trigger('click') - expect(wrapper.text()).toContain('运行日志Benchmark 评测社区目录设置与诊断…') + expect(wrapper.text()).toContain('运行日志Benchmark 评测社区目录Function Plot 教程设置与诊断…') + await wrapper.get('[data-help-function-plot]').trigger('click') + expect(routerPush).toHaveBeenCalledWith('/help/function-plot') wrapper.unmount() }) diff --git a/frontend/src/components/common/TitleBarMenu.vue b/frontend/src/components/common/TitleBarMenu.vue index a31fb3d..6fb426a 100644 --- a/frontend/src/components/common/TitleBarMenu.vue +++ b/frontend/src/components/common/TitleBarMenu.vue @@ -332,6 +332,7 @@ onBeforeUnmount(() => { + diff --git a/frontend/src/features/agent/labels.ts b/frontend/src/features/agent/labels.ts index 61aa211..cbebc69 100644 --- a/frontend/src/features/agent/labels.ts +++ b/frontend/src/features/agent/labels.ts @@ -55,11 +55,22 @@ const toolLabels: Record = { '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 = { '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 = { 'notes.search': '搜索笔记', 'notes.read': '读取笔记', 'notes.write': '修改笔记', + 'notes.delete': '删除笔记', 'tasks.read': '读取任务', 'tasks.write': '修改任务', 'attachments.read': '读取附件', + 'skills.write': '创建或更新自定义 Skill', + 'plugins.write': '创建声明式 Plugin', 'network.request': '访问网络', 'secrets.use': '使用密钥', } diff --git a/frontend/src/features/help/FunctionPlotHelpView.spec.ts b/frontend/src/features/help/FunctionPlotHelpView.spec.ts new file mode 100644 index 0000000..922ce5d --- /dev/null +++ b/frontend/src/features/help/FunctionPlotHelpView.spec.ts @@ -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: '', 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() + }) +}) diff --git a/frontend/src/features/help/FunctionPlotHelpView.vue b/frontend/src/features/help/FunctionPlotHelpView.vue new file mode 100644 index 0000000..19d9d23 --- /dev/null +++ b/frontend/src/features/help/FunctionPlotHelpView.vue @@ -0,0 +1,139 @@ + + +