feat: release 2.3.0 with workspace plugin extensions

This commit is contained in:
chenmingxuan committed 2026-10-27 21:38:43 +08:00
1 parent 8380057548
commit bb873ec0e0
44 files changed
+1497 -48

No files matched your search

+6 -1
View File
@@ -43,7 +43,12 @@ def download(url: str, progress: Callable[[str, int], None]) -> bytes:
def load_catalog() -> PluginCatalog:
data = download(settings.ARTEMIS_PLUGIN_MARKETPLACE_URL, lambda *_: None)
url = settings.ARTEMIS_PLUGIN_MARKETPLACE_URL
# Older hosts reject schema-v3 entries before checking version constraints.
# Keep their index intact, and route only the exact former official URL.
if url == "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json":
url = url.removesuffix("catalog.json") + "catalog-v3.json"
data = download(url, lambda *_: None)
if len(data) > 2 * 1024 * 1024:
raise ValueError("市场索引过大")
return PluginCatalog.model_validate_json(data)
+9 -4
View File
@@ -12,6 +12,8 @@ from packaging.specifiers import SpecifierSet
from packaging.version import Version
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
from artemis.plugins.workspace import WorkspaceContribution
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_-]{0,47}$")]
@@ -94,7 +96,7 @@ class EvidenceSource(PluginModel):
class PluginManifest(PluginModel):
schema_version: Literal[1, 2] = 1
schema_version: Literal[1, 2, 3] = 1
id: Identifier
name: str = Field(min_length=1, max_length=100)
version: str
@@ -113,6 +115,7 @@ class PluginManifest(PluginModel):
targets: list[ExecutionTarget] = Field(default_factory=lambda: ["mixed"])
observation: PluginObservation | None = None
capabilities: PluginCapabilities | None = None
workspace: WorkspaceContribution | None = None
@field_validator("version")
@classmethod
@@ -130,14 +133,16 @@ class PluginManifest(PluginModel):
@model_validator(mode="after")
def has_capability(self):
if self.server is None and self.skill is None:
raise ValueError("插件至少需要 MCP 服务或 Skill")
if self.server is None and self.skill is None and self.workspace is None:
raise ValueError("插件至少需要 MCP 服务、Skill 或工作区扩展")
if self.workspace is not None and self.schema_version != 3:
raise ValueError("工作区扩展需要 schema_version=3")
for name, field in self.config.items():
if field.secret and field.default:
raise ValueError(f"密钥字段 {name} 不允许默认值")
if self.schema_version == 1 and self.capabilities is not None:
raise ValueError("能力契约需要 schema_version=2;旧清单保持兼容")
if self.schema_version == 2:
if self.schema_version >= 2:
if self.capabilities is None:
raise ValueError("第二版插件必须声明 capabilities")
if self.observation:
+5
View File
@@ -105,6 +105,7 @@ class PluginStore:
if not existing:
profile = PluginProfile(
is_default=True,
authorized=manifest.server is None and manifest.skill is None,
values={k: v.default for k, v in manifest.config.items() if not v.secret},
)
db.execute(
@@ -236,6 +237,8 @@ class PluginStore:
def selections(self, target: str = "mixed") -> list[PluginSelection]:
selections = []
for plugin in self.list():
if not plugin["manifest"].get("server") and not plugin["manifest"].get("skill"):
continue
if not plugin["enabled"] or not self._supports_task(
plugin["manifest"].get("targets", ["mixed"]), target
):
@@ -264,6 +267,8 @@ class PluginStore:
pinned = []
for choice in self.selections(target) if selections is None else selections:
manifest, profile, _ = self.resolve(choice)
if manifest.server is None and manifest.skill is None:
raise ValueError(f"插件 {manifest.name} 是工作区扩展,无需加入执行任务")
if not self._supports_task(manifest.targets, target):
raise ValueError(f"插件 {manifest.name} 不支持 {target} 任务")
pinned.append(
+68
View File
@@ -0,0 +1,68 @@
"""Declarative workspace contributions; no plugin JavaScript or model execution."""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
class WorkspaceModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class PanelWidth(WorkspaceModel):
preferred: int = Field(default=340, ge=200, le=1000)
minimum: int = Field(default=240, ge=160, le=800)
maximum: int = Field(default=480, ge=200, le=1200)
@model_validator(mode="after")
def ordered(self):
if not self.minimum <= self.preferred <= self.maximum:
raise ValueError("面板宽度必须满足 minimum ≤ preferred ≤ maximum")
return self
class WorkspaceField(WorkspaceModel):
id: str = Field(pattern=r"^[a-z][a-z0-9_]{0,47}$")
label: str = Field(min_length=1, max_length=100)
description: str = Field(default="", max_length=1000)
kind: Literal["choice", "text"]
options: list[str] = Field(default_factory=list, max_length=12)
max_length: int = Field(default=2000, ge=1, le=10000)
report: bool = True
@model_validator(mode="after")
def valid_options(self):
if self.kind == "choice" and not self.options:
raise ValueError("选择字段必须声明 options")
if self.kind == "text" and self.options:
raise ValueError("文本字段不接受 options")
if len(set(self.options)) != len(self.options) or any(
not option.strip() or len(option) > min(100, self.max_length) for option in self.options
):
raise ValueError("选项必须非空、唯一且不超过长度限制")
return self
class WorkspacePanel(WorkspaceModel):
id: str = Field(pattern=r"^[a-z][a-z0-9_]{0,47}$")
title: str = Field(min_length=1, max_length=100)
side: Literal["left", "right"]
kind: Literal["initial_prompt", "form"]
width: PanelWidth = Field(default_factory=PanelWidth)
class WorkspaceContribution(WorkspaceModel):
panels: list[WorkspacePanel] = Field(min_length=1, max_length=2)
fields: list[WorkspaceField] = Field(default_factory=list, max_length=20)
@model_validator(mode="after")
def unique_contributions(self):
if len({panel.id for panel in self.panels}) != len(self.panels) or len(
{panel.kind for panel in self.panels}
) != len(self.panels):
raise ValueError("面板 id 和 kind 不得重复")
if len({field.id for field in self.fields}) != len(self.fields):
raise ValueError("工作区字段 id 不得重复")
if bool(self.fields) != any(panel.kind == "form" for panel in self.panels):
raise ValueError("表单面板与 fields 必须同时声明")
return self