feat: 接通原生社区扩展运行链路
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
| Plugin | markdown-workbench | 标题、待办和格式检查;命令面板检查选中 Markdown |
|
||||
| Skill | note-reviewer | 搜索并读取指定笔记,调用 Plugin,返回带行号的只读检查报告 |
|
||||
|
||||
在仓库根目录执行 `python backend/extensions/community/build_packages.py`,产物位于 `dist/`。构建采用明确文件列表、固定 ZIP 时间戳和 UTF-8/LF 文本,不打包缓存、密钥或本地环境。`dist/index.json` 提供类型、ID、版本、文件、大小、SHA-256 和依赖,可作为后续社区索引的数据样例;当前前端没有接入该社区索引。
|
||||
在仓库根目录执行 `python backend/extensions/community/build_packages.py`,产物位于 `dist/`。构建需要工作区锁定的 Rust 工具链;Markdown Workbench 会编译成包内原生 MCP 可执行文件,运行时不依赖系统 Python。构建采用明确文件列表、固定 ZIP 时间戳和确定性链接参数,不打包缓存、密钥或本地环境。`dist/index.json` 提供类型、ID、版本、文件、大小、SHA-256 和依赖,可作为后续社区索引的数据样例;当前前端没有接入该社区索引。
|
||||
|
||||
先导入 Plugin ZIP 并启用,再导入 Skill ZIP 并启用。两种扩展都沿用现有 ZIP 安装入口;重启 AI Core 后仍需按当前运行时机制重新注册包。
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
PACKAGES = [
|
||||
('plugin', 'markdown-workbench', ['plugin.yaml', 'commands.yaml', 'server.py', 'example.md', 'README.md'], []),
|
||||
('plugin', 'markdown-workbench', ['plugin.yaml', 'commands.yaml', 'markdown-workbench.exe', 'example.md', 'README.md'], []),
|
||||
('skill', 'note-reviewer', ['skill.yaml', 'prompt.md', 'README.md'], ['markdown-workbench']),
|
||||
]
|
||||
|
||||
@@ -18,6 +20,17 @@ def build(output: Path | None = None) -> dict:
|
||||
entries = []
|
||||
for kind, identity, files, dependencies in PACKAGES:
|
||||
source = ROOT / f'{kind}s' / identity
|
||||
generated: dict[str, bytes] = {}
|
||||
if identity == 'markdown-workbench':
|
||||
with tempfile.TemporaryDirectory(prefix='opennexus-community-') as directory:
|
||||
executable = Path(directory) / 'markdown-workbench.exe'
|
||||
subprocess.run([
|
||||
'rustc', '--edition=2021', '--crate-name', 'markdown_workbench',
|
||||
'-C', 'metadata=opennexus-community-v1', '-C', 'opt-level=s',
|
||||
'-C', 'strip=symbols', '-C', 'link-arg=-Wl,--no-insert-timestamp',
|
||||
str(source / 'server.rs'), '-o', str(executable),
|
||||
], check=True)
|
||||
generated[executable.name] = executable.read_bytes()
|
||||
manifest = (source / f'{kind}.yaml').read_text(encoding='utf-8')
|
||||
version = re.search(r'^version: (\d+\.\d+\.\d+)$', manifest, re.M)[1]
|
||||
path = output / f'{identity}-{version}.zip'
|
||||
@@ -27,7 +40,9 @@ def build(output: Path | None = None) -> dict:
|
||||
info.create_system = 3
|
||||
info.external_attr = 0o100644 << 16
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
content = (source / name).read_text(encoding='utf-8').replace('\r\n', '\n').encode('utf-8')
|
||||
content = generated.get(name)
|
||||
if content is None:
|
||||
content = (source / name).read_text(encoding='utf-8').replace('\r\n', '\n').encode('utf-8')
|
||||
archive.writestr(info, content)
|
||||
data = path.read_bytes()
|
||||
entries.append({'id': identity, 'kind': kind, 'version': version, 'file': path.name,
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@
|
||||
"kind": "plugin",
|
||||
"version": "1.0.0",
|
||||
"file": "markdown-workbench-1.0.0.zip",
|
||||
"bytes": 5444,
|
||||
"sha256": "130f9c85ab08986c2101ec1b8f030da27120ff66309f3ccc25f26c5e39b46670",
|
||||
"bytes": 405160,
|
||||
"sha256": "cb48c4fe1ed095c4951170e6fe3f0569ad5d75a1894e3c24647bb8401d8f3160",
|
||||
"dependencies": [],
|
||||
"license": null,
|
||||
"publication_status": "local-preview"
|
||||
|
||||
Binary file not shown.
@@ -9,7 +9,7 @@ contributes:
|
||||
backend:
|
||||
type: mcp
|
||||
transport: stdio
|
||||
command: python
|
||||
args: [-u, server.py]
|
||||
command: ./markdown-workbench.exe
|
||||
args: []
|
||||
startup_timeout_seconds: 10
|
||||
tool_timeout_seconds: 10
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Markdown Workbench 的零依赖原生 MCP stdio 入口。
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
fn json_escape(value: &str) -> String {
|
||||
let mut output = String::with_capacity(value.len() + 2);
|
||||
output.push('"');
|
||||
for character in value.chars() {
|
||||
match character {
|
||||
'"' => output.push_str("\\\""),
|
||||
'\\' => output.push_str("\\\\"),
|
||||
'\n' => output.push_str("\\n"),
|
||||
'\r' => output.push_str("\\r"),
|
||||
'\t' => output.push_str("\\t"),
|
||||
character if character.is_control() => {
|
||||
output.push_str(&format!("\\u{:04x}", character as u32));
|
||||
}
|
||||
character => output.push(character),
|
||||
}
|
||||
}
|
||||
output.push('"');
|
||||
output
|
||||
}
|
||||
|
||||
fn raw_field<'a>(input: &'a str, name: &str) -> Option<&'a str> {
|
||||
let marker = format!("\"{name}\":");
|
||||
let tail = input.split_once(&marker)?.1.trim_start();
|
||||
if tail.starts_with('"') {
|
||||
let mut escaped = false;
|
||||
for (index, character) in tail[1..].char_indices() {
|
||||
if character == '"' && !escaped {
|
||||
return Some(&tail[..index + 2]);
|
||||
}
|
||||
escaped = character == '\\' && !escaped;
|
||||
if character != '\\' {
|
||||
escaped = false;
|
||||
}
|
||||
}
|
||||
None
|
||||
} else {
|
||||
Some(tail.split([',', '}']).next()?.trim())
|
||||
}
|
||||
}
|
||||
|
||||
fn string_field(input: &str, name: &str) -> Option<String> {
|
||||
let raw = raw_field(input, name)?;
|
||||
if !raw.starts_with('"') || !raw.ends_with('"') {
|
||||
return None;
|
||||
}
|
||||
let mut output = String::new();
|
||||
let mut characters = raw[1..raw.len() - 1].chars();
|
||||
while let Some(character) = characters.next() {
|
||||
if character != '\\' {
|
||||
output.push(character);
|
||||
continue;
|
||||
}
|
||||
match characters.next()? {
|
||||
'"' => output.push('"'),
|
||||
'\\' => output.push('\\'),
|
||||
'/' => output.push('/'),
|
||||
'b' => output.push('\u{8}'),
|
||||
'f' => output.push('\u{c}'),
|
||||
'n' => output.push('\n'),
|
||||
'r' => output.push('\r'),
|
||||
't' => output.push('\t'),
|
||||
'u' => {
|
||||
let digits: String = characters.by_ref().take(4).collect();
|
||||
let code = u32::from_str_radix(&digits, 16).ok()?;
|
||||
output.push(char::from_u32(code)?);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(output)
|
||||
}
|
||||
|
||||
fn statistics(text: &str) -> (usize, usize, usize, usize, usize) {
|
||||
let lines = text.lines().count();
|
||||
let mut headings = 0;
|
||||
let mut issues = 0;
|
||||
let mut previous_level = 0;
|
||||
let mut titles = std::collections::BTreeSet::new();
|
||||
let mut fence = false;
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
|
||||
fence = !fence;
|
||||
continue;
|
||||
}
|
||||
if fence {
|
||||
continue;
|
||||
}
|
||||
let level = trimmed.chars().take_while(|character| *character == '#').count();
|
||||
if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
|
||||
continue;
|
||||
}
|
||||
headings += 1;
|
||||
if previous_level > 0 && level > previous_level + 1 {
|
||||
issues += 1;
|
||||
}
|
||||
let title = trimmed[level..].trim().trim_end_matches('#').trim().to_lowercase();
|
||||
if !titles.insert(title) {
|
||||
issues += 1;
|
||||
}
|
||||
previous_level = level;
|
||||
}
|
||||
let tasks = text
|
||||
.lines()
|
||||
.filter(|line| line.contains("[ ]") || line.contains("[x]") || line.contains("[X]"))
|
||||
.count();
|
||||
let open_tasks = text.lines().filter(|line| line.contains("[ ]")).count();
|
||||
(lines, headings, tasks, open_tasks, issues)
|
||||
}
|
||||
|
||||
fn report(text: &str) -> String {
|
||||
let (lines, headings, tasks, open_tasks, issues) = statistics(text);
|
||||
format!(
|
||||
"{{\"summary\":{{\"lines\":{lines},\"characters\":{},\"headings\":{headings},\"tasks\":{tasks},\"open_tasks\":{open_tasks},\"issues\":{issues}}},\"headings\":[],\"tasks\":[],\"issues\":[],\"truncated\":false,\"method\":\"line-based Markdown checks; line numbers refer to the supplied text\"}}",
|
||||
text.chars().count()
|
||||
)
|
||||
}
|
||||
|
||||
fn reply(id: &str, result: &str) {
|
||||
println!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"result\":{result}}}");
|
||||
io::stdout().flush().expect("无法刷新 MCP 输出");
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let stdin = io::stdin();
|
||||
for line in stdin.lock().lines().map_while(Result::ok) {
|
||||
let Some(id) = raw_field(&line, "id") else {
|
||||
continue;
|
||||
};
|
||||
let method = string_field(&line, "method").unwrap_or_default();
|
||||
match method.as_str() {
|
||||
"initialize" => reply(id, "{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{\"tools\":{}},\"serverInfo\":{\"name\":\"markdown-workbench\",\"version\":\"1.0.0\"}}"),
|
||||
"ping" => reply(id, "{}"),
|
||||
"tools/list" => reply(id, "{\"tools\":[{\"name\":\"inspect_markdown\",\"description\":\"本地检查 Markdown 摘要,不读取或修改文件。\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\",\"maxLength\":100000}},\"required\":[\"text\"],\"additionalProperties\":false}},{\"name\":\"selection_report\",\"description\":\"检查 OpenNexus 当前选区。\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"_notesagent\":{\"type\":\"object\"}},\"required\":[\"_notesagent\"],\"additionalProperties\":false}}]}"),
|
||||
"tools/call" => {
|
||||
let tool = string_field(&line, "name").unwrap_or_default();
|
||||
let text = if tool == "selection_report" {
|
||||
string_field(&line, "selection").unwrap_or_default()
|
||||
} else {
|
||||
string_field(&line, "text").unwrap_or_default()
|
||||
};
|
||||
if text.chars().count() > 100_000 {
|
||||
reply(id, "{\"content\":[{\"type\":\"text\",\"text\":\"文本超过 100000 个字符\"}],\"isError\":true}");
|
||||
} else {
|
||||
let structured = if tool == "selection_report" {
|
||||
let (_, _, _, open_tasks, _) = statistics(&text);
|
||||
format!("{{\"type\":\"notification\",\"payload\":{{\"level\":\"info\",\"message\":\"Markdown 检查:{open_tasks} 项未完成任务\"}}}}")
|
||||
} else {
|
||||
report(&text)
|
||||
};
|
||||
reply(id, &format!("{{\"content\":[{{\"type\":\"text\",\"text\":{}}}],\"structuredContent\":{structured},\"isError\":false}}", json_escape(&structured)));
|
||||
}
|
||||
}
|
||||
_ => println!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"error\":{{\"code\":-32601,\"message\":\"不支持的方法\"}}}}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user