Wenchao An 2a9beb34b9
feat(plugins): support manifests and static asset directories (#5685)
* feat(plugins): support manifest-backed browser assets

* fix(plugins): validate asset roots and clarify module timeouts

* fix(plugins): sandbox asset documents and support Turbo imports

* docs(gateway): keep guidance within the merged size budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-23 15:40:22 +08:00

217 lines
6.8 KiB
JavaScript

// Original web adaptation of Pi's bookmark concept. No React/DeerFlow imports.
export 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("link", "", {
rel: "stylesheet",
crossorigin: "use-credentials",
href: new URL("../styles.css", import.meta.url).href,
});
const intro = make("div", "", { class: "intro" });
intro.append(
make("img", "", {
crossorigin: "use-credentials",
src: new URL("../bookmark.svg", import.meta.url).href,
alt: "",
width: "28",
height: "28",
}),
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();
},
};
}