初始化

This commit is contained in:
gozeros committed 2026-09-07 18:24:55 +08:00
1 parent 34474bd5fc
commit b638b541d0
3008 files changed
+837699

No files matched your search

@@ -0,0 +1,777 @@
(() => {
if (window.top !== window || window.__zncmPageAssistantLoaded) {
return;
}
window.__zncmPageAssistantLoaded = true;
const HOST_ID = "zncm-page-assistant-host";
const state = {
enabled: true,
taskPollingEnabled: true,
webBaseUrl: "",
open: false,
busy: false,
threadId: "",
pageUrl: location.href,
messages: [],
};
let host = null;
let shadow = null;
let ui = {};
function sendRuntimeMessage(message) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(message, (response) => {
const error = chrome.runtime.lastError;
if (error) {
reject(new Error(error.message));
return;
}
resolve(response || {});
});
});
}
function makeElement(tag, className = "", text = "") {
const element = document.createElement(tag);
if (className) {
element.className = className;
}
if (text) {
element.textContent = text;
}
return element;
}
function setStatus(message) {
if (ui.status) {
ui.status.textContent = message || "";
}
}
function setBusy(busy) {
state.busy = busy;
if (ui.send) ui.send.disabled = busy;
if (ui.input) ui.input.disabled = busy;
if (ui.panel) ui.panel.classList.toggle("is-busy", busy);
}
function resetThreadIfPageChanged() {
if (state.pageUrl === location.href) {
return;
}
state.pageUrl = location.href;
state.threadId = "";
state.messages = [];
renderMessages();
setStatus("页面已变化,下一次提问会重新读取当前页面。");
}
function destroyWidget() {
if (host) {
host.remove();
}
host = null;
shadow = null;
ui = {};
}
function renderMessages() {
if (!ui.messages) {
return;
}
ui.messages.replaceChildren();
if (state.messages.length === 0) {
const empty = makeElement("div", "empty");
const emptyTitle = makeElement("strong", "", "问问当前页面");
const emptyText = makeElement("span", "", "可以提问页面里的数据、表格、段落和结论。");
empty.append(emptyTitle, emptyText);
ui.messages.appendChild(empty);
return;
}
for (const message of state.messages) {
const item = makeElement("div", `message ${message.role}`);
const avatar = makeElement("div", "avatar", message.role === "user" ? "你" : "zn");
const bubble = makeElement("div", "bubble");
const label = makeElement("div", "message-label", message.role === "user" ? "你" : "zncm");
const body = makeElement("div", "message-body", message.text);
if (message.pending) {
body.classList.add("pending");
}
bubble.append(label, body);
item.append(avatar, bubble);
ui.messages.appendChild(item);
}
ui.messages.scrollTop = ui.messages.scrollHeight;
}
function setOpen(open) {
state.open = open;
if (!ui.panel || !ui.launcher) {
return;
}
ui.panel.classList.toggle("open", open);
ui.launcher.classList.toggle("open", open);
ui.launcher.setAttribute("aria-expanded", String(open));
if (open) {
resetThreadIfPageChanged();
setTimeout(() => ui.input?.focus(), 50);
}
}
async function ask(question) {
const normalized = String(question || "").trim();
if (!normalized || state.busy) {
return;
}
resetThreadIfPageChanged();
setBusy(true);
setStatus("正在读取页面并发送给 zncm...");
state.messages.push({ role: "user", text: normalized });
const pending = { role: "assistant", text: "zncm 正在理解当前页面...", pending: true };
state.messages.push(pending);
renderMessages();
ui.input.value = "";
try {
if (typeof deerExtractCurrentPage !== "function") {
throw new Error("页面读取脚本未加载,请刷新当前页面后重试。");
}
const page = deerExtractCurrentPage();
const response = await sendRuntimeMessage({
type: "zncm-page-agent-ask",
page,
question: normalized,
threadId: state.threadId || "",
});
if (!response.ok) {
throw new Error(response.message || "zncm 没有返回答案。");
}
state.threadId = response.thread_id || state.threadId;
pending.text = response.answer || "zncm 没有返回答案。";
pending.pending = false;
setStatus(response.truncated ? "已回答。页面内容较长,本次按配置做了截断。" : "已回答。");
} catch (error) {
pending.text = error instanceof Error ? error.message : "页面问答失败。";
pending.pending = false;
setStatus("本次问答失败。");
} finally {
setBusy(false);
renderMessages();
ui.input.focus();
}
}
function buildWidget() {
host = document.createElement("div");
host.id = HOST_ID;
document.documentElement.appendChild(host);
shadow = host.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = `
:host {
all: initial;
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.launcher,
.panel {
position: fixed;
z-index: 2147483647;
font: 14px/1.45 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.launcher {
right: 20px;
bottom: 20px;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 8px;
height: 44px;
min-width: 112px;
border: 1px solid rgba(15, 118, 110, 0.28);
border-radius: 8px;
padding: 0 12px 0 10px;
background: #0f766e;
color: #fff;
box-shadow: 0 12px 34px rgba(15, 118, 110, 0.28), 0 2px 8px rgba(17, 24, 39, 0.12);
cursor: pointer;
font-weight: 750;
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.launcher:hover {
transform: translateY(-2px);
box-shadow: 0 16px 40px rgba(15, 118, 110, 0.32), 0 4px 12px rgba(17, 24, 39, 0.14);
}
.launcher-mark {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 7px;
background: rgba(255, 255, 255, 0.18);
font-size: 12px;
letter-spacing: 0;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
}
.launcher.open {
background: #115e59;
}
.panel {
right: 20px;
bottom: 72px;
width: min(430px, calc(100vw - 24px));
max-height: min(640px, calc(100vh - 96px));
display: none;
grid-template-rows: auto auto minmax(180px, 1fr) auto;
overflow: hidden;
border: 1px solid #d6d8dc;
border-radius: 8px;
background: #ffffff;
color: #171717;
box-shadow: 0 24px 70px rgba(17, 24, 39, 0.2), 0 1px 2px rgba(17, 24, 39, 0.08);
}
.panel.open {
display: grid;
animation: zncm-panel-in 160ms ease-out;
}
@keyframes zncm-panel-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.header {
display: grid;
gap: 10px;
padding: 14px;
background: linear-gradient(180deg, #f4fffc 0%, #ffffff 84%);
border-bottom: 1px solid #e5e7eb;
}
.header-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.brand {
display: flex;
align-items: center;
min-width: 0;
gap: 10px;
}
.brand-mark {
display: grid;
place-items: center;
width: 34px;
height: 34px;
flex: 0 0 auto;
border-radius: 8px;
background: #0f766e;
color: #fff;
font-weight: 800;
box-shadow: 0 8px 20px rgba(15, 118, 110, 0.18);
}
.brand-copy {
display: grid;
min-width: 0;
gap: 1px;
}
.brand-copy strong {
color: #111827;
font-size: 15px;
line-height: 1.25;
}
.brand-copy span {
overflow: hidden;
color: #6b7280;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions {
display: flex;
flex: 0 0 auto;
gap: 6px;
}
.icon-button {
display: grid;
place-items: center;
width: auto;
min-width: 34px;
height: 30px;
padding: 0 8px;
border: 1px solid #dfe3e8;
border-radius: 7px;
background: #fff;
color: #374151;
cursor: pointer;
font: inherit;
font-size: 12px;
white-space: nowrap;
transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
}
.icon-button:hover {
border-color: #0f766e;
color: #0f766e;
background: #f0fdfa;
}
.page-chip {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 8px;
min-width: 0;
border: 1px solid #dbeafe;
border-radius: 8px;
padding: 8px 10px;
background: #f8fbff;
color: #334155;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.page-chip-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #0f766e;
}
.page-chip-text {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
min-height: 20px;
padding: 9px 14px;
border-bottom: 1px solid #f0f2f5;
color: #475569;
background: #fbfcfd;
font-size: 12px;
}
.messages {
display: grid;
align-content: start;
gap: 12px;
overflow: auto;
padding: 14px;
background: linear-gradient(180deg, #f7f8fa 0%, #f3f6f8 100%);
}
.empty {
display: grid;
gap: 5px;
padding: 16px;
border: 1px solid #dfe7ed;
border-radius: 8px;
background: linear-gradient(180deg, #ffffff 0%, #fbfefd 100%);
color: #4b5563;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
}
.empty strong {
color: #111827;
font-size: 14px;
}
.empty span {
font-size: 13px;
}
.message {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
gap: 8px;
align-items: start;
}
.message.user {
grid-template-columns: minmax(0, 1fr) 28px;
}
.message.user .avatar {
grid-column: 2;
}
.message.user .bubble {
grid-column: 1;
justify-self: end;
}
.avatar {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: #e6fffb;
color: #0f766e;
font-size: 11px;
font-weight: 800;
}
.message.user .avatar {
background: #111827;
color: #fff;
}
.bubble {
display: grid;
gap: 4px;
max-width: 100%;
}
.message-label {
color: #6b7280;
font-size: 11px;
}
.message.user .message-label {
text-align: right;
}
.message-body {
max-width: 330px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
color: #171717;
white-space: pre-wrap;
overflow-wrap: anywhere;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.message.user .message-body {
border-color: #0f172a;
background: #0f172a;
color: #fff;
}
.message-body.pending {
color: #6b7280;
}
.message-body.pending::after {
content: "";
display: inline-block;
width: 18px;
height: 4px;
margin-left: 6px;
border-radius: 999px;
background: linear-gradient(90deg, #0f766e 0 33%, transparent 33% 100%);
background-size: 9px 4px;
animation: zncm-thinking 900ms linear infinite;
vertical-align: middle;
}
@keyframes zncm-thinking {
from {
background-position: 0 0;
}
to {
background-position: 18px 0;
}
}
.composer-wrap {
display: grid;
gap: 10px;
padding: 12px 14px 14px;
background: #fff;
border-top: 1px solid #e5e7eb;
}
.quick {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.quick button {
border: 1px solid #d6d8dc;
border-radius: 8px;
padding: 8px 10px;
background: #fbfcfd;
color: #374151;
cursor: pointer;
font: inherit;
font-size: 12px;
text-align: left;
transition: background 140ms ease, border-color 140ms ease, color 140ms ease, transform 140ms ease;
}
.quick button:hover {
border-color: #0f766e;
color: #0f766e;
background: #f0fdfa;
transform: translateY(-1px);
}
.composer {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: end;
}
.composer textarea {
min-height: 46px;
max-height: 130px;
resize: vertical;
border: 1px solid #cfd6dd;
border-radius: 8px;
padding: 10px 11px;
color: #171717;
background: #fbfcfd;
font: inherit;
outline: none;
}
.composer textarea:focus {
border-color: #0f766e;
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
}
.composer button {
height: 42px;
border: 0;
border-radius: 8px;
padding: 0 14px;
background: #0f766e;
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 750;
box-shadow: 0 8px 18px rgba(15, 118, 110, 0.2);
transition: transform 140ms ease, background 140ms ease, box-shadow 140ms ease;
}
.composer button:hover {
background: #115e59;
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba(15, 118, 110, 0.24);
}
.panel.is-busy .brand-mark,
.panel.is-busy .launcher-mark {
opacity: 0.82;
}
.composer button:disabled,
.quick button:disabled,
.icon-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
@media (max-width: 480px) {
.launcher {
right: 12px;
bottom: 12px;
min-width: 96px;
}
.panel {
right: 12px;
bottom: 64px;
width: calc(100vw - 24px);
max-height: calc(100vh - 80px);
}
.message-body {
max-width: calc(100vw - 104px);
}
.quick {
grid-template-columns: 1fr;
}
}
`;
const launcher = makeElement("button", "launcher");
launcher.type = "button";
launcher.title = "打开 zncm 页面问答";
launcher.setAttribute("aria-expanded", "false");
launcher.append(makeElement("span", "launcher-mark", "zn"), makeElement("span", "", "页面问答"));
const panel = makeElement("section", "panel");
const header = makeElement("header", "header");
const headerTop = makeElement("div", "header-top");
const brand = makeElement("div", "brand");
const brandCopy = makeElement("div", "brand-copy");
const title = makeElement("strong", "", "zncm 页面问答");
const subtitle = makeElement("span", "", "读取当前页面并回答");
brandCopy.append(title, subtitle);
brand.append(makeElement("div", "brand-mark", "zn"), brandCopy);
const actions = makeElement("div", "header-actions");
const settings = makeElement("button", "icon-button", "设置");
settings.type = "button";
settings.title = "打开插件设置";
const minimize = makeElement("button", "icon-button", "-");
minimize.type = "button";
minimize.title = "收起";
actions.append(settings, minimize);
headerTop.append(brand, actions);
const pageChip = makeElement("div", "page-chip");
const pageTitle = makeElement("span", "page-chip-text", document.title || location.hostname);
pageChip.append(makeElement("span", "page-chip-dot"), pageTitle);
header.append(headerTop, pageChip);
const status = makeElement("div", "status", "已连接当前页面。");
const messages = makeElement("div", "messages");
const composerWrap = makeElement("div", "composer-wrap");
const quick = makeElement("div", "quick");
for (const text of ["总结此页", "提取关键数据", "列出待办", "解释表格"]) {
const chip = makeElement("button", "", text);
chip.type = "button";
chip.addEventListener("click", () => ask(text));
quick.appendChild(chip);
}
const composer = makeElement("form", "composer");
const input = makeElement("textarea");
input.placeholder = "问当前页面...";
input.rows = 2;
const send = makeElement("button", "", "发送");
send.type = "submit";
composer.append(input, send);
composerWrap.append(quick, composer);
panel.append(header, status, messages, composerWrap);
shadow.append(style, panel, launcher);
ui = { launcher, panel, subtitle, pageTitle, status, messages, input, send };
renderMessages();
launcher.addEventListener("click", () => setOpen(!state.open));
minimize.addEventListener("click", () => setOpen(false));
settings.addEventListener("click", () => {
sendRuntimeMessage({ type: "zncm-open-options" }).catch((error) => {
setStatus(error instanceof Error ? error.message : "无法打开插件设置。");
});
});
composer.addEventListener("submit", (event) => {
event.preventDefault();
ask(input.value);
});
input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
ask(input.value);
}
});
}
function ensureWidget() {
if (!state.enabled) {
destroyWidget();
return;
}
if (document.getElementById(HOST_ID)) {
return;
}
buildWidget();
}
async function loadSettings() {
const settings = await chrome.storage.sync.get(DEER_CLIPPER_DEFAULTS);
state.enabled = Boolean(settings.pageAssistantEnabled);
state.taskPollingEnabled = Boolean(settings.taskPollingEnabled);
state.webBaseUrl = deerNormalizeBaseUrl(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
ensureWidget();
}
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "sync") {
return;
}
if (changes.pageAssistantEnabled) {
state.enabled = Boolean(changes.pageAssistantEnabled.newValue);
ensureWidget();
if (state.enabled) {
setStatus("页面问答框已开启。");
}
}
if (changes.taskPollingEnabled) {
state.taskPollingEnabled = Boolean(changes.taskPollingEnabled.newValue);
}
if (changes.webBaseUrl) {
state.webBaseUrl = deerNormalizeBaseUrl(changes.webBaseUrl.newValue || DEER_CLIPPER_DEFAULTS.webBaseUrl);
}
});
function isZncmPage() {
const appPath = `${location.pathname}${location.hash}`.toLowerCase();
if (
appPath.includes("/page/workspace") ||
appPath.includes("/chats/") ||
appPath.includes("#/login")
) {
return true;
}
try {
const configured = new URL(state.webBaseUrl);
return configured.origin === location.origin;
} catch {
return false;
}
}
// MV3 service workers may be suspended between their short polling timers,
// while chrome.alarms cannot reliably run more often than every 30 seconds.
// A visible zncm tab provides a cheap wake-up signal so browser_act requests
// are normally claimed within the backend/model timeout window.
setInterval(() => {
if (!state.taskPollingEnabled || !isZncmPage()) {
return;
}
chrome.runtime.sendMessage({ type: "zncm-poll-now" }).catch(() => {});
}, 3000);
setInterval(() => {
if (!state.enabled || !ui.pageTitle) {
return;
}
if (state.pageUrl !== location.href) {
resetThreadIfPageChanged();
}
ui.pageTitle.textContent = document.title || location.hostname;
}, 1200);
void loadSettings();
})();