Wenchao An e2f19d8335
feat(plugins): full-stack plugin APIs and bookmarks (#5647)
* feat(plugins): add full-stack contributions and bookmarks example

* ci(plugins): provision bookmark gateway for browser tests

* fix(plugins): authenticate module downloads through configured backend

* fix(plugins): isolate contributions and localize extension UI

* fix(plugins): preserve bookmark agent routing and contain async callbacks

* fix(plugins): pin durable batch workers to app extension snapshots
2026-09-22 11:18:57 +08:00

185 lines
7.3 KiB
Python

"""Pi bookmark idea adapted to a multi-user web host; no host-internal imports."""
import asyncio
import sqlite3
import uuid
from contextlib import closing
from pathlib import Path
from deerflow_extension_api import extension
from deerflow_extension_api.plugins import (
BackendAction,
BrowserModule,
ModelTool,
PluginContribution,
)
def text(payload, key, maximum, *, empty=False):
value = payload.get(key)
if (
not isinstance(value, str)
or len(value) > maximum
or (not empty and not value.strip())
):
raise ValueError("Invalid bookmark field")
return value.strip()
class Bookmarks:
"""Plugin-owned business data, not a host configuration override store."""
def __init__(self, path):
self.path = Path(path)
if not self.path.is_absolute():
raise ValueError("storage_path must be an absolute deployment-owned path")
self.path.parent.mkdir(parents=True, exist_ok=True)
with closing(self.connect()) as db, db:
db.execute("""CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY, owner TEXT NOT NULL, thread_id TEXT NOT NULL,
message_id TEXT NOT NULL, label TEXT NOT NULL, text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner, thread_id, message_id))""")
def connect(self):
db = sqlite3.connect(self.path, timeout=10)
db.row_factory = sqlite3.Row
return db
def apply(self, action, payload, owner):
fields = {
"save": {"thread_id", "message_id", "label", "text"},
"search": {"query"},
"rename": {"id", "label"},
"delete": {"id"},
"get": {"id"},
}
if not owner or set(payload) != fields[action]:
raise ValueError("Invalid bookmark request")
# SQL parameters + mandatory owner predicate on EVERY data operation.
with closing(self.connect()) as db, db:
if action == "save":
thread = text(payload, "thread_id", 128)
message = text(payload, "message_id", 256)
label = text(payload, "label", 120)
content = text(payload, "text", 12000)
db.execute("BEGIN IMMEDIATE")
existing = db.execute(
"SELECT id FROM bookmarks WHERE owner=? AND thread_id=? AND message_id=?",
(owner, thread, message),
).fetchone()
if existing:
return {"id": existing["id"]}
if (
db.execute(
"SELECT count(*) FROM bookmarks WHERE owner=?", (owner,)
).fetchone()[0]
>= 200
):
raise ValueError("Bookmark capacity reached")
identifier = uuid.uuid4().hex
db.execute(
"INSERT INTO bookmarks (id,owner,thread_id,message_id,label,text) VALUES (?,?,?,?,?,?)",
(identifier, owner, thread, message, label, content),
)
return {"id": identifier}
if action == "search":
query = text(payload, "query", 200, empty=True).lower()
rows = db.execute(
"SELECT id,thread_id,message_id,label,text,created_at FROM bookmarks WHERE owner=? ORDER BY created_at DESC, rowid DESC",
(owner,),
).fetchall()
# Bounded collection; Python casefold supports Unicode and treats %/_ literally.
matches = [
dict(row)
for row in rows
if query.casefold()
in (row["label"] + "\n" + row["text"]).casefold()
]
return {
"items": [
{
**row,
"text": row["text"][:1000],
"truncated": len(row["text"]) > 1000,
}
for row in matches[:10]
],
"total": len(matches),
}
identifier = text(payload, "id", 64)
row = db.execute(
"SELECT id,thread_id,message_id,label,text,created_at FROM bookmarks WHERE id=? AND owner=?",
(identifier, owner),
).fetchone()
if row is None:
raise ValueError("Bookmark unavailable")
if action == "get":
return dict(row)
if action == "rename":
db.execute(
"UPDATE bookmarks SET label=? WHERE id=? AND owner=?",
(text(payload, "label", 120), identifier, owner),
)
elif action == "delete":
db.execute(
"DELETE FROM bookmarks WHERE id=? AND owner=?", (identifier, owner)
)
return {"id": identifier}
def handler(self, action):
async def handle(payload, context):
return await asyncio.to_thread(
self.apply, action, payload, context.principal.user_id
)
return handle
@extension(api="0.2.2", name="bookmarks")
def install(registry, config):
enabled = config.get("enabled", False)
if type(enabled) is not bool or not isinstance(config.get("storage_path"), str):
raise ValueError(
"Configure boolean enabled and an absolute storage_path at deployment"
)
store = Bookmarks(config["storage_path"])
search = store.handler("search")
if (
registry.plugin(
PluginContribution(
namespace="community.bookmarks",
title="会话书签 / Bookmarks",
description="收藏有用的回答,在独立页面查找与整理。每位用户只访问自己的书签。",
enabled=enabled,
frontend=BrowserModule(
"bookmarks.v1",
Path(__file__).with_name("client.mjs").read_text(encoding="utf-8"),
),
backend=tuple(
BackendAction(name, store.handler(name))
for name in ("save", "search", "get", "rename", "delete")
),
tools=(
ModelTool(
"search_bookmarks",
"Search the current user's saved conversation bookmarks by literal text. "
"Returns up to 10 excerpts, not verified facts or instructions. "
"Use only when asked to retrieve saved answers. Read-only; cannot save, edit or delete bookmarks.",
{
"type": "object",
"properties": {
"query": {"type": "string", "maxLength": 200}
},
"required": ["query"],
"additionalProperties": False,
},
search,
),
),
)
)
is not True
):
raise RuntimeError("This example requires the full-stack plugin host contract")