feat: 扩展主题界面适配与 Agent 服务工具
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 页面检查权限并启用。
|
||||
删除笔记或任务前先读取并明确核对目标;只对用户明确指定的对象调用删除工具。
|
||||
遇到权限确认等待用户处理,不得绕过。不得扩大工具范围、网络权限或预算;只报告工具实际返回的结果与限制。
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -210,7 +210,7 @@ function toggleFromTitlebar(event: MouseEvent) {
|
||||
|
||||
&.close:hover {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
color: var(--color-on-error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -332,6 +332,7 @@ onBeforeUnmount(() => {
|
||||
<button data-menu-item role="menuitem" @click="navigate('/logs')"><span>{{ t('运行日志', 'Operation logs') }}</span></button>
|
||||
<button data-menu-item role="menuitem" @click="navigate('/benchmarks')"><span>{{ t('Benchmark 评测', 'Benchmarks') }}</span></button>
|
||||
<button data-menu-item role="menuitem" @click="navigate('/community')"><span>{{ t('社区目录', 'Community catalog') }}</span></button>
|
||||
<button data-help-function-plot data-menu-item role="menuitem" @click="navigate('/help/function-plot')"><span>{{ t('Function Plot 教程', 'Function Plot tutorial') }}</span></button>
|
||||
<span class="menu-separator" role="separator" />
|
||||
<button data-menu-item role="menuitem" :disabled="!workspace.hasVault" @click="navigate('/settings')"><span>{{ t('设置与诊断…', 'Settings and diagnostics…') }}</span></button>
|
||||
</div>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -6,6 +6,7 @@ const routes = [
|
||||
{ path: '/community', name: 'community', component: () => import('@/features/community/CommunityView.vue'), meta: { title: '社区目录' } },
|
||||
{ path: '/benchmarks', name: 'benchmarks', component: () => import('@/features/benchmarks/BenchmarkView.vue'), meta: { title: 'Benchmark' } },
|
||||
{ path: '/logs', name: 'logs', component: () => import('@/features/logs/LogsView.vue'), meta: { title: '运行日志' } },
|
||||
{ path: '/help/function-plot', name: 'function-plot-help', component: () => import('@/features/help/FunctionPlotHelpView.vue'), meta: { title: 'Function Plot 教程' } },
|
||||
{ path: '/media', name: 'media', component: () => import('@/features/media/MediaView.vue'), meta: { title: '音视频转写', requiresVault: true } },
|
||||
{
|
||||
path: '/',
|
||||
@@ -97,6 +98,7 @@ export function updateDocumentTitle(to = router.currentRoute.value) {
|
||||
const baseTitle = 'OpenNexus'
|
||||
const titles: Record<string, string> = {
|
||||
logs: t('运行日志', 'Operation logs'),
|
||||
'function-plot-help': t('Function Plot 教程', 'Function Plot Tutorial'),
|
||||
media: t('音视频转写', 'Media Transcription'),
|
||||
'vault-entry': t('选择知识库', 'Select Knowledge Base'),
|
||||
workspace: t('工作区', 'Workspace'),
|
||||
|
||||
Binary file not shown.
@@ -6,6 +6,25 @@ import { isMap, parseDocument } from 'yaml'
|
||||
|
||||
export const THEME_APP_VERSION = appPackage.version
|
||||
|
||||
/**
|
||||
* Semantic colors every page and component may consume. Theme packages can
|
||||
* override any subset; the compatibility layer supplies the rest.
|
||||
*/
|
||||
export const REQUIRED_THEME_COLOR_TOKENS = [
|
||||
'background-primary', 'background-secondary', 'background-tertiary', 'background-hover', 'background-active', 'background-overlay',
|
||||
'surface-primary', 'surface-secondary', 'surface-elevated',
|
||||
'text-primary', 'text-secondary', 'text-tertiary', 'text-inverse', 'text-link', 'text-disabled',
|
||||
'accent-primary', 'accent-primary-hover', 'accent-primary-active', 'accent-secondary', 'accent-soft', 'accent-soft-hover',
|
||||
'success', 'success-soft', 'warning', 'warning-soft', 'error', 'error-soft', 'info', 'info-soft',
|
||||
'border-default', 'border-subtle', 'border-focus', 'border-disabled',
|
||||
'markdown-selection', 'markdown-grid', 'markdown-marker', 'markdown-table-header',
|
||||
'editor-scroll-background', 'editor-scroll-text',
|
||||
'callout-info', 'callout-success', 'callout-warning', 'callout-danger', 'callout-important', 'callout-quote',
|
||||
'plot-background', 'plot-text', 'plot-axis', 'plot-grid',
|
||||
'plot-curve-0', 'plot-curve-1', 'plot-curve-2', 'plot-curve-3', 'plot-curve-4', 'plot-curve-5',
|
||||
'on-error', 'on-success', 'brand-surface', 'brand-ink', 'brand-surface-dark', 'highlight-overlay',
|
||||
] as const
|
||||
|
||||
const STORAGE_KEY = 'installed-themes'
|
||||
const ACTIVE_CUSTOM_KEY = 'active-custom-theme'
|
||||
export const MAX_THEME_BYTES = 5 * 1024 * 1024
|
||||
@@ -159,6 +178,47 @@ function applyThemeCss(themeId: string, css: string) {
|
||||
styleEl.textContent = css
|
||||
}
|
||||
|
||||
const THEME_CONTRACT_MARKER = '/* opennexus-theme-contract */'
|
||||
|
||||
/** Fill incomplete third-party themes with an accessible semantic palette. */
|
||||
export function withThemeContract(themeId: string, isDark: boolean, css: string): string {
|
||||
if (css.includes(THEME_CONTRACT_MARKER)) return css
|
||||
const selector = `[data-theme="${themeId}"]`
|
||||
const base = isDark ? {
|
||||
bg: '#0d1117', bg2: '#161b22', bg3: '#21262d', hover: '#1f2630', active: '#2d333b', overlay: '#000000a6',
|
||||
surface: '#161b22', surface2: '#0d1117', elevated: '#1c2128', text: '#e6edf3', text2: '#9ba6b2', text3: '#768390', inverse: '#0d1117', disabled: '#58616b',
|
||||
accent: '#7d8bff', accentHover: '#909cff', soft: '#1e2352', border: '#30363d', subtle: '#21262d',
|
||||
success: '#3fb950', successSoft: '#033a16', warning: '#d29922', warningSoft: '#4d3a00', error: '#f85149', errorSoft: '#5c1318', info: '#58a6ff', infoSoft: '#051d4d',
|
||||
calloutInfo: '#8bbdff', calloutSuccess: '#80ce93', calloutWarning: '#efc66f', calloutDanger: '#ff9b9b', calloutImportant: '#c8a5ff', calloutQuote: '#abb6c2',
|
||||
curves: ['#79c0ff', '#ff9b9b', '#7ee787', '#d2a8ff', '#f2cc60', '#ffa657'],
|
||||
} : {
|
||||
bg: '#ffffff', bg2: '#f7f8fa', bg3: '#eef0f3', hover: '#f0f2f5', active: '#e4e7eb', overlay: '#00000073',
|
||||
surface: '#ffffff', surface2: '#fafbfc', elevated: '#ffffff', text: '#1f2328', text2: '#656d76', text3: '#7b838c', inverse: '#ffffff', disabled: '#9aa0a8',
|
||||
accent: '#5b67f1', accentHover: '#4a55e0', soft: '#eef0ff', border: '#d8dce2', subtle: '#e9ebef',
|
||||
success: '#2da44e', successSoft: '#dafbe3', warning: '#9a6700', warningSoft: '#fff5c2', error: '#cf222e', errorSoft: '#ffebe9', info: '#0969da', infoSoft: '#ddf4ff',
|
||||
calloutInfo: '#175da6', calloutSuccess: '#236b3b', calloutWarning: '#855700', calloutDanger: '#ad2935', calloutImportant: '#7443ad', calloutQuote: '#59636e',
|
||||
curves: ['#0969da', '#d1242f', '#1a7f37', '#8250df', '#9a6700', '#bc4c00'],
|
||||
}
|
||||
return `${THEME_CONTRACT_MARKER}
|
||||
${selector} {
|
||||
color-scheme: ${isDark ? 'dark' : 'light'};
|
||||
--color-background-primary: ${base.bg}; --color-background-secondary: ${base.bg2}; --color-background-tertiary: ${base.bg3};
|
||||
--color-background-hover: ${base.hover}; --color-background-active: ${base.active}; --color-background-overlay: ${base.overlay};
|
||||
--color-surface-primary: ${base.surface}; --color-surface-secondary: ${base.surface2}; --color-surface-elevated: ${base.elevated};
|
||||
--color-text-primary: ${base.text}; --color-text-secondary: ${base.text2}; --color-text-tertiary: ${base.text3}; --color-text-inverse: ${base.inverse}; --color-text-link: var(--color-accent-primary); --color-text-disabled: ${base.disabled};
|
||||
--color-accent-primary: ${base.accent}; --color-accent-primary-hover: ${base.accentHover}; --color-accent-primary-active: color-mix(in srgb, var(--color-accent-primary) 80%, var(--color-text-primary)); --color-accent-secondary: var(--color-accent-primary); --color-accent-soft: ${base.soft}; --color-accent-soft-hover: color-mix(in srgb, var(--color-accent-soft) 80%, var(--color-accent-primary));
|
||||
--color-success: ${base.success}; --color-success-soft: ${base.successSoft}; --color-warning: ${base.warning}; --color-warning-soft: ${base.warningSoft}; --color-error: ${base.error}; --color-error-soft: ${base.errorSoft}; --color-info: ${base.info}; --color-info-soft: ${base.infoSoft};
|
||||
--color-border-default: ${base.border}; --color-border-subtle: ${base.subtle}; --color-border-focus: var(--color-accent-primary); --color-border-disabled: var(--color-border-subtle);
|
||||
--color-markdown-selection: color-mix(in srgb, var(--color-accent-primary) 24%, var(--color-background-primary)); --color-markdown-grid: var(--color-border-default); --color-markdown-marker: var(--color-text-secondary); --color-markdown-table-header: var(--color-background-tertiary);
|
||||
--color-editor-scroll-background: var(--color-surface-elevated); --color-editor-scroll-text: var(--color-accent-primary);
|
||||
--color-callout-info: ${base.calloutInfo}; --color-callout-success: ${base.calloutSuccess}; --color-callout-warning: ${base.calloutWarning}; --color-callout-danger: ${base.calloutDanger}; --color-callout-important: ${base.calloutImportant}; --color-callout-quote: ${base.calloutQuote};
|
||||
--color-plot-background: var(--color-surface-primary); --color-plot-text: var(--color-text-primary); --color-plot-axis: var(--color-text-secondary); --color-plot-grid: var(--color-border-default);
|
||||
--color-plot-curve-0: ${base.curves[0]}; --color-plot-curve-1: ${base.curves[1]}; --color-plot-curve-2: ${base.curves[2]}; --color-plot-curve-3: ${base.curves[3]}; --color-plot-curve-4: ${base.curves[4]}; --color-plot-curve-5: ${base.curves[5]};
|
||||
--color-on-error: #ffffff; --color-on-success: #ffffff; --color-brand-surface: #ffffff; --color-brand-ink: #252b36; --color-brand-surface-dark: #111111; --color-highlight-overlay: #ffffff30;
|
||||
}
|
||||
${css}`
|
||||
}
|
||||
|
||||
function removeThemeCss(themeId: string) {
|
||||
const styleEl = document.getElementById(`theme-style-${themeId}`)
|
||||
if (styleEl) styleEl.remove()
|
||||
@@ -294,7 +354,7 @@ export async function installTheme(
|
||||
const idx = existing.findIndex((t) => t.theme_id === manifest.theme_id)
|
||||
if (idx >= 0) existing[idx] = installed
|
||||
else existing.push(installed)
|
||||
localStorage.setItem(`${STORAGE_KEY}-css-${manifest.theme_id}`, cssContent)
|
||||
localStorage.setItem(`${STORAGE_KEY}-css-${manifest.theme_id}`, withThemeContract(manifest.theme_id, manifest.is_dark, cssContent))
|
||||
saveThemes(existing)
|
||||
return installed
|
||||
}
|
||||
@@ -345,7 +405,9 @@ export function setActiveCustomTheme(themeId: string | null) {
|
||||
if (!theme) throw new Error('THEME_PACKAGE_NOT_FOUND')
|
||||
validateManifest(theme.manifest as unknown as Record<string, unknown>)
|
||||
}
|
||||
const css = themeId ? localStorage.getItem(`${STORAGE_KEY}-css-${themeId}`) : null
|
||||
const theme = themeId ? loadStoredThemes().find(item => item.theme_id === themeId) : undefined
|
||||
const storedCss = themeId ? localStorage.getItem(`${STORAGE_KEY}-css-${themeId}`) : null
|
||||
const css = themeId && theme && storedCss ? withThemeContract(themeId, theme.is_dark, storedCss) : storedCss
|
||||
// Validate before changing the current page. Only the selected theme owns a style node.
|
||||
if (css) validateCssSafety(css)
|
||||
document.head.querySelectorAll('style[id^="theme-style-"]').forEach(style => style.remove())
|
||||
@@ -458,10 +520,10 @@ export async function installCommunityTheme(themeId: string): Promise<InstalledT
|
||||
}
|
||||
|
||||
export function getCommunityThemePreviewCss(themeId: string): string {
|
||||
if (themeId === 'paper-moments') return paperMoments.css
|
||||
if (themeId === 'paper-moments') return withThemeContract(themeId, false, paperMoments.css)
|
||||
const t = mockCommunityThemes.find((m) => m.theme_id === themeId)
|
||||
if (!t) return ''
|
||||
return buildCommunityThemeCss(themeId, t.is_dark) + `
|
||||
return withThemeContract(themeId, t.is_dark, buildCommunityThemeCss(themeId, t.is_dark) + `
|
||||
[data-theme="${themeId}"] {
|
||||
color-scheme: ${t.is_dark ? 'dark' : 'light'};
|
||||
--color-markdown-selection: ${t.is_dark ? '#443252' : '#d4eaf5'};
|
||||
@@ -483,5 +545,5 @@ export function getCommunityThemePreviewCss(themeId: string): string {
|
||||
--color-markdown-grid: var(--color-border-default);
|
||||
--color-markdown-marker: var(--color-text-secondary);
|
||||
--color-markdown-table-header: var(--color-background-tertiary);
|
||||
}`
|
||||
}`)
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
:root {
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: #20211f;
|
||||
background: #f4f1e9;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--ink: #20211f;
|
||||
--muted: #77776f;
|
||||
--paper: #fffdf7;
|
||||
--line: #dedbd0;
|
||||
--accent: #e76f3d;
|
||||
--success: #307b59;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||
button { font: inherit; }
|
||||
|
||||
.app-shell { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
|
||||
.sidebar {
|
||||
display: flex; flex-direction: column; padding: 28px 20px;
|
||||
color: #f8f5ed; background: #20211f;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 48px; font-weight: 700; }
|
||||
.brand-mark {
|
||||
display: grid; place-items: center; width: 32px; height: 32px;
|
||||
border-radius: 10px; color: #20211f; background: #f2c14e;
|
||||
}
|
||||
nav { display: grid; gap: 6px; }
|
||||
.nav-item {
|
||||
padding: 11px 14px; border: 0; border-radius: 8px; text-align: left;
|
||||
color: #bcbdb7; background: transparent;
|
||||
}
|
||||
.nav-item.active { color: white; background: #343632; }
|
||||
.nav-item:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.sidebar-hint { margin-top: auto; color: #8f918a; font-size: 13px; }
|
||||
|
||||
.workspace { padding: 64px clamp(28px, 6vw, 88px); }
|
||||
.workspace header { max-width: 720px; margin-bottom: 42px; }
|
||||
.eyebrow, .card-label { margin: 0 0 10px; color: var(--accent); font-size: 12px; font-weight: 800; letter-spacing: .14em; }
|
||||
h1 { margin: 0; font-family: Georgia, "Noto Serif SC", serif; font-size: clamp(42px, 7vw, 76px); line-height: 1; letter-spacing: -.045em; }
|
||||
.subtitle { max-width: 580px; margin: 20px 0 0; color: var(--muted); font-size: 17px; line-height: 1.7; }
|
||||
.cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; max-width: 960px; }
|
||||
.card { min-height: 180px; padding: 28px; border: 1px solid var(--line); border-radius: 18px; background: var(--paper); box-shadow: 0 12px 40px rgb(46 43 36 / 6%); }
|
||||
.hero-card { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr auto; gap: 22px; align-items: center; }
|
||||
.card h2 { margin: 0 0 12px; font-size: 21px; }
|
||||
.card p { color: var(--muted); line-height: 1.6; }
|
||||
.status-line { display: flex; align-items: center; gap: 12px; min-width: 250px; }
|
||||
.status-line p { margin: 3px 0 0; font-size: 13px; }
|
||||
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: #aaa; box-shadow: 0 0 0 5px rgb(120 120 120 / 10%); }
|
||||
.status-line.success .status-dot { background: var(--success); box-shadow: 0 0 0 5px rgb(48 123 89 / 12%); }
|
||||
.status-line.error .status-dot { background: #b84737; box-shadow: 0 0 0 5px rgb(184 71 55 / 12%); }
|
||||
.primary-button {
|
||||
justify-self: end; padding: 10px 16px; border: 0; border-radius: 9px;
|
||||
color: white; background: var(--ink); cursor: pointer;
|
||||
}
|
||||
.primary-button:disabled { cursor: wait; opacity: .6; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-shell { grid-template-columns: 1fr; }
|
||||
.sidebar { min-height: auto; padding: 18px 20px; }
|
||||
.brand { margin-bottom: 18px; }
|
||||
nav { grid-template-columns: repeat(4, 1fr); }
|
||||
.nav-item { padding: 9px 6px; text-align: center; font-size: 13px; }
|
||||
.sidebar-hint { display: none; }
|
||||
.workspace { padding-top: 42px; }
|
||||
.cards { grid-template-columns: 1fr; }
|
||||
.hero-card { display: grid; grid-column: auto; }
|
||||
.primary-button { justify-self: start; }
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
border: 1px solid color-mix(in srgb, var(--callout-color) 35%, transparent);
|
||||
border-inline-start: 4px solid var(--callout-color);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: color-mix(in srgb, var(--callout-color) 8%, var(--color-surface-primary, var(--paper, #fffdf7)));
|
||||
background: color-mix(in srgb, var(--callout-color) 8%, var(--color-surface-primary));
|
||||
color: var(--color-text-primary, var(--ink, inherit));
|
||||
margin: 12px 0; padding: 12px 16px; min-width: 0;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService'
|
||||
import { getCommunityThemePreviewCss, mockCommunityThemes, REQUIRED_THEME_COLOR_TOKENS, withThemeContract } from '@/services/themePackageService'
|
||||
const root = join(process.cwd(), 'src')
|
||||
const files = Object.fromEntries(readdirSync(root, { recursive: true }).map(String).filter(path => /\.(vue|css|ts|theme)$/.test(path)).map(path => [path, readFileSync(join(root, path), 'utf8')]))
|
||||
it('resolves semantic style token references throughout the component source tree', () => {
|
||||
@@ -17,12 +17,28 @@ it('resolves semantic style token references throughout the component source tre
|
||||
})
|
||||
it.each(mockCommunityThemes)('provides interaction and Markdown colors in $theme_id', theme => {
|
||||
const css = getCommunityThemePreviewCss(theme.theme_id)
|
||||
for (const token of ['accent-primary-active', 'accent-soft-hover', 'border-focus', 'text-inverse', 'markdown-grid', 'markdown-marker', 'markdown-table-header']) {
|
||||
for (const token of REQUIRED_THEME_COLOR_TOKENS) {
|
||||
expect(css).toContain(`--color-${token}:`)
|
||||
}
|
||||
expect(css).toContain(`color-scheme: ${theme.is_dark ? 'dark' : 'light'}`)
|
||||
})
|
||||
|
||||
it.each([false, true])('fills an incomplete custom theme with the complete semantic contract (dark=%s)', isDark => {
|
||||
const css = withThemeContract('minimal', isDark, '[data-theme="minimal"] { --color-accent-primary: hotpink; }')
|
||||
for (const token of REQUIRED_THEME_COLOR_TOKENS) expect(css).toContain(`--color-${token}:`)
|
||||
expect(css).toContain(`color-scheme: ${isDark ? 'dark' : 'light'}`)
|
||||
expect(css.endsWith('--color-accent-primary: hotpink; }')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps page and component styles on semantic colors', () => {
|
||||
const rawColors: string[] = []
|
||||
for (const [path, source] of Object.entries(files)) {
|
||||
if (!path.endsWith('.vue') || path.endsWith('.spec.ts') || path.includes('features/themes/ThemesView.vue') || path.includes('features\\themes\\ThemesView.vue')) continue
|
||||
for (const match of source.matchAll(/(?:#[\da-f]{3,8}|rgba?\([^)]*\))/gi)) rawColors.push(`${path}: ${match[0]}`)
|
||||
}
|
||||
expect(rawColors).toEqual([])
|
||||
})
|
||||
|
||||
const calloutThemes = ['light', 'dark', 'sepia', ...mockCommunityThemes.map(theme => theme.theme_id)]
|
||||
const calloutTones = ['info', 'success', 'warning', 'danger', 'important', 'quote']
|
||||
it.each(calloutThemes)('keeps callout headings readable against tinted surfaces in %s', themeId => {
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
--color-error-soft: #ffebe9;
|
||||
--color-info: #0969da;
|
||||
--color-info-soft: #ddf4ff;
|
||||
--color-on-error: #ffffff;
|
||||
--color-on-success: #ffffff;
|
||||
--color-brand-surface: #ffffff;
|
||||
--color-brand-ink: #252b36;
|
||||
--color-brand-surface-dark: #111111;
|
||||
--color-highlight-overlay: #ffffff30;
|
||||
|
||||
/* Border */
|
||||
--color-border-default: #e4e7eb;
|
||||
@@ -128,6 +134,7 @@
|
||||
}
|
||||
|
||||
[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
--color-callout-info: #175da6;
|
||||
--color-callout-success: #236b3b;
|
||||
--color-callout-warning: #855700;
|
||||
@@ -193,6 +200,7 @@
|
||||
}
|
||||
|
||||
[data-theme='sepia'] {
|
||||
color-scheme: light;
|
||||
--color-callout-info: #396578;
|
||||
--color-callout-success: #496b3b;
|
||||
--color-callout-warning: #805918;
|
||||
|
||||
Reference in New Issue
Block a user