diff --git a/backend/app/extensions/runtime.py b/backend/app/extensions/runtime.py index 1147665..05d13ba 100644 --- a/backend/app/extensions/runtime.py +++ b/backend/app/extensions/runtime.py @@ -248,7 +248,7 @@ class DeclarativeToolSpec(BaseModel): description: str parameters: dict[str, Any] = Field(default_factory=dict) permission: str | None = None - handler: Literal["echo", "uppercase", "execution_policy"] + handler: Literal["echo", "uppercase", "execution_policy", "inspect_markdown"] class DeclarativePluginHost: @@ -270,6 +270,89 @@ class DeclarativePluginHost: 'requires_permission_policy':True,'completion_requires_verification':True} if handler == "uppercase": return {"text": str(values.get("text", "")).upper()} + if handler == "inspect_markdown": + text = str(values.get("text", "")) + if len(text) > 100_000: + raise ExtensionError( + "PLUGIN_ARGUMENT_INVALID", "Markdown text exceeds 100000 characters" + ) + headings: list[dict[str, Any]] = [] + tasks: list[dict[str, Any]] = [] + issues: list[dict[str, Any]] = [] + seen: dict[str, int] = {} + previous_level = 0 + fence_marker: str | None = None + fence_line = 0 + for line_number, line in enumerate(text.splitlines(), start=1): + stripped = line.lstrip() + marker = stripped[:3] + if marker in {"```", "~~~"}: + if fence_marker is None: + fence_marker, fence_line = marker, line_number + elif marker == fence_marker: + fence_marker = None + continue + if fence_marker is not None: + continue + task_match = re.match(r"^\s*[-*+]\s+\[([ xX])\]\s+(.*)$", line) + if task_match: + tasks.append( + { + "line": line_number, + "completed": task_match.group(1).lower() == "x", + "text": task_match.group(2).strip(), + } + ) + heading_match = re.match(r"^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$", line) + if not heading_match: + continue + level = len(heading_match.group(1)) + title = heading_match.group(2).strip() + headings.append({"line": line_number, "level": level, "title": title}) + if previous_level and level > previous_level + 1: + issues.append( + { + "line": line_number, + "type": "heading_level_jump", + "message": f"标题从 H{previous_level} 跳到 H{level}", + } + ) + normalized = title.casefold() + if normalized in seen: + issues.append( + { + "line": line_number, + "type": "duplicate_heading", + "message": f"标题与第 {seen[normalized]} 行重复", + } + ) + else: + seen[normalized] = line_number + previous_level = level + if fence_marker is not None: + issues.append( + { + "line": fence_line, + "type": "unclosed_code_fence", + "message": "代码围栏未闭合", + } + ) + open_tasks = sum(not item["completed"] for item in tasks) + return { + "summary": { + "lines": len(text.splitlines()), + "characters": len(text), + "headings": len(headings), + "tasks": len(tasks), + "open_tasks": open_tasks, + "issues": len(issues), + }, + "headings": headings[:200], + "tasks": tasks[:200], + "issues": issues[:200], + "truncated": any(len(items) > 200 for items in (headings, tasks, issues)), + "method": "line-based Markdown checks; line numbers refer to the supplied text", + } raise ExtensionError("PLUGIN_HANDLER_UNSUPPORTED", f"Unsupported handler: {handler}") async def execute_command( diff --git a/backend/app/services/media_notes.py b/backend/app/services/media_notes.py index ab79cd1..e874648 100644 --- a/backend/app/services/media_notes.py +++ b/backend/app/services/media_notes.py @@ -1,10 +1,14 @@ """幂等转录本导出,无需覆盖已编辑的笔记。""" import asyncio import hashlib -from contextlib import closing +import re +from contextlib import closing, contextmanager +from uuid import NAMESPACE_URL, uuid4, uuid5 +from app import host_bridge from app.config import get_settings -from app.contracts import Message, MessageRole, ModelRequest, TranscriptNoteRequest +from app.agent.tools import ToolExecutionContext +from app.contracts import Message, MessageRole, ModelRequest, ToolCall, TranscriptNoteRequest from app.database.db import connect, transaction from app.errors import ApiError from app.providers.base import ProviderError @@ -15,6 +19,19 @@ from app.services.transcription_service import require_job _locks = {} +@contextmanager +def _artifact_operation(label: str): + """Give each Host mutation in a multi-artifact request its own operation id.""" + parent = host_bridge.operation_id.get() or str(uuid4()) + token = host_bridge.operation_id.set(str(uuid5( + NAMESPACE_URL, f"opennexus:media-artifact:{parent}:{label}", + ))) + try: + yield + finally: + host_bridge.operation_id.reset(token) + + async def create_transcript_note(job_id, options): identity = (str(get_settings().db_path), job_id) lock = _locks.setdefault(identity, asyncio.Lock()) @@ -29,7 +46,9 @@ async def create_transcript_note(job_id, options): row = conn.execute("SELECT note_id FROM media_notes WHERE job_id=? AND revision=? AND options_hash=?", (job_id, job.revision, options_hash)).fetchone() if row: - return await note_service.get_note(row[0]) + existing = await note_service.get_note(row[0]) + if existing is not None: + return existing marker = f"" title = f"{options.title} · {job_id[-8:]}-r{job.revision}-{options_hash[:6]}" lines = [marker, f"# {options.title}", "", f"[源音频](/#/media?job={job_id})", ""] @@ -64,7 +83,9 @@ async def create_transcript_note(job_id, options): else: note = await _create_note(title, markdown, options, marker) with closing(connect()) as conn, transaction(conn): - conn.execute("INSERT OR IGNORE INTO media_notes VALUES (?,?,?,?)", (job_id, job.revision, options_hash, note.note_id)) + # 媒体任务历史是全局的,而桌面笔记属于当前 Vault。旧关联可能 + # 指向另一个 Vault 的 file_id;当前 Vault 恢复/创建后应接管关联。 + conn.execute("INSERT OR REPLACE INTO media_notes VALUES (?,?,?,?)", (job_id, job.revision, options_hash, note.note_id)) conn.execute("INSERT OR REPLACE INTO media_note_baselines VALUES (?,?)", (note.note_id, hashlib.sha256(markdown.encode()).hexdigest())) return note @@ -127,6 +148,66 @@ async def _complete(provider_id: str, model: str, system: str, content: str) -> return turn.text.strip().removeprefix("```markdown").removeprefix("```").removesuffix("```").strip() +_COURSE_FENCE = re.compile(r"```([\w+-]+)[ \t]*\n(.*?)\n```", re.DOTALL) + + +def _function_plot_arguments(source: str) -> dict: + arguments: dict = {"expressions": []} + for raw in source.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + key, separator, value = line.partition(":") + if separator and key.strip().lower() in {"domain", "range", "xlabel", "ylabel", "grid"}: + key = key.strip().lower() + value = value.strip() + if key in {"domain", "range"}: + pair = [float(item.strip()) for item in value.split(",", 1)] + arguments["domain" if key == "domain" else "y_range"] = pair + elif key == "grid": + arguments["grid"] = value.lower() not in {"false", "0", "no"} + else: + arguments[key] = value + continue + expression = line[4:].strip() if line.lower().startswith("y = ") else line + arguments["expressions"].append(expression) + return arguments + + +async def _compose_course_blocks(markdown: str, job_id: str) -> str: + """Recompose supported generated blocks through the same tools exposed to Agents.""" + from app.container import container + + rendered: list[str] = [] + cursor = 0 + for index, match in enumerate(_COURSE_FENCE.finditer(markdown), 1): + rendered.append(markdown[cursor:match.start()]) + language, source = match.group(1).lower(), match.group(2) + if language in {"function-plot", "function_plot", "functionplot"}: + name = "function_plot.compose" + try: + arguments = _function_plot_arguments(source) + except (TypeError, ValueError) as exc: + raise ApiError(502, "KNOWLEDGE_NOTE_VISUAL_INVALID", "模型生成的函数图参数无效。") from exc + else: + name = "markdown.compose" + arguments = { + "format": "mermaid" if language == "mermaid" else "code-block", + "text": source, + "language": "" if language == "mermaid" else language, + } + result = await container.tools.execute( + ToolCall(tool_call_id=f"course-block-{index}", name=name, arguments=arguments), + ToolExecutionContext(run_id=f"media-note-{job_id}"), + ) + if not result.success or not isinstance(result.output, dict) or not result.output.get("markdown"): + raise ApiError(502, "KNOWLEDGE_NOTE_VISUAL_INVALID", result.error_message or "课程笔记图表校验失败。") + rendered.append(result.output["markdown"]) + cursor = match.end() + rendered.append(markdown[cursor:]) + return "".join(rendered) + + async def _knowledge_markdown(job, provider_id: str, model: str, title: str) -> str: transcript = _transcript_text(job) if not transcript.strip(): @@ -134,7 +215,11 @@ async def _knowledge_markdown(job, provider_id: str, model: str, title: str) -> system = ( "你是一名严谨的课程笔记整理助手。只能依据提供的转录内容整理,不补写未出现的事实。" "输出中文 Markdown 正文,使用清晰的二级、三级标题;包含课程主题、核心概念、关键论证或步骤、" - "重要例子、待复习问题。合并口语重复,保留专业术语和必要条件。不要使用代码围栏,也不要写处理说明。" + "重要例子、待复习问题。合并口语重复,保留专业术语和必要条件。" + "只有在确实帮助理解时才补充可由转录推导出的材料:算法或程序课可给出带语言标记的简洁代码块;" + "流程、状态或关系适合可视化时可给出 mermaid 代码块;课程涉及函数曲线且画图有助理解时可给出 " + "function-plot 代码块(第一行可写 domain: -10, 10,表达式逐行写成 y = ...)。" + "不要为了展示而强行添加图表,也不要输出上述三类以外的特殊围栏或处理说明。" ) parts = _chunks(transcript) summaries: list[str] = [] @@ -151,6 +236,7 @@ async def _knowledge_markdown(job, provider_id: str, model: str, title: str) -> "请将以下分段知识点合并成一篇完整课程笔记,消除重复并保持逻辑顺序:\n\n" + "\n\n".join(f"### 分段 {index}\n{summary}" for index, summary in enumerate(summaries, 1)), ) + body = await _compose_course_blocks(body, job.job_id) return "\n".join([ f"", f"# {title}", "", f"[查看完整转录稿](/#/media?job={job.job_id})", "", body, @@ -166,7 +252,11 @@ async def create_transcript_artifacts(job_id, options): include_timestamps=options.include_timestamps, include_speakers=options.include_speakers, ) - transcript_note = await create_transcript_note(job_id, transcript_options) + # The Rust Host treats an operation id as one immutable mutation. Creating + # two notes under the request operation id makes the second write look like + # an idempotency payload conflict, so derive one child id per artifact. + with _artifact_operation("transcript"): + transcript_note = await create_transcript_note(job_id, transcript_options) job = require_job(job_id) knowledge_title = options.knowledge_title or f"{options.title} · 知识点" identity = (str(get_settings().db_path), job_id, "knowledge") @@ -188,11 +278,12 @@ async def create_transcript_artifacts(job_id, options): markdown = await _knowledge_markdown(job, options.provider_id, options.model, knowledge_title) note_title = f"{knowledge_title} · {job_id[-8:]}-r{job.revision}-{signature[-6:]}" marker = markdown.splitlines()[0] - knowledge_note = await _create_note( - note_title, markdown, options, marker, tags=["课程笔记", "知识点"] - ) + with _artifact_operation("knowledge"): + knowledge_note = await _create_note( + note_title, markdown, options, marker, tags=["课程笔记", "知识点"] + ) with closing(connect()) as conn, transaction(conn): - conn.execute("INSERT OR IGNORE INTO media_notes VALUES (?,?,?,?)", + conn.execute("INSERT OR REPLACE INTO media_notes VALUES (?,?,?,?)", (job_id, job.revision, signature, knowledge_note.note_id)) return {"transcript": transcript_note, "knowledge_note": knowledge_note} diff --git a/backend/extensions/community/build_packages.py b/backend/extensions/community/build_packages.py index 5603f8e..cbe906f 100644 --- a/backend/extensions/community/build_packages.py +++ b/backend/extensions/community/build_packages.py @@ -10,7 +10,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent PACKAGES = [ - ('plugin', 'markdown-workbench', ['plugin.yaml', 'commands.yaml', 'markdown-workbench.exe', 'example.md', 'README.md'], []), + ('plugin', 'markdown-workbench', ['plugin.yaml', 'tools.yaml', 'example.md', 'README.md'], []), ('plugin', 'study-plan-kit', ['plugin.yaml', 'study-plan-kit.exe', 'README.md'], []), ('skill', 'note-reviewer', ['skill.yaml', 'prompt.md', 'README.md'], ['markdown-workbench']), ('skill', 'course-note-rewriter', ['skill.yaml', 'prompt.md', 'README.md'], ['markdown-workbench']), diff --git a/backend/extensions/community/dist/index.json b/backend/extensions/community/dist/index.json index 12e3d47..24c722d 100644 --- a/backend/extensions/community/dist/index.json +++ b/backend/extensions/community/dist/index.json @@ -6,8 +6,8 @@ "kind": "plugin", "version": "1.0.0", "file": "markdown-workbench-1.0.0.zip", - "bytes": 405144, - "sha256": "02203a73c7e6cac7b4001e98a6b1490a639296e76d74d13350f94b8facdb0d02", + "bytes": 2445, + "sha256": "4ca777a5f4fdffcaa3424d4f5d158a593e9f388fd88ab54783630aa59a3e8f59", "dependencies": [], "license": null, "publication_status": "local-preview" diff --git a/backend/extensions/community/dist/markdown-workbench-1.0.0.zip b/backend/extensions/community/dist/markdown-workbench-1.0.0.zip index b41fef5..15ae8b3 100644 Binary files a/backend/extensions/community/dist/markdown-workbench-1.0.0.zip and b/backend/extensions/community/dist/markdown-workbench-1.0.0.zip differ diff --git a/backend/extensions/community/plugins/markdown-workbench/README.md b/backend/extensions/community/plugins/markdown-workbench/README.md index a41ffd9..2423236 100644 --- a/backend/extensions/community/plugins/markdown-workbench/README.md +++ b/backend/extensions/community/plugins/markdown-workbench/README.md @@ -1,16 +1,15 @@ # Markdown 笔记检查 1.0.0 -真实的本地 MCP stdio Plugin,仅依赖 Python 3.11+ 标准库。需要 AI Core 主机能够运行 `python`;当前 NotesAgent 仅在 development 模式允许启动此类本地进程。 +生产环境可用的声明式本地 Plugin。分析逻辑由 OpenNexus 的白名单内置 Host 执行,不启动外部进程、不读取磁盘,也不连接网络。 ## 功能 - Agent 工具 `markdown-workbench.inspect_markdown`:传入 `text`,返回行数、字符数、标题、任务、未完成任务、重复标题、标题跳级及未闭合代码围栏。结果包含 1 起始行号。 -- 命令 `检查选中 Markdown`:选择笔记中的文字后,在命令面板(Ctrl+P)执行;通知展示统计和前三条问题。不会修改选区。 - `example.md` 是可独立检查的示例,预期 3 个标题、2 项任务(1 项未完成)、2 条提示(标题跳级、重复标题)。 ## 安装 -在 Plugin 页面安装 `markdown-workbench-1.0.0.zip`,再启用 Plugin。随后安装并启用配套 Skill `note-reviewer`。本 Plugin 不申请宿主权限,不读取磁盘笔记、不连接网络、不需要密钥;只分析宿主显式传入的文本。宿主本地进程隔离仍不是 OS 沙箱。 +在 Plugin 页面安装 `markdown-workbench-1.0.0.zip`,再启用 Plugin。随后安装并启用配套 Skill `course-note-rewriter` 或 `note-reviewer`。本 Plugin 不申请权限、不读取磁盘笔记、不连接网络、不需要密钥;只分析宿主显式传入的文本。 ## 输入与限制 diff --git a/backend/extensions/community/plugins/markdown-workbench/plugin.yaml b/backend/extensions/community/plugins/markdown-workbench/plugin.yaml index 8278114..cbfbf70 100644 --- a/backend/extensions/community/plugins/markdown-workbench/plugin.yaml +++ b/backend/extensions/community/plugins/markdown-workbench/plugin.yaml @@ -5,11 +5,6 @@ description: 本地检查 Markdown 标题层级、重复标题、未完成任务 permissions: [] contributes: tools: [markdown-workbench.inspect_markdown] - commands: [markdown-workbench.inspect-selection] backend: - type: mcp - transport: stdio - command: ./markdown-workbench.exe - args: [] - startup_timeout_seconds: 10 - tool_timeout_seconds: 10 + type: internal_rpc + transport: none diff --git a/backend/extensions/community/plugins/markdown-workbench/tools.yaml b/backend/extensions/community/plugins/markdown-workbench/tools.yaml new file mode 100644 index 0000000..7c015b8 --- /dev/null +++ b/backend/extensions/community/plugins/markdown-workbench/tools.yaml @@ -0,0 +1,12 @@ +tools: + - name: markdown-workbench.inspect_markdown + description: 本地检查 Markdown 标题层级、重复标题、任务项与代码围栏,并返回原文行号。 + handler: inspect_markdown + parameters: + type: object + properties: + text: + type: string + maxLength: 100000 + required: [text] + additionalProperties: false diff --git a/backend/tests/test_community_packages.py b/backend/tests/test_community_packages.py index 59a3700..b0f276c 100644 --- a/backend/tests/test_community_packages.py +++ b/backend/tests/test_community_packages.py @@ -6,7 +6,7 @@ import pytest from app.config import BACKEND_DIR from app.container import build_container -from app.contracts import ModelCapability, PluginCommandContext, ToolCall +from app.contracts import ModelCapability, ToolCall from app.agent.tools import ToolExecutionContext from app.extensions.archive import install_zip @@ -37,7 +37,7 @@ def test_analysis_ignores_metadata_and_code_and_keeps_line_numbers(): assert many['truncated'] and many['summary']['tasks'] == 205 and len(many['tasks']) == 200 -def test_zip_install_real_mcp_tool_command_and_skill(tmp_path): +def test_zip_install_real_declarative_tool_and_skill(tmp_path): builder = load(ROOT / 'build_packages.py') output = tmp_path / 'dist' catalog = builder.build(output) @@ -53,8 +53,6 @@ def test_zip_install_real_mcp_tool_command_and_skill(tmp_path): result = await runtime.tools.execute(ToolCall(tool_call_id='community-test', name='markdown-workbench.inspect_markdown', arguments={'text': sample}), ToolExecutionContext(run_id='community-test')) assert result.success, result.error_message assert result.output['summary']['issues'] == 2 - command = await runtime.plugins.execute_command('markdown-workbench.inspect-selection', {}, PluginCommandContext(selection=sample)) - assert '1 项未完成任务' in command.effect.payload.message assert runtime.skills.enable('note-reviewer').status == 'ready' config = runtime.skills.build_agent_configuration('note-reviewer', [ModelCapability.chat, ModelCapability.tool_calling]) assert 'notes.read' in config.allowed_tools diff --git a/backend/tests/test_media_jobs.py b/backend/tests/test_media_jobs.py index 3d72157..5b9b100 100644 --- a/backend/tests/test_media_jobs.py +++ b/backend/tests/test_media_jobs.py @@ -149,6 +149,130 @@ def test_desktop_revision_conflict_recovers_marker_matched_note(monkeypatch): assert result is recovered +def test_transcript_export_recovers_link_from_another_vault(monkeypatch): + from app.contracts import TranscriptNoteRequest + from app.services import note_service + from app.services.media_notes import create_transcript_note + + text_attachment() + + async def scenario(): + job = await jobs.create_transcription("lecture.txt") + options = TranscriptNoteRequest(title="跨库课程") + original = await create_transcript_note(job.job_id, options) + with closing(connect()) as conn: + conn.execute( + "UPDATE media_notes SET note_id=? WHERE job_id=?", + ("note-from-another-vault", job.job_id), + ) + conn.commit() + + real_get_note = note_service.get_note + + async def get_note(note_id): + if note_id == "note-from-another-vault": + return None + return await real_get_note(note_id) + + monkeypatch.setattr(note_service, "get_note", get_note) + recovered = await create_transcript_note(job.job_id, options) + assert recovered.note_id == original.note_id + with closing(connect()) as conn: + linked = conn.execute( + "SELECT note_id FROM media_notes WHERE job_id=?", + (job.job_id,), + ).fetchone()[0] + assert linked == original.note_id + + asyncio.run(scenario()) + + +def test_artifact_host_writes_use_distinct_child_operations(monkeypatch): + from app import host_bridge + from app.contracts import TranscriptArtifactsRequest + from app.services import media_notes + + operations = [] + text_attachment() + job = asyncio.run(jobs.create_transcription("lecture.txt")) + + async def transcript(_job_id, _options): + operations.append(host_bridge.operation_id.get()) + return SimpleNamespace(note_id="transcript-note") + + async def knowledge(*_args): + return "\n# Knowledge" + + async def create(*_args, **_kwargs): + operations.append(host_bridge.operation_id.get()) + return SimpleNamespace(note_id="knowledge-note") + + monkeypatch.setattr(media_notes, "create_transcript_note", transcript) + monkeypatch.setattr(media_notes, "_knowledge_markdown", knowledge) + monkeypatch.setattr(media_notes, "_create_note", create) + + token = host_bridge.operation_id.set("11111111-1111-4111-8111-111111111111") + try: + result = asyncio.run(media_notes.create_transcript_artifacts( + job.job_id, + TranscriptArtifactsRequest( + title="Transcript", knowledge_title="Knowledge", + provider_id="mock", model="mock-1", + ), + )) + finally: + host_bridge.operation_id.reset(token) + + assert result["transcript"].note_id == "transcript-note" + assert result["knowledge_note"].note_id == "knowledge-note" + assert len(operations) == 2 + assert operations[0] != operations[1] + assert all(operation and operation != "11111111-1111-4111-8111-111111111111" for operation in operations) + + +def test_course_note_blocks_are_recomposed_with_markdown_and_plot_tools(monkeypatch): + from app.container import container + from app.services.media_notes import _compose_course_blocks + + names = [] + original = container.tools.execute + + async def execute(call, context): + names.append(call.name) + return await original(call, context) + + monkeypatch.setattr(container.tools, "execute", execute) + markdown = """## 算法 +```python +left += 1 +``` +```mermaid +flowchart LR + A --> B +``` +```function_plot +domain: -4, 4 +range: -1, 8 +y = x^2 +```""" + rendered = asyncio.run(_compose_course_blocks(markdown, "media-test")) + assert names == ["markdown.compose", "markdown.compose", "function_plot.compose"] + assert "```python\nleft += 1\n```" in rendered + assert "```mermaid\nflowchart LR" in rendered + assert "```function-plot\ndomain: -4, 4" in rendered + + +def test_course_note_rejects_invalid_function_plot(): + from app.services.media_notes import _compose_course_blocks + + with pytest.raises(ApiError) as invalid: + asyncio.run(_compose_course_blocks( + "```function-plot\ndomain: -4, 4\ny = __import__('os')\n```", + "media-test", + )) + assert invalid.value.code == "KNOWLEDGE_NOTE_VISUAL_INVALID" + + def test_local_only_export_and_rebuild_keep_local_embedding_policy(monkeypatch): from types import SimpleNamespace from app.contracts import TranscriptNoteRequest, IndexRebuildRequest diff --git a/backend/tests/test_plugin_contributions.py b/backend/tests/test_plugin_contributions.py index 1235d6f..88e7078 100644 --- a/backend/tests/test_plugin_contributions.py +++ b/backend/tests/test_plugin_contributions.py @@ -3,7 +3,7 @@ import json from pathlib import Path import pytest -from pydantic import TypeAdapter, ValidationError +from pydantic import BaseModel, TypeAdapter, ValidationError from app.agent import ToolRegistry from app.config import BACKEND_DIR, get_settings @@ -91,6 +91,33 @@ def test_echo_command_returns_none_for_empty_message() -> None: assert populated.payload.message == "hello" +def test_markdown_inspector_returns_line_based_findings() -> None: + class Arguments(BaseModel): + text: str + + markdown = "# 课程\n### 跳级\n## 重复\n## 重复\n- [ ] 复习\n```python\nprint(1)" + result = run( + DeclarativePluginHost().execute( + "inspect_markdown", Arguments(text=markdown), None + ) + ) + + assert result["summary"] == { + "lines": 7, + "characters": len(markdown), + "headings": 4, + "tasks": 1, + "open_tasks": 1, + "issues": 3, + } + assert [issue["type"] for issue in result["issues"]] == [ + "heading_level_jump", + "duplicate_heading", + "unclosed_code_fence", + ] + assert [issue["line"] for issue in result["issues"]] == [2, 4, 6] + + @pytest.mark.parametrize( ("effect_type", "payload"), [ diff --git a/frontend/src/features/plugins/PluginsView.vue b/frontend/src/features/plugins/PluginsView.vue index af5a5bb..4184d7e 100644 --- a/frontend/src/features/plugins/PluginsView.vue +++ b/frontend/src/features/plugins/PluginsView.vue @@ -20,6 +20,7 @@ const actionError = ref('') const showInstall = ref(false) const activeTab = ref<'info' | 'settings' | 'commands'>('info') const pluginCommands = ref([]) +const restoreNoticeKey = ref(0) onMounted(() => { void pluginStore.loadPlugins() }) @@ -42,6 +43,12 @@ async function toggle(id: string, enabled: boolean) { } catch (error) { actionError.value = error instanceof Error ? error.message : t('状态更新失败', 'Status update failed') } } +function installed() { + showInstall.value = false + actionError.value = '' + restoreNoticeKey.value += 1 +} + async function grant(id: string, permissions: string[]) { if (!(await askConfirm(`${t('将授权:', 'Grant permissions: ')}${permissions.join(', ')}。${t('是否继续?', 'Continue?')}`))) return try { await pluginStore.grantPermissions(id, permissions) } @@ -65,9 +72,9 @@ const hasCommandContribution = computed(() =>