fix: complete 0.5.1 demo workflows
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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"<!-- transcription:{job_id}:{job.revision}:{options_hash} -->"
|
||||
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"<!-- knowledge-note:{job.job_id}:{job.revision}:{provider_id}:{model} -->",
|
||||
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}
|
||||
|
||||
|
||||
@@ -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']),
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
Binary file not shown.
@@ -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 不申请权限、不读取磁盘笔记、不连接网络、不需要密钥;只分析宿主显式传入的文本。
|
||||
|
||||
## 输入与限制
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 "<!-- 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):
|
||||
from types import SimpleNamespace
|
||||
from app.contracts import TranscriptNoteRequest, IndexRebuildRequest
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -20,6 +20,7 @@ const actionError = ref('')
|
||||
const showInstall = ref(false)
|
||||
const activeTab = ref<'info' | 'settings' | 'commands'>('info')
|
||||
const pluginCommands = ref<PluginCommand[]>([])
|
||||
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(() =>
|
||||
|
||||
<template>
|
||||
<section class="feature-page">
|
||||
<ExtensionRestoreNotice kind="plugin" />
|
||||
<ExtensionRestoreNotice :key="restoreNoticeKey" kind="plugin" />
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -14,8 +14,15 @@ import UserSkillEditor from './UserSkillEditor.vue'
|
||||
const skillStore = useSkillStore()
|
||||
const actionError = ref('')
|
||||
const showInstall = ref(false)
|
||||
const restoreNoticeKey = ref(0)
|
||||
onMounted(() => { void skillStore.loadSkills() })
|
||||
|
||||
function installed() {
|
||||
showInstall.value = false
|
||||
actionError.value = ''
|
||||
restoreNoticeKey.value += 1
|
||||
}
|
||||
|
||||
|
||||
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') }
|
||||
@@ -28,9 +35,9 @@ async function uninstall(skillId: string, name: string) {
|
||||
|
||||
<template>
|
||||
<section class="feature-page">
|
||||
<ExtensionRestoreNotice kind="skill" />
|
||||
<ExtensionRestoreNotice :key="restoreNoticeKey" kind="skill" />
|
||||
<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>
|
||||
<UserSkillEditor />
|
||||
<div v-if="skillStore.error || actionError" class="error-banner">{{ skillStore.error || actionError }}</div>
|
||||
|
||||
Reference in New Issue
Block a user