Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ffa069151 | ||
|
|
3d5ffa1cb6 |
@@ -20,7 +20,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #2563eb;
|
||||
color: #1f7a8c;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -65,7 +65,7 @@
|
||||
}
|
||||
|
||||
.chatpapers-empty-icon {
|
||||
color: #2563eb;
|
||||
color: #1f7a8c;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
@@ -141,17 +141,41 @@
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.chatpapers-toolbar,
|
||||
.chatpapers-actions {
|
||||
.chatpapers-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chatpapers-composer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chatpapers-composer .chatpapers-input {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
min-height: 72px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.chatpapers-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.chatpapers-actions .chatpapers-btn {
|
||||
justify-content: center;
|
||||
min-width: 4.5em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chatpapers-btn {
|
||||
@@ -484,6 +508,7 @@
|
||||
|
||||
.chatpapers-multi-root .chatpapers-header,
|
||||
.chatpapers-multi-root .chatpapers-paper-chips,
|
||||
.chatpapers-multi-root .chatpapers-cite-bar,
|
||||
.chatpapers-multi-root .chatpapers-status,
|
||||
.chatpapers-multi-root .chatpapers-toolbar,
|
||||
.chatpapers-multi-root .chatpapers-composer {
|
||||
@@ -503,10 +528,14 @@
|
||||
|
||||
.chatpapers-multi-root .chatpapers-composer {
|
||||
margin-top: 2px;
|
||||
padding-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||
}
|
||||
|
||||
.chatpapers-multi-root .chatpapers-composer .chatpapers-input {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.chatpapers-multi-root .chatpapers-toolbar {
|
||||
padding-top: 2px;
|
||||
}
|
||||
@@ -539,18 +568,18 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 72px;
|
||||
max-height: 88px;
|
||||
overflow: auto;
|
||||
margin-bottom: 2px;
|
||||
scrollbar-width: thin;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chatpapers-chip {
|
||||
display: inline-block;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 260px;
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
@@ -558,3 +587,168 @@
|
||||
background: color-mix(in srgb, #2563eb 10%, transparent);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chatpapers-chip-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 210px;
|
||||
}
|
||||
|
||||
.chatpapers-chip-remove {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0.65;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chatpapers-chip-remove:hover {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||
}
|
||||
|
||||
.chatpapers-chip-add {
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
max-width: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chatpapers-chip-add:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, #2563eb 12%, transparent);
|
||||
}
|
||||
|
||||
.chatpapers-chip-add:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chatpapers-cite-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 8px;
|
||||
padding: 2px 0 4px;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.chatpapers-cite-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chatpapers-cite-picker {
|
||||
position: relative;
|
||||
flex: 1 1 180px;
|
||||
min-width: 140px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.chatpapers-cite-trigger {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.chatpapers-cite-trigger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
|
||||
.chatpapers-cite-trigger:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chatpapers-cite-trigger-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chatpapers-cite-chevron {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chatpapers-cite-picker.is-open .chatpapers-cite-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.chatpapers-cite-menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
z-index: 40;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||
background: Canvas;
|
||||
color: CanvasText;
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, #000 18%, transparent);
|
||||
padding: 4px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.chatpapers-cite-option {
|
||||
appearance: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chatpapers-cite-option:hover {
|
||||
background: color-mix(in srgb, #2563eb 14%, transparent);
|
||||
}
|
||||
|
||||
.chatpapers-cite-option.is-active {
|
||||
background: color-mix(in srgb, #2563eb 22%, transparent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chatpapers-cite-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="context-fill" stroke="context-stroke">
|
||||
<path d="M3.5 4.5h13a1.5 1.5 0 0 1 1.5 1.5v7a1.5 1.5 0 0 1-1.5 1.5H8l-3.2 2.4a.5.5 0 0 1-.8-.4V14.5H3.5A1.5 1.5 0 0 1 2 13V6a1.5 1.5 0 0 1 1.5-1.5z"/>
|
||||
<circle cx="7" cy="9.5" r="1"/>
|
||||
<circle cx="10" cy="9.5" r="1"/>
|
||||
<circle cx="13" cy="9.5" r="1"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<!-- ChatPapers: paper + chat — sidebar glyph -->
|
||||
<path
|
||||
fill="context-fill"
|
||||
fill-opacity="0.92"
|
||||
d="M3.2 2.8h7.4c.4 0 .7.15.95.4l2.05 2.1c.25.25.4.58.4.95V14.2c0 .9-.73 1.63-1.63 1.63H3.2c-.9 0-1.63-.73-1.63-1.63V4.43C1.57 3.53 2.3 2.8 3.2 2.8z"
|
||||
/>
|
||||
<path
|
||||
fill="context-fill"
|
||||
fill-opacity="0.35"
|
||||
d="M10.6 2.85v2.35c0 .45.37.82.82.82h2.35L10.6 2.85z"
|
||||
/>
|
||||
<path
|
||||
stroke="context-stroke"
|
||||
stroke-opacity="0.55"
|
||||
stroke-width="0.9"
|
||||
stroke-linecap="round"
|
||||
d="M4.35 7.1h5.1M4.35 9.15h4.2M4.35 11.2h3.4"
|
||||
/>
|
||||
<path
|
||||
fill="context-fill"
|
||||
d="M11.35 9.05h5.1c.85 0 1.55.7 1.55 1.55v3.35c0 .85-.7 1.55-1.55 1.55h-1.55l-1.55 1.25a.4.4 0 0 1-.65-.32v-.93h-1.35c-.85 0-1.55-.7-1.55-1.55v-3.35c0-.85.7-1.55 1.55-1.55z"
|
||||
/>
|
||||
<circle cx="12.55" cy="12.15" r="0.55" fill="context-stroke" fill-opacity="0.9"/>
|
||||
<circle cx="13.95" cy="12.15" r="0.55" fill="context-stroke" fill-opacity="0.9"/>
|
||||
<circle cx="15.35" cy="12.15" r="0.55" fill="context-stroke" fill-opacity="0.9"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 677 B After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 836 B After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -27,12 +27,12 @@ chat-copied = Copied to clipboard
|
||||
chat-empty-title = Start a conversation
|
||||
chat-empty-desc = Summarize this paper or ask a question about methods, results, and limitations.
|
||||
|
||||
itemmenu-chat = ChatPapers: Open chat
|
||||
itemmenu-chat = ChatPapers: Chat
|
||||
itemmenu-compare = ChatPapers: Compare papers
|
||||
|
||||
multi-title = Multi-paper chat
|
||||
multi-empty-title = Read papers together
|
||||
multi-empty-desc = Enter a research topic and click Topic review for writing-ready notes; or compare, summarize each, or ask freely.
|
||||
multi-title = ChatPapers chat
|
||||
multi-empty-title = Paper chat
|
||||
multi-empty-desc = Ask about one paper, or click Add papers to bring more from your library for comparison and topic review.
|
||||
multi-placeholder = Enter a topic or question, e.g. Diffusion models for medical image segmentation
|
||||
multi-topic-review = Topic review
|
||||
multi-need-topic = Enter a research topic in the box, then click Topic review
|
||||
@@ -41,6 +41,20 @@ multi-summary-each = Summarize each
|
||||
multi-common = Common themes
|
||||
multi-close = Close
|
||||
multi-no-items = Please select items first
|
||||
multi-need-two = Select at least 2 items to compare
|
||||
multi-too-many = { $count } items selected; using the first 8
|
||||
multi-ready = { $count } papers ready for topic review
|
||||
multi-need-two = Add at least 2 papers for comparison actions (use Add papers)
|
||||
multi-too-many = Up to 8 papers; currently { $count }
|
||||
multi-ready = { $count } paper(s) loaded — chat away, or Add papers to expand
|
||||
multi-add-paper = Add papers
|
||||
multi-remove-paper = Remove from chat
|
||||
multi-add-none = No new papers added (may already be in the chat)
|
||||
multi-paper-added = Added { $added }; now { $count } papers
|
||||
multi-paper-removed = Removed; now { $count } papers
|
||||
|
||||
cite-style = Citation style
|
||||
cite-copy = Copy bibliography
|
||||
cite-export = Export file
|
||||
cite-loading = Loading styles…
|
||||
cite-empty = No styles available
|
||||
cite-copied = Bibliography copied
|
||||
cite-exported = Bibliography exported
|
||||
cite-export-cancel = Export cancelled
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
itemmenu-label = ChatPapers: Summarize
|
||||
itemmenu-chat = ChatPapers: Open chat
|
||||
itemmenu-label = ChatPapers
|
||||
itemmenu-chat = ChatPapers: Chat
|
||||
itemmenu-compare = ChatPapers: Compare papers
|
||||
|
||||
@@ -27,12 +27,12 @@ chat-copied = 已复制到剪贴板
|
||||
chat-empty-title = 开始对话
|
||||
chat-empty-desc = 可以一键总结本文,或提问方法、结果与局限等问题。
|
||||
|
||||
itemmenu-chat = ChatPapers:打开对话
|
||||
itemmenu-chat = ChatPapers:对话
|
||||
itemmenu-compare = ChatPapers:多篇对照
|
||||
|
||||
multi-title = 多篇对照
|
||||
multi-empty-title = 多论文整体阅读
|
||||
multi-empty-desc = 先在输入框写研究主题,点「主题综述」生成可写入要点;也可对比、各自摘要或自由提问。
|
||||
multi-title = ChatPapers 对话
|
||||
multi-empty-title = 文献对话
|
||||
multi-empty-desc = 单篇可直接提问;点「添加文献」从文库加入更多论文后可对比与主题综述。
|
||||
multi-placeholder = 输入研究主题或问题,例如:扩散模型在医学影像分割中的应用
|
||||
multi-topic-review = 主题综述
|
||||
multi-need-topic = 请先在输入框填写研究主题,再点「主题综述」
|
||||
@@ -41,7 +41,21 @@ multi-summary-each = 各自摘要
|
||||
multi-common = 共同主题
|
||||
multi-close = 关闭
|
||||
multi-no-items = 请先选择文献
|
||||
multi-need-two = 请至少选择 2 篇文献再对照
|
||||
multi-too-many = 已选择 { $count } 篇,将使用前 8 篇
|
||||
multi-ready = 已选择 { $count } 篇文献,可开始整体阅读与主题综述
|
||||
multi-need-two = 请至少加入 2 篇文献后再做对比类操作(可点「添加文献」)
|
||||
multi-too-many = 最多支持 8 篇,当前已选 { $count } 篇
|
||||
multi-ready = 已载入 { $count } 篇文献,可开始对话;可用「添加文献」扩充
|
||||
multi-add-paper = 添加文献
|
||||
multi-remove-paper = 移出对话
|
||||
multi-add-none = 未加入新文献(可能已在对话中)
|
||||
multi-paper-added = 已加入 { $added } 篇,当前共 { $count } 篇
|
||||
multi-paper-removed = 已移出,当前共 { $count } 篇
|
||||
|
||||
cite-style = 引用样式
|
||||
cite-copy = 复制参考文献
|
||||
cite-export = 导出文件
|
||||
cite-loading = 加载样式…
|
||||
cite-empty = 无可用样式
|
||||
cite-copied = 参考文献已复制到剪贴板
|
||||
cite-exported = 参考文献已导出
|
||||
cite-export-cancel = 已取消导出
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
itemmenu-label = ChatPapers:总结
|
||||
itemmenu-chat = ChatPapers:打开对话
|
||||
itemmenu-label = ChatPapers
|
||||
itemmenu-chat = ChatPapers:对话
|
||||
itemmenu-compare = ChatPapers:多篇对照
|
||||
|
||||
|
After Width: | Height: | Size: 786 KiB |
@@ -0,0 +1,40 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
/**
|
||||
* zotero-plugin-toolkit's `_importESModule` still prefers ChromeUtils.import
|
||||
* when that property exists. On Zotero 9 / Firefox 128+, `import` remains as a
|
||||
* stub that throws "ChromeUtils.import() has been removed".
|
||||
*/
|
||||
export function fixToolkitImportESModule() {
|
||||
return {
|
||||
name: "chatpapers-fix-toolkit-importesmodule",
|
||||
setup(build) {
|
||||
build.onLoad(
|
||||
{ filter: /zotero-plugin-toolkit[\\/]dist[\\/]index\.js$/ },
|
||||
async (args) => {
|
||||
const text = await fs.readFile(args.path, "utf8");
|
||||
const next = text.replace(
|
||||
/function _importESModule\(path\) \{[\s\S]*?\n\}/,
|
||||
`function _importESModule(path) {
|
||||
\tif (typeof ChromeUtils.importESModule === "function") {
|
||||
\t\ttry {
|
||||
\t\t\treturn ChromeUtils.importESModule(path, { global: "contextual" });
|
||||
\t\t} catch (e) {
|
||||
\t\t\treturn ChromeUtils.importESModule(path);
|
||||
\t\t}
|
||||
\t}
|
||||
\tif (path.endsWith(".sys.mjs")) path = path.replace(/\\.sys\\.mjs$/, ".jsm");
|
||||
\treturn ChromeUtils.import(path);
|
||||
}`,
|
||||
);
|
||||
if (next === text) {
|
||||
console.warn(
|
||||
"[chatpapers] toolkit _importESModule patch did not match; check toolkit version",
|
||||
);
|
||||
}
|
||||
return { contents: next, loader: "js" };
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getString } from "../../utils/locale";
|
||||
import { openMultiChatDialog } from "./multiChatDialog";
|
||||
|
||||
const MENU_CHAT_ID = "chatpapers-itemmenu-chat";
|
||||
/** Legacy duplicate menu — unregister if still present from older builds. */
|
||||
const MENU_COMPARE_ID = "chatpapers-itemmenu-compare";
|
||||
|
||||
function getSelectedItems(): Zotero.Item[] {
|
||||
@@ -15,7 +16,18 @@ function getSelectedItems(): Zotero.Item[] {
|
||||
}
|
||||
}
|
||||
|
||||
function removeMenuById(id: string) {
|
||||
try {
|
||||
const doc = Zotero.getMainWindow()?.document;
|
||||
doc?.getElementById(id)?.remove();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function registerItemMenus() {
|
||||
removeMenuById(MENU_COMPARE_ID);
|
||||
|
||||
const icon = `chrome://${config.addonRef}/content/icons/favicon@0.5x.png`;
|
||||
|
||||
ztoolkit.Menu.register("item", {
|
||||
@@ -28,24 +40,4 @@ export function registerItemMenus() {
|
||||
openMultiChatDialog(items);
|
||||
},
|
||||
});
|
||||
|
||||
ztoolkit.Menu.register("item", {
|
||||
tag: "menuitem",
|
||||
id: MENU_COMPARE_ID,
|
||||
label: getString("itemmenu-compare"),
|
||||
icon,
|
||||
commandListener: () => {
|
||||
const items = getSelectedItems();
|
||||
if (items.length < 2) {
|
||||
new ztoolkit.ProgressWindow("ChatPapers")
|
||||
.createLine({
|
||||
text: getString("multi-need-two"),
|
||||
type: "fail",
|
||||
})
|
||||
.show();
|
||||
return;
|
||||
}
|
||||
openMultiChatDialog(items);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -198,15 +198,12 @@ export function openMultiChatDialog(items: Zotero.Item[]) {
|
||||
});
|
||||
|
||||
activeDialog = dialogHelper;
|
||||
dialogHelper.open(
|
||||
`ChatPapers · ${Math.min(selected.length, 8)} papers`,
|
||||
{
|
||||
dialogHelper.open("ChatPapers", {
|
||||
centerscreen: true,
|
||||
resizable: true,
|
||||
noDialogMode: true,
|
||||
fitContent: false,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Download,
|
||||
GitCompare,
|
||||
Layers,
|
||||
ListTree,
|
||||
MessageSquareText,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
Square,
|
||||
User,
|
||||
X,
|
||||
type IconNode,
|
||||
} from "lucide";
|
||||
import { chatStream, getRuntimeConfig } from "../llm/client";
|
||||
@@ -21,6 +25,13 @@ import {
|
||||
type MultiChatMode,
|
||||
} from "../pdf/multiContext";
|
||||
import { createChildNote } from "../zotero/notes";
|
||||
import {
|
||||
defaultCiteFormat,
|
||||
formatCiteText,
|
||||
listCiteFormatOptions,
|
||||
saveCiteToFile,
|
||||
type CiteFormatOption,
|
||||
} from "../zotero/citeExport";
|
||||
import { getString } from "../../utils/locale";
|
||||
import { markdownToHtml } from "../../utils/markdown";
|
||||
import { createLucideIcon } from "../../utils/icons";
|
||||
@@ -29,6 +40,11 @@ import {
|
||||
isAbortError,
|
||||
type AbortHandle,
|
||||
} from "../../utils/abort";
|
||||
import { pickLibraryItems } from "./pickItems";
|
||||
|
||||
const MAX_PAPERS = 8;
|
||||
|
||||
let rememberedCiteFormat = "";
|
||||
|
||||
export class MultiChatView {
|
||||
private body: HTMLElement;
|
||||
@@ -36,6 +52,7 @@ export class MultiChatView {
|
||||
private items: Zotero.Item[];
|
||||
private messages: ChatMessage[] = [];
|
||||
private abort?: AbortHandle;
|
||||
private citeFormats: CiteFormatOption[] = [];
|
||||
|
||||
private messagesEl!: HTMLElement;
|
||||
private inputEl!: HTMLTextAreaElement;
|
||||
@@ -44,11 +61,22 @@ export class MultiChatView {
|
||||
private sendBtn!: HTMLButtonElement;
|
||||
private stopBtn!: HTMLButtonElement;
|
||||
private emptyEl!: HTMLElement;
|
||||
private paperListEl!: HTMLElement;
|
||||
private citeBarEl!: HTMLElement;
|
||||
private citePickerEl!: HTMLElement;
|
||||
private citeTriggerEl!: HTMLButtonElement;
|
||||
private citeTriggerLabelEl!: HTMLElement;
|
||||
private citeMenuEl!: HTMLElement;
|
||||
private citeValue = "";
|
||||
private titleEl!: HTMLElement;
|
||||
private compareBtn!: HTMLButtonElement;
|
||||
private summaryEachBtn!: HTMLButtonElement;
|
||||
private commonBtn!: HTMLButtonElement;
|
||||
|
||||
constructor(doc: Document, body: HTMLElement, items: Zotero.Item[]) {
|
||||
this.doc = doc;
|
||||
this.body = body;
|
||||
this.items = normalizeLibraryItems(items);
|
||||
this.items = normalizeLibraryItems(items).slice(0, MAX_PAPERS);
|
||||
}
|
||||
|
||||
async mount(): Promise<void> {
|
||||
@@ -66,22 +94,13 @@ export class MultiChatView {
|
||||
size: 18,
|
||||
className: "chatpapers-brand-icon",
|
||||
}),
|
||||
this.el(
|
||||
"div",
|
||||
"chatpapers-brand-text",
|
||||
getString("multi-title"),
|
||||
),
|
||||
this.el("div", "chatpapers-brand-text", getString("multi-title")),
|
||||
);
|
||||
|
||||
const titles = this.items
|
||||
.map((it) => it.getField("title") || it.attachmentFilename || `#${it.id}`)
|
||||
.slice(0, 6);
|
||||
const more =
|
||||
this.items.length > 6 ? ` 等 ${this.items.length} 篇` : `(${this.items.length} 篇)`;
|
||||
|
||||
this.titleEl = this.el("div", "chatpapers-title");
|
||||
header.append(
|
||||
brand,
|
||||
this.el("div", "chatpapers-title", titles.join(" · ") + more),
|
||||
this.titleEl,
|
||||
this.el(
|
||||
"div",
|
||||
"chatpapers-meta",
|
||||
@@ -89,20 +108,13 @@ export class MultiChatView {
|
||||
),
|
||||
);
|
||||
|
||||
const paperList = this.el("div", "chatpapers-paper-chips");
|
||||
for (const [i, item] of this.items.entries()) {
|
||||
const chip = this.el(
|
||||
"span",
|
||||
"chatpapers-chip",
|
||||
`${i + 1}. ${item.getField("title") || item.attachmentFilename || item.id}`,
|
||||
);
|
||||
paperList.append(chip);
|
||||
}
|
||||
this.paperListEl = this.el("div", "chatpapers-paper-chips");
|
||||
this.citeBarEl = this.buildCiteBar();
|
||||
|
||||
this.messagesEl = this.el("div", "chatpapers-messages");
|
||||
this.emptyEl = this.el("div", "chatpapers-empty");
|
||||
this.emptyEl.append(
|
||||
createLucideIcon(this.doc, GitCompare, {
|
||||
createLucideIcon(this.doc, MessageSquareText, {
|
||||
size: 28,
|
||||
className: "chatpapers-empty-icon",
|
||||
}),
|
||||
@@ -115,19 +127,24 @@ export class MultiChatView {
|
||||
this.statusEl.append(this.statusTextEl);
|
||||
|
||||
const toolbar = this.el("div", "chatpapers-toolbar");
|
||||
this.compareBtn = this.iconBtn(GitCompare, getString("multi-compare"), () =>
|
||||
this.runModeGuarded("compare", getString("multi-compare")),
|
||||
);
|
||||
this.summaryEachBtn = this.iconBtn(
|
||||
ListTree,
|
||||
getString("multi-summary-each"),
|
||||
() => this.runModeGuarded("summary-each", getString("multi-summary-each")),
|
||||
);
|
||||
this.commonBtn = this.iconBtn(Layers, getString("multi-common"), () =>
|
||||
this.runModeGuarded("common-themes", getString("multi-common")),
|
||||
);
|
||||
toolbar.append(
|
||||
this.iconBtn(NotebookPen, getString("multi-topic-review"), () =>
|
||||
void this.runTopicReview(),
|
||||
),
|
||||
this.iconBtn(GitCompare, getString("multi-compare"), () =>
|
||||
this.runMode("compare", getString("multi-compare")),
|
||||
),
|
||||
this.iconBtn(ListTree, getString("multi-summary-each"), () =>
|
||||
this.runMode("summary-each", getString("multi-summary-each")),
|
||||
),
|
||||
this.iconBtn(Layers, getString("multi-common"), () =>
|
||||
this.runMode("common-themes", getString("multi-common")),
|
||||
),
|
||||
this.compareBtn,
|
||||
this.summaryEachBtn,
|
||||
this.commonBtn,
|
||||
this.iconBtn(Sparkles, getString("chat-save-note"), () =>
|
||||
this.saveLastNote(),
|
||||
),
|
||||
@@ -163,14 +180,17 @@ export class MultiChatView {
|
||||
|
||||
this.body.append(
|
||||
header,
|
||||
paperList,
|
||||
this.paperListEl,
|
||||
this.citeBarEl,
|
||||
this.messagesEl,
|
||||
this.statusEl,
|
||||
toolbar,
|
||||
composer,
|
||||
);
|
||||
|
||||
this.refreshPaperChrome();
|
||||
this.renderMessages();
|
||||
void this.loadCiteFormats();
|
||||
if (!cfg.provider || !cfg.baseUrl) {
|
||||
this.setStatus(getString("chat-need-config"), "warn");
|
||||
} else {
|
||||
@@ -183,9 +203,186 @@ export class MultiChatView {
|
||||
|
||||
destroy(): void {
|
||||
this.stop();
|
||||
try {
|
||||
const onDocClick = (this.citePickerEl as any)?._onDocClick;
|
||||
if (onDocClick) this.doc.removeEventListener("click", onDocClick, true);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.body.replaceChildren();
|
||||
}
|
||||
|
||||
private buildCiteBar(): HTMLElement {
|
||||
const bar = this.el("div", "chatpapers-cite-bar");
|
||||
const label = this.el(
|
||||
"span",
|
||||
"chatpapers-cite-label",
|
||||
getString("cite-style"),
|
||||
);
|
||||
|
||||
// Custom picker — native <select> breaks in Zotero dialogs (SelectParent / menupopup).
|
||||
this.citePickerEl = this.el("div", "chatpapers-cite-picker");
|
||||
this.citeTriggerEl = this.doc.createElement("button");
|
||||
this.citeTriggerEl.type = "button";
|
||||
this.citeTriggerEl.className = "chatpapers-cite-trigger";
|
||||
this.citeTriggerEl.title = getString("cite-style");
|
||||
this.citeTriggerLabelEl = this.el(
|
||||
"span",
|
||||
"chatpapers-cite-trigger-label",
|
||||
getString("cite-loading"),
|
||||
);
|
||||
this.citeTriggerEl.append(
|
||||
this.citeTriggerLabelEl,
|
||||
createLucideIcon(this.doc, ChevronDown, {
|
||||
size: 14,
|
||||
className: "chatpapers-cite-chevron",
|
||||
}),
|
||||
);
|
||||
this.citeMenuEl = this.el("div", "chatpapers-cite-menu");
|
||||
this.citeMenuEl.hidden = true;
|
||||
this.citePickerEl.append(this.citeTriggerEl, this.citeMenuEl);
|
||||
|
||||
this.citeTriggerEl.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.toggleCiteMenu();
|
||||
});
|
||||
|
||||
const onDocClick = (ev: Event) => {
|
||||
const t = ev.target as Node | null;
|
||||
if (!t || this.citePickerEl.contains(t)) return;
|
||||
this.closeCiteMenu();
|
||||
};
|
||||
this.doc.addEventListener("click", onDocClick, true);
|
||||
// Store for cleanup via destroy
|
||||
(this.citePickerEl as any)._onDocClick = onDocClick;
|
||||
|
||||
const copyBtn = this.iconBtn(Copy, getString("cite-copy"), () =>
|
||||
void this.copyCite(),
|
||||
);
|
||||
copyBtn.classList.add("chatpapers-cite-btn");
|
||||
const exportBtn = this.iconBtn(Download, getString("cite-export"), () =>
|
||||
void this.exportCite(),
|
||||
);
|
||||
exportBtn.classList.add("chatpapers-cite-btn");
|
||||
|
||||
bar.append(label, this.citePickerEl, copyBtn, exportBtn);
|
||||
return bar;
|
||||
}
|
||||
|
||||
private toggleCiteMenu() {
|
||||
if (this.citeMenuEl.hidden) this.openCiteMenu();
|
||||
else this.closeCiteMenu();
|
||||
}
|
||||
|
||||
private openCiteMenu() {
|
||||
this.citeMenuEl.hidden = false;
|
||||
this.citePickerEl.classList.add("is-open");
|
||||
}
|
||||
|
||||
private closeCiteMenu() {
|
||||
this.citeMenuEl.hidden = true;
|
||||
this.citePickerEl.classList.remove("is-open");
|
||||
}
|
||||
|
||||
private setCiteValue(value: string, label?: string) {
|
||||
this.citeValue = value;
|
||||
rememberedCiteFormat = value;
|
||||
const found = this.citeFormats.find((f) => f.value === value);
|
||||
this.citeTriggerLabelEl.textContent =
|
||||
label || found?.label || getString("cite-empty");
|
||||
this.citeTriggerEl.disabled = !value;
|
||||
}
|
||||
|
||||
private async loadCiteFormats() {
|
||||
try {
|
||||
this.citeFormats = await listCiteFormatOptions();
|
||||
this.citeMenuEl.replaceChildren();
|
||||
if (!this.citeFormats.length) {
|
||||
this.setCiteValue("", getString("cite-empty"));
|
||||
return;
|
||||
}
|
||||
for (const fmt of this.citeFormats) {
|
||||
const opt = this.doc.createElement("button");
|
||||
opt.type = "button";
|
||||
opt.className = "chatpapers-cite-option";
|
||||
opt.dataset.value = fmt.value;
|
||||
opt.textContent = fmt.label;
|
||||
opt.title = fmt.label;
|
||||
opt.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setCiteValue(fmt.value, fmt.label);
|
||||
this.closeCiteMenu();
|
||||
this.highlightCiteOption(fmt.value);
|
||||
});
|
||||
this.citeMenuEl.append(opt);
|
||||
}
|
||||
const preferred =
|
||||
(rememberedCiteFormat &&
|
||||
this.citeFormats.some((f) => f.value === rememberedCiteFormat) &&
|
||||
rememberedCiteFormat) ||
|
||||
defaultCiteFormat(this.citeFormats);
|
||||
this.setCiteValue(preferred);
|
||||
this.highlightCiteOption(preferred);
|
||||
} catch (e) {
|
||||
ztoolkit.log("loadCiteFormats failed", e);
|
||||
this.citeMenuEl.replaceChildren();
|
||||
this.setCiteValue("", getString("cite-empty"));
|
||||
}
|
||||
}
|
||||
|
||||
private highlightCiteOption(value: string) {
|
||||
for (const node of Array.from(this.citeMenuEl.children)) {
|
||||
const btn = node as HTMLElement;
|
||||
btn.classList.toggle("is-active", btn.dataset.value === value);
|
||||
}
|
||||
}
|
||||
|
||||
private currentCiteFormat(): string {
|
||||
return this.citeValue || rememberedCiteFormat || "";
|
||||
}
|
||||
|
||||
private async copyCite() {
|
||||
const format = this.currentCiteFormat();
|
||||
if (!format) {
|
||||
this.setStatus(getString("cite-empty"), "warn");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { text } = await formatCiteText(this.items, format);
|
||||
rememberedCiteFormat = format;
|
||||
try {
|
||||
new ztoolkit.Clipboard().addText(text, "text/unicode").copy();
|
||||
} catch {
|
||||
(Zotero.Utilities as any).Internal.copyTextToClipboard(text);
|
||||
}
|
||||
this.setStatus(getString("cite-copied"), "info");
|
||||
} catch (e) {
|
||||
this.setStatus(String(e), "error");
|
||||
}
|
||||
}
|
||||
|
||||
private async exportCite() {
|
||||
const format = this.currentCiteFormat();
|
||||
if (!format) {
|
||||
this.setStatus(getString("cite-empty"), "warn");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { text, ext } = await formatCiteText(this.items, format);
|
||||
rememberedCiteFormat = format;
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
const filename = `chatpapers-refs-${stamp}.${ext}`;
|
||||
const win = this.doc.defaultView || Zotero.getMainWindow();
|
||||
const saved = await saveCiteToFile(win as Window, filename, text);
|
||||
if (saved) this.setStatus(getString("cite-exported"), "info");
|
||||
else this.setStatus(getString("cite-export-cancel"), "warn");
|
||||
} catch (e) {
|
||||
this.setStatus(String(e), "error");
|
||||
}
|
||||
}
|
||||
|
||||
private el(tag: string, className?: string, text?: string): HTMLElement {
|
||||
const node = this.doc.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
@@ -218,6 +415,124 @@ export class MultiChatView {
|
||||
this.statusEl.dataset.kind = kind;
|
||||
}
|
||||
|
||||
private itemLabel(item: Zotero.Item): string {
|
||||
return (
|
||||
item.getField("title") ||
|
||||
item.attachmentFilename ||
|
||||
`#${item.id}`
|
||||
);
|
||||
}
|
||||
|
||||
private refreshPaperChrome() {
|
||||
const titles = this.items.map((it) => this.itemLabel(it)).slice(0, 4);
|
||||
const more =
|
||||
this.items.length > 4
|
||||
? ` 等 ${this.items.length} 篇`
|
||||
: `(${this.items.length} 篇)`;
|
||||
this.titleEl.textContent = titles.join(" · ") + more;
|
||||
|
||||
this.paperListEl.replaceChildren();
|
||||
for (const [i, item] of this.items.entries()) {
|
||||
const chip = this.el("span", "chatpapers-chip");
|
||||
const label = this.el(
|
||||
"span",
|
||||
"chatpapers-chip-label",
|
||||
`${i + 1}. ${this.itemLabel(item)}`,
|
||||
);
|
||||
label.title = this.itemLabel(item);
|
||||
chip.append(label);
|
||||
|
||||
if (this.items.length > 1) {
|
||||
const remove = this.doc.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "chatpapers-chip-remove";
|
||||
remove.title = getString("multi-remove-paper");
|
||||
remove.append(createLucideIcon(this.doc, X, { size: 11 }));
|
||||
remove.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.removeItem(item.id);
|
||||
});
|
||||
chip.append(remove);
|
||||
}
|
||||
this.paperListEl.append(chip);
|
||||
}
|
||||
|
||||
const addBtn = this.doc.createElement("button");
|
||||
addBtn.type = "button";
|
||||
addBtn.className = "chatpapers-chip chatpapers-chip-add";
|
||||
addBtn.title = getString("multi-add-paper");
|
||||
addBtn.append(
|
||||
createLucideIcon(this.doc, Plus, { size: 12 }),
|
||||
this.el("span", undefined, getString("multi-add-paper")),
|
||||
);
|
||||
addBtn.disabled = this.items.length >= MAX_PAPERS;
|
||||
addBtn.addEventListener("click", () => void this.addPapersFromLibrary());
|
||||
this.paperListEl.append(addBtn);
|
||||
|
||||
const multiOk = this.items.length >= 2;
|
||||
for (const btn of [this.compareBtn, this.summaryEachBtn, this.commonBtn]) {
|
||||
btn.disabled = !multiOk;
|
||||
btn.title = multiOk
|
||||
? btn.querySelector(".chatpapers-btn-label")?.textContent || ""
|
||||
: getString("multi-need-two");
|
||||
}
|
||||
}
|
||||
|
||||
private removeItem(id: number) {
|
||||
if (this.items.length <= 1) return;
|
||||
this.items = this.items.filter((it) => it.id !== id);
|
||||
this.refreshPaperChrome();
|
||||
this.setStatus(
|
||||
getString("multi-paper-removed", { args: { count: this.items.length } }),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
private async addPapersFromLibrary() {
|
||||
if (this.items.length >= MAX_PAPERS) {
|
||||
this.setStatus(
|
||||
getString("multi-too-many", { args: { count: this.items.length } }),
|
||||
"warn",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const picked = await pickLibraryItems({ multiSelect: true });
|
||||
if (!picked.length) return;
|
||||
|
||||
const existing = new Set(this.items.map((i) => i.id));
|
||||
const incoming = normalizeLibraryItems(picked).filter(
|
||||
(it) => !existing.has(it.id),
|
||||
);
|
||||
if (!incoming.length) {
|
||||
this.setStatus(getString("multi-add-none"), "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
const room = MAX_PAPERS - this.items.length;
|
||||
const toAdd = incoming.slice(0, room);
|
||||
this.items = [...this.items, ...toAdd];
|
||||
this.refreshPaperChrome();
|
||||
|
||||
if (incoming.length > room) {
|
||||
this.setStatus(
|
||||
getString("multi-too-many", { args: { count: this.items.length } }),
|
||||
"warn",
|
||||
);
|
||||
} else {
|
||||
this.setStatus(
|
||||
getString("multi-paper-added", {
|
||||
args: { added: toAdd.length, count: this.items.length },
|
||||
}),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.setStatus(String(e), "error");
|
||||
}
|
||||
}
|
||||
|
||||
private renderMessages() {
|
||||
this.messagesEl.replaceChildren();
|
||||
const visible = this.messages.filter((m) => m.role !== "system");
|
||||
@@ -321,11 +636,14 @@ export class MultiChatView {
|
||||
await this.run("chat", text);
|
||||
}
|
||||
|
||||
private async runMode(mode: MultiChatMode, label: string) {
|
||||
await this.run(mode, label);
|
||||
private runModeGuarded(mode: MultiChatMode, label: string) {
|
||||
if (this.items.length < 2) {
|
||||
this.setStatus(getString("multi-need-two"), "warn");
|
||||
return;
|
||||
}
|
||||
void this.run(mode, label);
|
||||
}
|
||||
|
||||
/** Topic-oriented lit review for writing; topic comes from the input box. */
|
||||
private async runTopicReview() {
|
||||
const topic = this.inputEl.value.trim();
|
||||
if (!topic) {
|
||||
@@ -403,13 +721,14 @@ export class MultiChatView {
|
||||
}
|
||||
const parent = this.items.find((i) => i.isRegularItem()) || this.items[0];
|
||||
if (!parent) return;
|
||||
const noteParent = parent.isAttachment() && parent.parentItemID
|
||||
const noteParent =
|
||||
parent.isAttachment() && parent.parentItemID
|
||||
? Zotero.Items.get(parent.parentItemID) || parent
|
||||
: parent;
|
||||
try {
|
||||
await createChildNote({
|
||||
parentItem: noteParent,
|
||||
title: `ChatPapers 多篇对照 · ${new Date().toLocaleString()}`,
|
||||
title: `ChatPapers 对话 · ${new Date().toLocaleString()}`,
|
||||
bodyMarkdown: last.content,
|
||||
});
|
||||
this.setStatus(getString("chat-note-saved"), "info");
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Open Zotero's built-in Select Items dialog and return chosen library items.
|
||||
*/
|
||||
export async function pickLibraryItems(options?: {
|
||||
multiSelect?: boolean;
|
||||
}): Promise<Zotero.Item[]> {
|
||||
const multiSelect = options?.multiSelect !== false;
|
||||
const mainWin = Zotero.getMainWindow?.() as
|
||||
| (Window & { openDialog?: (...args: any[]) => Window | null })
|
||||
| undefined;
|
||||
if (!mainWin?.openDialog) {
|
||||
throw new Error("无法打开文献选择窗口");
|
||||
}
|
||||
|
||||
const io: {
|
||||
multiSelect: boolean;
|
||||
onlyRegularItems: boolean;
|
||||
dataOut: number[] | null;
|
||||
itemTreeID: string;
|
||||
} = {
|
||||
multiSelect,
|
||||
onlyRegularItems: true,
|
||||
dataOut: null,
|
||||
itemTreeID: "chatpapers-select-items",
|
||||
};
|
||||
|
||||
const urls = [
|
||||
"chrome://zotero/content/selectItemsDialog.xhtml",
|
||||
"chrome://zotero/content/selectItemsDialog.xul",
|
||||
];
|
||||
|
||||
let opened = false;
|
||||
for (const url of urls) {
|
||||
try {
|
||||
mainWin.openDialog(
|
||||
url,
|
||||
"chatpapers-select-items-dialog",
|
||||
"chrome,modal,centerscreen,resizable=yes",
|
||||
io,
|
||||
);
|
||||
opened = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
ztoolkit.log("pickLibraryItems openDialog failed", url, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!opened) {
|
||||
throw new Error("当前 Zotero 版本无法打开文献选择对话框");
|
||||
}
|
||||
|
||||
const ids = Array.isArray(io.dataOut) ? io.dataOut : [];
|
||||
if (!ids.length) return [];
|
||||
|
||||
return (Zotero.Items.get(ids).filter(Boolean) as Zotero.Item[]) || [];
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { normalizeLibraryItems } from "../pdf/multiContext";
|
||||
|
||||
export interface CiteFormatOption {
|
||||
/** QuickCopy / export key, e.g. export:<id> or bibliography:<styleID> */
|
||||
value: string;
|
||||
label: string;
|
||||
kind: "export" | "bibliography";
|
||||
/** Lower sorts first within priority band */
|
||||
sortKey: string;
|
||||
/** 0 = BibTeX/LaTeX, 1 = GB/T 7714, 2 = user quick-copy, 3 = other */
|
||||
band: number;
|
||||
}
|
||||
|
||||
const BIBTEX_ID = "9cb70025-a888-4a29-a210-93ec52da40d4";
|
||||
const BIBLATEX_ID = "b6e39b57-8942-4d11-8259-342c46ce395f";
|
||||
|
||||
/** Prefer Better BibTeX / built-in BibTeX when available. */
|
||||
async function resolveTranslatorId(
|
||||
preferredLabels: string[],
|
||||
fallbackId?: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const translators = await (Zotero.Translators as any).getAllForType(
|
||||
"export",
|
||||
);
|
||||
const list = (translators || []) as Array<{
|
||||
translatorID: string;
|
||||
label: string;
|
||||
}>;
|
||||
for (const want of preferredLabels) {
|
||||
const exact = list.find(
|
||||
(t) => t.label.toLowerCase() === want.toLowerCase(),
|
||||
);
|
||||
if (exact) return exact.translatorID;
|
||||
}
|
||||
for (const want of preferredLabels) {
|
||||
const fuzzy = list.find((t) =>
|
||||
t.label.toLowerCase().includes(want.toLowerCase()),
|
||||
);
|
||||
if (fuzzy) return fuzzy.translatorID;
|
||||
}
|
||||
} catch (e) {
|
||||
ztoolkit.log("resolveTranslatorId failed", e);
|
||||
}
|
||||
return fallbackId || null;
|
||||
}
|
||||
|
||||
function isGbtStyle(id: string, title: string): boolean {
|
||||
const s = `${id} ${title}`.toLowerCase();
|
||||
return (
|
||||
s.includes("7714") ||
|
||||
s.includes("gb-t") ||
|
||||
s.includes("gbt") ||
|
||||
s.includes("china-national-standard")
|
||||
);
|
||||
}
|
||||
|
||||
/** Regular parent items suitable for citation export. */
|
||||
export function toCiteItems(items: Zotero.Item[]): Zotero.Item[] {
|
||||
const map = new Map<number, Zotero.Item>();
|
||||
for (const raw of normalizeLibraryItems(items)) {
|
||||
let item = raw;
|
||||
if (item.isAttachment() && item.parentItemID) {
|
||||
const parent = Zotero.Items.get(item.parentItemID);
|
||||
if (parent) item = parent;
|
||||
}
|
||||
if (item.isRegularItem()) map.set(item.id, item);
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dropdown options: BibTeX/BibLaTeX first, then GB/T 7714,
|
||||
* then the user's Quick Copy style, then other installed CSL styles.
|
||||
*/
|
||||
export async function listCiteFormatOptions(): Promise<CiteFormatOption[]> {
|
||||
try {
|
||||
await (Zotero.Schema as any).schemaUpdatePromise;
|
||||
} catch {
|
||||
try {
|
||||
await (Zotero.Schema as any).schemeUpdatePromise;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const options: CiteFormatOption[] = [];
|
||||
|
||||
const bibtexId = await resolveTranslatorId(
|
||||
["Better BibTeX", "BibTeX"],
|
||||
BIBTEX_ID,
|
||||
);
|
||||
if (bibtexId) {
|
||||
options.push({
|
||||
value: `export:${bibtexId}`,
|
||||
label: "BibTeX (LaTeX)",
|
||||
kind: "export",
|
||||
sortKey: "0-bibtex",
|
||||
band: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const biblatexId = await resolveTranslatorId(
|
||||
["Better BibLaTeX", "BibLaTeX"],
|
||||
BIBLATEX_ID,
|
||||
);
|
||||
if (biblatexId && biblatexId !== bibtexId) {
|
||||
options.push({
|
||||
value: `export:${biblatexId}`,
|
||||
label: "BibLaTeX (LaTeX)",
|
||||
kind: "export",
|
||||
sortKey: "1-biblatex",
|
||||
band: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let quickCopyStyleId = "";
|
||||
try {
|
||||
const setting = String(Zotero.Prefs.get("export.quickCopy.setting") || "");
|
||||
if (setting.startsWith("bibliography=")) {
|
||||
quickCopyStyleId = setting.slice("bibliography=".length);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const styles = (Zotero.Styles as any).getVisible() as Array<{
|
||||
styleID: string;
|
||||
title: string;
|
||||
}>;
|
||||
for (const style of styles || []) {
|
||||
const id = style.styleID;
|
||||
const title = style.title || id;
|
||||
if (!id) continue;
|
||||
let band = 3;
|
||||
if (isGbtStyle(id, title)) band = 1;
|
||||
else if (quickCopyStyleId && id === quickCopyStyleId) band = 2;
|
||||
options.push({
|
||||
value: `bibliography:${id}`,
|
||||
label: band === 1 ? `GB/T · ${title}` : title,
|
||||
kind: "bibliography",
|
||||
sortKey: title.toLowerCase(),
|
||||
band,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
ztoolkit.log("listCiteFormatOptions styles failed", e);
|
||||
}
|
||||
|
||||
options.sort((a, b) => {
|
||||
if (a.band !== b.band) return a.band - b.band;
|
||||
return a.sortKey.localeCompare(b.sortKey, "zh");
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function exportViaTranslator(
|
||||
items: Zotero.Item[],
|
||||
translatorID: string,
|
||||
): Promise<string> {
|
||||
const TranslateExport = (Zotero as any).Translate?.Export;
|
||||
if (!TranslateExport) {
|
||||
throw new Error("当前环境不支持导出翻译器");
|
||||
}
|
||||
const translation = new TranslateExport();
|
||||
translation.setItems(items);
|
||||
translation.setTranslator(translatorID);
|
||||
translation.setDisplayOptions?.({ exportNotes: false });
|
||||
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
let settled = false;
|
||||
translation.setHandler("done", (_obj: any, success: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (!success) {
|
||||
reject(new Error("导出失败"));
|
||||
return;
|
||||
}
|
||||
const text =
|
||||
(typeof translation.string === "string" && translation.string) ||
|
||||
(typeof _obj?.string === "string" && _obj.string) ||
|
||||
"";
|
||||
resolve(text);
|
||||
});
|
||||
translation.setHandler("error", (_obj: any, err: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
try {
|
||||
const maybe = translation.translate();
|
||||
if (maybe && typeof maybe.then === "function") {
|
||||
maybe.then((result: any) => {
|
||||
if (settled) return;
|
||||
if (typeof result === "string" && result) {
|
||||
settled = true;
|
||||
resolve(result);
|
||||
}
|
||||
}).catch((e: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(e instanceof Error ? e : new Error(String(e)));
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function quickCopyContent(
|
||||
items: Zotero.Item[],
|
||||
format: string,
|
||||
): Promise<{ text?: string; html?: string }> {
|
||||
const qc = Zotero.QuickCopy as any;
|
||||
let result = qc.getContentFromItems(items, format);
|
||||
if (result && typeof result.then === "function") {
|
||||
result = await result;
|
||||
}
|
||||
// Some builds expose getContentFromItemsAsync
|
||||
if (!result?.text && !result?.html && typeof qc.getContentFromItemsAsync === "function") {
|
||||
result = await qc.getContentFromItemsAsync(items, format);
|
||||
}
|
||||
return result || {};
|
||||
}
|
||||
|
||||
/** Format current papers as plain text for the selected cite format. */
|
||||
export async function formatCiteText(
|
||||
items: Zotero.Item[],
|
||||
formatValue: string,
|
||||
): Promise<{ text: string; ext: string; mimeHint: string }> {
|
||||
const citeItems = toCiteItems(items);
|
||||
if (!citeItems.length) {
|
||||
throw new Error("没有可导出的文献条目");
|
||||
}
|
||||
|
||||
if (formatValue.startsWith("export:")) {
|
||||
const translatorID = formatValue.slice("export:".length);
|
||||
let text = "";
|
||||
try {
|
||||
const result = await quickCopyContent(
|
||||
citeItems,
|
||||
`export=${translatorID}`,
|
||||
);
|
||||
text = result.text || "";
|
||||
} catch {
|
||||
text = "";
|
||||
}
|
||||
if (!text.trim()) {
|
||||
text = await exportViaTranslator(citeItems, translatorID);
|
||||
}
|
||||
if (!text.trim()) throw new Error("BibTeX 导出结果为空");
|
||||
return { text, ext: "bib", mimeHint: "application/x-bibtex" };
|
||||
}
|
||||
|
||||
const styleId = formatValue.startsWith("bibliography:")
|
||||
? formatValue.slice("bibliography:".length)
|
||||
: formatValue;
|
||||
const format = `bibliography=${styleId}`;
|
||||
const result = await quickCopyContent(citeItems, format);
|
||||
const text =
|
||||
(result.text && result.text.trim()) ||
|
||||
(result.html ? stripHtml(result.html) : "");
|
||||
if (!text) throw new Error("参考文献生成结果为空(请确认已安装该样式)");
|
||||
return { text, ext: "txt", mimeHint: "text/plain" };
|
||||
}
|
||||
|
||||
export async function saveCiteToFile(
|
||||
parentWin: Window,
|
||||
filename: string,
|
||||
content: string,
|
||||
): Promise<boolean> {
|
||||
const FilePickerCtor =
|
||||
(parentWin as any).FilePicker ||
|
||||
(ztoolkit.getGlobal("FilePicker" as any) as any);
|
||||
if (!FilePickerCtor) {
|
||||
// Fallback: copy only path unavailable
|
||||
throw new Error("无法打开另存为对话框");
|
||||
}
|
||||
const fp = new FilePickerCtor();
|
||||
fp.init(parentWin, "ChatPapers", fp.modeSave);
|
||||
const isBib = /\.bib$/i.test(filename);
|
||||
if (isBib) {
|
||||
fp.appendFilter("BibTeX", "*.bib");
|
||||
}
|
||||
fp.appendFilter("Text", "*.txt");
|
||||
fp.appendFilters(fp.filterAll);
|
||||
fp.defaultString = filename;
|
||||
|
||||
const rv = await fp.show();
|
||||
if (rv !== fp.returnOK && rv !== fp.returnReplace) {
|
||||
return false;
|
||||
}
|
||||
const path = fp.file;
|
||||
if (!path) return false;
|
||||
await Zotero.File.putContentsAsync(path, content);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function defaultCiteFormat(options: CiteFormatOption[]): string {
|
||||
if (!options.length) return "";
|
||||
const bib = options.find((o) => o.band === 0);
|
||||
if (bib) return bib.value;
|
||||
const gbt = options.find((o) => o.band === 1);
|
||||
if (gbt) return gbt.value;
|
||||
return options[0].value;
|
||||
}
|
||||
@@ -98,14 +98,17 @@ function restoreSlots(html: string, slots: string[]): string {
|
||||
}
|
||||
|
||||
function inlineFormat(text: string): string {
|
||||
// Keep placeholders intact while formatting
|
||||
let s = escapeHtml(text);
|
||||
// Un-escape placeholders that escapeHtml shouldn't touch anyway (%% is fine)
|
||||
// Turn literal <br> / <br> into real breaks (models often emit these)
|
||||
const BR = "%%CPBR%%";
|
||||
let raw = text
|
||||
.replace(/<br\s*\/?>/gi, BR)
|
||||
.replace(/<br\s*\/?>/gi, BR);
|
||||
let s = escapeHtml(raw);
|
||||
s = s.split(BR).join("<br>");
|
||||
s = s.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||
s = s.replace(/__(.+?)__/g, "<strong>$1</strong>");
|
||||
s = s.replace(/(?<!\w)\*(?!\*)([^*]+)\*(?!\w)/g, "<em>$1</em>");
|
||||
s = s.replace(/(?<!\w)_(?!_)([^_]+)_(?!\w)/g, "<em>$1</em>");
|
||||
// inline code already extracted; leftover backticks rare
|
||||
s = s.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g,
|
||||
'<a href="$2" title="$2">$1</a>',
|
||||
|
||||
@@ -24,11 +24,21 @@ export type FluentMessageId =
|
||||
| 'chat-stopped'
|
||||
| 'chat-summarize'
|
||||
| 'chat-thinking'
|
||||
| 'cite-copied'
|
||||
| 'cite-copy'
|
||||
| 'cite-empty'
|
||||
| 'cite-export'
|
||||
| 'cite-export-cancel'
|
||||
| 'cite-exported'
|
||||
| 'cite-loading'
|
||||
| 'cite-style'
|
||||
| 'item-section-chat-head'
|
||||
| 'item-section-chat-sidenav'
|
||||
| 'itemmenu-chat'
|
||||
| 'itemmenu-compare'
|
||||
| 'itemmenu-label'
|
||||
| 'multi-add-none'
|
||||
| 'multi-add-paper'
|
||||
| 'multi-close'
|
||||
| 'multi-common'
|
||||
| 'multi-compare'
|
||||
@@ -37,8 +47,11 @@ export type FluentMessageId =
|
||||
| 'multi-need-topic'
|
||||
| 'multi-need-two'
|
||||
| 'multi-no-items'
|
||||
| 'multi-paper-added'
|
||||
| 'multi-paper-removed'
|
||||
| 'multi-placeholder'
|
||||
| 'multi-ready'
|
||||
| 'multi-remove-paper'
|
||||
| 'multi-summary-each'
|
||||
| 'multi-title'
|
||||
| 'multi-too-many'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig } from "zotero-plugin-scaffold";
|
||||
import pkg from "./package.json";
|
||||
import { fixToolkitImportESModule } from "./scripts/esbuild-fix-toolkit-import.mjs";
|
||||
|
||||
export default defineConfig({
|
||||
source: ["src", "addon"],
|
||||
@@ -35,6 +36,7 @@ export default defineConfig({
|
||||
bundle: true,
|
||||
target: "firefox115",
|
||||
outfile: `.scaffold/build/addon/content/scripts/${pkg.config.addonRef}.js`,
|
||||
plugins: [fixToolkitImportESModule()],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||