feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45

Merged
Kronecker merged 151 commits from feat/phase3-completion into main 2026-09-10 01:16:31 +08:00
26 changed files with 703 additions and 92 deletions
Showing only changes of commit e5d144056c - Show all commits
+4
View File
@@ -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,
+306
View File
@@ -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")
+5
View File
@@ -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,
+1 -1
View File
@@ -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]
+79
View File
@@ -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) {
+1 -1
View File
@@ -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>
+25
View File
@@ -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',
+1 -1
View File
@@ -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); }
+2
View File
@@ -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.
+67 -5
View File
@@ -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);
}`
}`)
}
-70
View File
@@ -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; }
}
+1 -1
View File
@@ -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;
}
+18 -2
View File
@@ -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 => {
+8
View File
@@ -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;