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

279 lines
9.6 KiB
JavaScript

// Original web adaptation of Pi's bookmark concept. No React/DeerFlow imports.
function mountBookmarks(root, context) {
const zh = context.locale.startsWith("zh");
const words = zh
? {
heading: "留住值得再看的回答",
description:
"从会话菜单收藏回答,再回到这里查找。只有你可以访问这些书签。",
search: "搜索书签",
submit: "搜索",
empty: "还没有匹配的书签",
open: "返回会话",
rename: "保存名称",
remove: "删除",
confirm: "确认删除",
cancel: "取消",
expand: "查看完整内容",
loading: "正在加载…",
error: "操作未完成,请重试。",
label: "书签名称",
}
: {
heading: "Keep answers worth returning to",
description:
"Save an answer from the conversation menu. Your bookmarks are private to your account.",
search: "Search bookmarks",
submit: "Search",
empty: "No matching bookmarks",
open: "Open conversation",
rename: "Save name",
remove: "Delete",
confirm: "Confirm delete",
cancel: "Cancel",
expand: "Read full answer",
loading: "Loading…",
error: "Could not complete the action. Please retry.",
label: "Bookmark name",
};
const make = (tag, content, attributes = {}) => {
const element = document.createElement(tag);
if (content) element.textContent = content;
for (const [key, value] of Object.entries(attributes))
element.setAttribute(key, value);
return element;
};
const style = make(
"style",
`
:host {display:block;color:inherit;font:inherit} * {box-sizing:border-box}
.intro {padding:24px;background:linear-gradient(125deg,#edf9f4,#f1f4ff);border-radius:16px;color:#1b3930;margin-bottom:22px}
h3 {font-size:23px;letter-spacing:-.5px;margin:0 0 8px} p {font-size:14px;line-height:1.7;margin:0}
form {display:flex;gap:10px;margin:0 0 14px} input {font:inherit;color:inherit;background:transparent;border:1px solid #8885;border-radius:9px;padding:10px 12px;min-width:0;flex:1}
button,a {font:inherit;font-size:13px;cursor:pointer;border:1px solid #8885;border-radius:8px;padding:8px 12px;background:transparent;color:inherit;text-decoration:none}
button:disabled {opacity:.5;cursor:wait} button:hover,a:hover {background:#8881} .search-button {background:#216549;color:white} .search-button:hover {background:#194f39}
article {border:1px solid #8884;border-radius:14px;padding:20px;margin:14px 0} .name {display:flex;gap:8px;flex-wrap:wrap;margin-bottom:15px}
pre {white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;font-size:14px;line-height:1.8;margin:0 0 16px;max-height:320px;overflow:auto}
.actions {display:flex;gap:9px;align-items:center;flex-wrap:wrap} .meta {font-size:12px;opacity:.6;margin-bottom:12px}
[role=status],[role=alert] {font-size:13px;margin:8px 0} [role=alert] {color:#bd4040}
`,
);
const intro = make("div", "", { class: "intro" });
intro.append(make("h3", words.heading), make("p", words.description));
const form = make("form");
const query = make("input", "", {
type: "search",
placeholder: words.search,
"aria-label": words.search,
maxlength: "200",
});
const search = make("button", words.submit, {
type: "submit",
class: "search-button",
});
form.append(query, search);
const status = make("p", "", { role: "status" });
const error = make("p", "", { role: "alert" });
const list = make("div");
root.append(style, intro, form, status, error, list);
let generation = 0;
let disposed = false;
const active = () => !disposed && !context.signal.aborted;
async function run(button, operation) {
button.disabled = true;
error.textContent = "";
try {
await operation();
} catch {
if (active()) error.textContent = words.error;
} finally {
if (active()) button.disabled = false;
}
}
async function load() {
const current = ++generation;
status.textContent = words.loading;
try {
const data = await context.callBackend("search", { query: query.value });
if (!active() || current !== generation) return;
status.textContent = data.total
? zh
? `共 ${data.total} 条 · 显示前 10 条,可搜索缩小范围`
: `${data.total} saved · Showing up to 10; search to narrow results`
: words.empty;
list.replaceChildren();
for (const item of data.items) {
const card = make("article");
const row = make("div", "", { class: "name" });
const label = make("input", "", {
"aria-label": words.label,
maxlength: "120",
});
label.value = item.label;
const rename = make("button", words.rename, { type: "button" });
rename.addEventListener(
"click",
() =>
void run(rename, async () => {
await context.callBackend("rename", {
id: item.id,
label: label.value,
});
await load();
}),
{ signal: context.signal },
);
row.append(label, rename);
const content = make("pre", item.text);
const actions = make("div", "", { class: "actions" });
// The host resolves current routing metadata, including for old bookmarks.
const open = make("button", words.open, { type: "button" });
open.addEventListener(
"click",
() =>
void run(open, async () => {
if (!context.openConversation)
throw new Error("Host conversation navigation is unavailable");
await context.openConversation(item.thread_id);
}),
{ signal: context.signal },
);
actions.append(open);
if (item.truncated) {
const expand = make("button", words.expand, { type: "button" });
expand.addEventListener(
"click",
() =>
void run(expand, async () => {
const answer = await context.callBackend("get", {
id: item.id,
});
if (active()) {
content.textContent = answer.text;
expand.remove();
}
}),
{ signal: context.signal },
);
actions.append(expand);
}
const remove = make("button", words.remove, { type: "button" });
const cancel = make("button", words.cancel, { type: "button" });
cancel.hidden = true;
cancel.addEventListener(
"click",
() => {
remove.textContent = words.remove;
cancel.hidden = true;
},
{ signal: context.signal },
);
remove.addEventListener(
"click",
() => {
if (cancel.hidden) {
remove.textContent = words.confirm;
cancel.hidden = false;
return;
}
void run(remove, async () => {
await context.callBackend("delete", { id: item.id });
await load();
});
},
{ signal: context.signal },
);
actions.append(remove, cancel);
card.append(
row,
make("div", item.created_at, { class: "meta" }),
content,
actions,
);
list.append(card);
}
} catch (cause) {
if (active() && current === generation) status.textContent = "";
throw cause;
}
}
form.addEventListener(
"submit",
(event) => {
event.preventDefault();
void run(search, load);
},
{ signal: context.signal },
);
void run(search, load);
return {
dispose() {
disposed = true;
generation++;
root.replaceChildren();
},
};
}
export default {
apiVersion: 1,
module: "bookmarks.v1",
icon: "bookmark",
surfaces: [
{
id: "library",
slot: "page",
title: "Bookmarks",
navigation: {
label: "My bookmarks",
labelZh: "我的书签",
icon: "bookmark",
},
mount: mountBookmarks,
},
],
conversationActions(_t, locale = "en") {
const zh = locale.startsWith("zh");
return {
label: zh ? "书签" : "Bookmarks",
icon: "bookmark",
actions: [
{
id: "save-answer",
label: zh ? "收藏最后一条回答" : "Save last answer",
icon: "bookmark",
available: (settings) => settings.enabled === true,
async execute(context, services) {
const answer = await services.latestVisibleAnswer(context);
if (!answer) {
services.showMessage(
zh ? "暂无可收藏的回答" : "No visible answer to save",
);
return;
}
if (answer.text.length > 12000) {
services.showMessage(
zh
? "回答超过 12,000 字符,暂不支持收藏。"
: "Answers longer than 12,000 characters cannot be bookmarked yet.",
);
return;
}
await services.callBackend("save", {
thread_id: context.thread.thread_id,
message_id: answer.id,
label: answer.text.replace(/\s+/g, " ").trim().slice(0, 80),
text: answer.text,
});
services.showMessage(
zh
? "已收藏,可从侧边栏打开“我的书签”查看"
: "Saved. Open My bookmarks in the sidebar.",
);
},
},
],
};
},
};