fix: complete 0.5.1 demo workflows

This commit is contained in:
2026-09-18 09:11:53 +08:00
parent 01ceae6fc6
commit 83ce2409e6
13 changed files with 376 additions and 33 deletions
+84 -1
View File
@@ -248,7 +248,7 @@ class DeclarativeToolSpec(BaseModel):
description: str description: str
parameters: dict[str, Any] = Field(default_factory=dict) parameters: dict[str, Any] = Field(default_factory=dict)
permission: str | None = None permission: str | None = None
handler: Literal["echo", "uppercase", "execution_policy"] handler: Literal["echo", "uppercase", "execution_policy", "inspect_markdown"]
class DeclarativePluginHost: class DeclarativePluginHost:
@@ -270,6 +270,89 @@ class DeclarativePluginHost:
'requires_permission_policy':True,'completion_requires_verification':True} 'requires_permission_policy':True,'completion_requires_verification':True}
if handler == "uppercase": if handler == "uppercase":
return {"text": str(values.get("text", "")).upper()} 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}") raise ExtensionError("PLUGIN_HANDLER_UNSUPPORTED", f"Unsupported handler: {handler}")
async def execute_command( async def execute_command(
+97 -6
View File
@@ -1,10 +1,14 @@
"""幂等转录本导出,无需覆盖已编辑的笔记。""" """幂等转录本导出,无需覆盖已编辑的笔记。"""
import asyncio import asyncio
import hashlib 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.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.database.db import connect, transaction
from app.errors import ApiError from app.errors import ApiError
from app.providers.base import ProviderError from app.providers.base import ProviderError
@@ -15,6 +19,19 @@ from app.services.transcription_service import require_job
_locks = {} _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): async def create_transcript_note(job_id, options):
identity = (str(get_settings().db_path), job_id) identity = (str(get_settings().db_path), job_id)
lock = _locks.setdefault(identity, asyncio.Lock()) 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=?", row = conn.execute("SELECT note_id FROM media_notes WHERE job_id=? AND revision=? AND options_hash=?",
(job_id, job.revision, options_hash)).fetchone() (job_id, job.revision, options_hash)).fetchone()
if row: 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"<!-- transcription:{job_id}:{job.revision}:{options_hash} -->" marker = f"<!-- transcription:{job_id}:{job.revision}:{options_hash} -->"
title = f"{options.title} · {job_id[-8:]}-r{job.revision}-{options_hash[:6]}" title = f"{options.title} · {job_id[-8:]}-r{job.revision}-{options_hash[:6]}"
lines = [marker, f"# {options.title}", "", f"[源音频](/#/media?job={job_id})", ""] lines = [marker, f"# {options.title}", "", f"[源音频](/#/media?job={job_id})", ""]
@@ -64,7 +83,9 @@ async def create_transcript_note(job_id, options):
else: else:
note = await _create_note(title, markdown, options, marker) note = await _create_note(title, markdown, options, marker)
with closing(connect()) as conn, transaction(conn): 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())) conn.execute("INSERT OR REPLACE INTO media_note_baselines VALUES (?,?)", (note.note_id, hashlib.sha256(markdown.encode()).hexdigest()))
return note 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() 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: async def _knowledge_markdown(job, provider_id: str, model: str, title: str) -> str:
transcript = _transcript_text(job) transcript = _transcript_text(job)
if not transcript.strip(): if not transcript.strip():
@@ -134,7 +215,11 @@ async def _knowledge_markdown(job, provider_id: str, model: str, title: str) ->
system = ( system = (
"你是一名严谨的课程笔记整理助手。只能依据提供的转录内容整理,不补写未出现的事实。" "你是一名严谨的课程笔记整理助手。只能依据提供的转录内容整理,不补写未出现的事实。"
"输出中文 Markdown 正文,使用清晰的二级、三级标题;包含课程主题、核心概念、关键论证或步骤、" "输出中文 Markdown 正文,使用清晰的二级、三级标题;包含课程主题、核心概念、关键论证或步骤、"
"重要例子、待复习问题。合并口语重复,保留专业术语和必要条件。不要使用代码围栏,也不要写处理说明。" "重要例子、待复习问题。合并口语重复,保留专业术语和必要条件。"
"只有在确实帮助理解时才补充可由转录推导出的材料:算法或程序课可给出带语言标记的简洁代码块;"
"流程、状态或关系适合可视化时可给出 mermaid 代码块;课程涉及函数曲线且画图有助理解时可给出 "
"function-plot 代码块(第一行可写 domain: -10, 10,表达式逐行写成 y = ...)。"
"不要为了展示而强行添加图表,也不要输出上述三类以外的特殊围栏或处理说明。"
) )
parts = _chunks(transcript) parts = _chunks(transcript)
summaries: list[str] = [] summaries: list[str] = []
@@ -151,6 +236,7 @@ async def _knowledge_markdown(job, provider_id: str, model: str, title: str) ->
"请将以下分段知识点合并成一篇完整课程笔记,消除重复并保持逻辑顺序:\n\n" "请将以下分段知识点合并成一篇完整课程笔记,消除重复并保持逻辑顺序:\n\n"
+ "\n\n".join(f"### 分段 {index}\n{summary}" for index, summary in enumerate(summaries, 1)), + "\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([ return "\n".join([
f"<!-- knowledge-note:{job.job_id}:{job.revision}:{provider_id}:{model} -->", f"<!-- knowledge-note:{job.job_id}:{job.revision}:{provider_id}:{model} -->",
f"# {title}", "", f"[查看完整转录稿](/#/media?job={job.job_id})", "", body, f"# {title}", "", f"[查看完整转录稿](/#/media?job={job.job_id})", "", body,
@@ -166,6 +252,10 @@ async def create_transcript_artifacts(job_id, options):
include_timestamps=options.include_timestamps, include_timestamps=options.include_timestamps,
include_speakers=options.include_speakers, include_speakers=options.include_speakers,
) )
# 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) transcript_note = await create_transcript_note(job_id, transcript_options)
job = require_job(job_id) job = require_job(job_id)
knowledge_title = options.knowledge_title or f"{options.title} · 知识点" knowledge_title = options.knowledge_title or f"{options.title} · 知识点"
@@ -188,11 +278,12 @@ async def create_transcript_artifacts(job_id, options):
markdown = await _knowledge_markdown(job, options.provider_id, options.model, knowledge_title) 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:]}" note_title = f"{knowledge_title} · {job_id[-8:]}-r{job.revision}-{signature[-6:]}"
marker = markdown.splitlines()[0] marker = markdown.splitlines()[0]
with _artifact_operation("knowledge"):
knowledge_note = await _create_note( knowledge_note = await _create_note(
note_title, markdown, options, marker, tags=["课程笔记", "知识点"] note_title, markdown, options, marker, tags=["课程笔记", "知识点"]
) )
with closing(connect()) as conn, transaction(conn): 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)) (job_id, job.revision, signature, knowledge_note.note_id))
return {"transcript": transcript_note, "knowledge_note": knowledge_note} return {"transcript": transcript_note, "knowledge_note": knowledge_note}
@@ -10,7 +10,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
PACKAGES = [ 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'], []), ('plugin', 'study-plan-kit', ['plugin.yaml', 'study-plan-kit.exe', 'README.md'], []),
('skill', 'note-reviewer', ['skill.yaml', 'prompt.md', 'README.md'], ['markdown-workbench']), ('skill', 'note-reviewer', ['skill.yaml', 'prompt.md', 'README.md'], ['markdown-workbench']),
('skill', 'course-note-rewriter', ['skill.yaml', 'prompt.md', 'README.md'], ['markdown-workbench']), ('skill', 'course-note-rewriter', ['skill.yaml', 'prompt.md', 'README.md'], ['markdown-workbench']),
+2 -2
View File
@@ -6,8 +6,8 @@
"kind": "plugin", "kind": "plugin",
"version": "1.0.0", "version": "1.0.0",
"file": "markdown-workbench-1.0.0.zip", "file": "markdown-workbench-1.0.0.zip",
"bytes": 405144, "bytes": 2445,
"sha256": "02203a73c7e6cac7b4001e98a6b1490a639296e76d74d13350f94b8facdb0d02", "sha256": "4ca777a5f4fdffcaa3424d4f5d158a593e9f388fd88ab54783630aa59a3e8f59",
"dependencies": [], "dependencies": [],
"license": null, "license": null,
"publication_status": "local-preview" "publication_status": "local-preview"
Binary file not shown.
@@ -1,16 +1,15 @@
# Markdown 笔记检查 1.0.0 # 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 起始行号。 - Agent 工具 `markdown-workbench.inspect_markdown`:传入 `text`,返回行数、字符数、标题、任务、未完成任务、重复标题、标题跳级及未闭合代码围栏。结果包含 1 起始行号。
- 命令 `检查选中 Markdown`:选择笔记中的文字后,在命令面板(Ctrl+P)执行;通知展示统计和前三条问题。不会修改选区。
- `example.md` 是可独立检查的示例,预期 3 个标题、2 项任务(1 项未完成)、2 条提示(标题跳级、重复标题)。 - `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 不申请权限不读取磁盘笔记、不连接网络、不需要密钥;只分析宿主显式传入的文本。
## 输入与限制 ## 输入与限制
@@ -5,11 +5,6 @@ description: 本地检查 Markdown 标题层级、重复标题、未完成任务
permissions: [] permissions: []
contributes: contributes:
tools: [markdown-workbench.inspect_markdown] tools: [markdown-workbench.inspect_markdown]
commands: [markdown-workbench.inspect-selection]
backend: backend:
type: mcp type: internal_rpc
transport: stdio transport: none
command: ./markdown-workbench.exe
args: []
startup_timeout_seconds: 10
tool_timeout_seconds: 10
@@ -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
+2 -4
View File
@@ -6,7 +6,7 @@ import pytest
from app.config import BACKEND_DIR from app.config import BACKEND_DIR
from app.container import build_container 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.agent.tools import ToolExecutionContext
from app.extensions.archive import install_zip 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 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') builder = load(ROOT / 'build_packages.py')
output = tmp_path / 'dist' output = tmp_path / 'dist'
catalog = builder.build(output) 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')) 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.success, result.error_message
assert result.output['summary']['issues'] == 2 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' assert runtime.skills.enable('note-reviewer').status == 'ready'
config = runtime.skills.build_agent_configuration('note-reviewer', [ModelCapability.chat, ModelCapability.tool_calling]) config = runtime.skills.build_agent_configuration('note-reviewer', [ModelCapability.chat, ModelCapability.tool_calling])
assert 'notes.read' in config.allowed_tools assert 'notes.read' in config.allowed_tools
+124
View File
@@ -149,6 +149,130 @@ def test_desktop_revision_conflict_recovers_marker_matched_note(monkeypatch):
assert result is recovered 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 "<!-- knowledge-note:test -->\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): def test_local_only_export_and_rebuild_keep_local_embedding_policy(monkeypatch):
from types import SimpleNamespace from types import SimpleNamespace
from app.contracts import TranscriptNoteRequest, IndexRebuildRequest from app.contracts import TranscriptNoteRequest, IndexRebuildRequest
+28 -1
View File
@@ -3,7 +3,7 @@ import json
from pathlib import Path from pathlib import Path
import pytest import pytest
from pydantic import TypeAdapter, ValidationError from pydantic import BaseModel, TypeAdapter, ValidationError
from app.agent import ToolRegistry from app.agent import ToolRegistry
from app.config import BACKEND_DIR, get_settings 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" 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( @pytest.mark.parametrize(
("effect_type", "payload"), ("effect_type", "payload"),
[ [
@@ -20,6 +20,7 @@ const actionError = ref('')
const showInstall = ref(false) const showInstall = ref(false)
const activeTab = ref<'info' | 'settings' | 'commands'>('info') const activeTab = ref<'info' | 'settings' | 'commands'>('info')
const pluginCommands = ref<PluginCommand[]>([]) const pluginCommands = ref<PluginCommand[]>([])
const restoreNoticeKey = ref(0)
onMounted(() => { void pluginStore.loadPlugins() }) 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') } } 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[]) { async function grant(id: string, permissions: string[]) {
if (!(await askConfirm(`${t('将授权:', 'Grant permissions: ')}${permissions.join(', ')}${t('是否继续?', 'Continue?')}`))) return if (!(await askConfirm(`${t('将授权:', 'Grant permissions: ')}${permissions.join(', ')}${t('是否继续?', 'Continue?')}`))) return
try { await pluginStore.grantPermissions(id, permissions) } try { await pluginStore.grantPermissions(id, permissions) }
@@ -65,9 +72,9 @@ const hasCommandContribution = computed(() =>
<template> <template>
<section class="feature-page"> <section class="feature-page">
<ExtensionRestoreNotice kind="plugin" /> <ExtensionRestoreNotice :key="restoreNoticeKey" kind="plugin" />
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" /> <ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<ExtensionInstallDialog v-if="showInstall" kind="Plugin" :install="pluginStore.installPlugin" @close="showInstall = false" @installed="showInstall = false; actionError = ''" /> <ExtensionInstallDialog v-if="showInstall" kind="Plugin" :install="pluginStore.installPlugin" @close="showInstall = false" @installed="installed" />
<header class="feature-header"> <header class="feature-header">
<div><h1>{{ t('Plugin 与 MCP', 'Plugins and MCP') }}</h1><p>{{ t('管理插件生命周期、MCP Host、权限和受控 Contribution。', 'Manage plugin lifecycles, MCP hosts, permissions, and controlled contributions.') }}</p></div> <div><h1>{{ t('Plugin 与 MCP', 'Plugins and MCP') }}</h1><p>{{ t('管理插件生命周期、MCP Host、权限和受控 Contribution。', 'Manage plugin lifecycles, MCP hosts, permissions, and controlled contributions.') }}</p></div>
<button class="button-primary" @click="showInstall = true">{{ t('安装 Plugin', 'Install Plugin') }}</button> <button class="button-primary" @click="showInstall = true">{{ t('安装 Plugin', 'Install Plugin') }}</button>
+9 -2
View File
@@ -14,8 +14,15 @@ import UserSkillEditor from './UserSkillEditor.vue'
const skillStore = useSkillStore() const skillStore = useSkillStore()
const actionError = ref('') const actionError = ref('')
const showInstall = ref(false) const showInstall = ref(false)
const restoreNoticeKey = ref(0)
onMounted(() => { void skillStore.loadSkills() }) onMounted(() => { void skillStore.loadSkills() })
function installed() {
showInstall.value = false
actionError.value = ''
restoreNoticeKey.value += 1
}
async function toggle(skillId: string, enabled: boolean) { async function toggle(skillId: string, enabled: boolean) {
try { enabled ? await skillStore.disableSkill(skillId) : await skillStore.enableSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : t('状态更新失败', 'Status update failed') } try { enabled ? await skillStore.disableSkill(skillId) : await skillStore.enableSkill(skillId) } catch (error) { actionError.value = error instanceof Error ? error.message : t('状态更新失败', 'Status update failed') }
@@ -28,9 +35,9 @@ async function uninstall(skillId: string, name: string) {
<template> <template>
<section class="feature-page"> <section class="feature-page">
<ExtensionRestoreNotice kind="skill" /> <ExtensionRestoreNotice :key="restoreNoticeKey" kind="skill" />
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" /> <ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<ExtensionInstallDialog v-if="showInstall" kind="Skill" :install="skillStore.installSkill" @close="showInstall = false" @installed="showInstall = false; actionError = ''" /> <ExtensionInstallDialog v-if="showInstall" kind="Skill" :install="skillStore.installSkill" @close="showInstall = false" @installed="installed" />
<header class="feature-header"><div><h1>{{ t('Skill 管理', 'Skill Management') }}</h1><p>{{ t('查看工作流使用的 Tool、权限、检索配置和模型要求。', 'Review the tools, permissions, retrieval settings, and model requirements used by workflows.') }}</p></div><button class="button-primary" @click="showInstall = true">{{ t('安装 Skill', 'Install Skill') }}</button></header> <header class="feature-header"><div><h1>{{ t('Skill 管理', 'Skill Management') }}</h1><p>{{ t('查看工作流使用的 Tool、权限、检索配置和模型要求。', 'Review the tools, permissions, retrieval settings, and model requirements used by workflows.') }}</p></div><button class="button-primary" @click="showInstall = true">{{ t('安装 Skill', 'Install Skill') }}</button></header>
<UserSkillEditor /> <UserSkillEditor />
<div v-if="skillStore.error || actionError" class="error-banner">{{ skillStore.error || actionError }}</div> <div v-if="skillStore.error || actionError" class="error-banner">{{ skillStore.error || actionError }}</div>