实现单篇和多篇论文的对比总结
This commit is contained in:
+454
-34
@@ -6,12 +6,33 @@
|
|||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
-moz-user-select: text;
|
||||||
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatpapers-header {
|
.chatpapers-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #2563eb;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-brand-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatpapers-title {
|
.chatpapers-title {
|
||||||
@@ -20,54 +41,92 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chatpapers-meta {
|
.chatpapers-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatpapers-messages {
|
.chatpapers-meta-icon {
|
||||||
flex: 1;
|
flex-shrink: 0;
|
||||||
min-height: 180px;
|
opacity: 0.85;
|
||||||
max-height: 420px;
|
}
|
||||||
overflow: auto;
|
|
||||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
.chatpapers-empty {
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 28px 16px;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.72;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatpapers-bubble {
|
.chatpapers-empty-icon {
|
||||||
padding: 8px 10px;
|
color: #2563eb;
|
||||||
border-radius: 8px;
|
opacity: 0.85;
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatpapers-user {
|
|
||||||
background: color-mix(in srgb, #3b82f6 18%, transparent);
|
|
||||||
align-self: flex-end;
|
|
||||||
max-width: 92%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatpapers-assistant {
|
|
||||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
|
||||||
align-self: flex-start;
|
|
||||||
max-width: 96%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatpapers-role {
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.65;
|
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-empty-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-empty-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
max-width: 220px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-icon {
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-role-left {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-role-icon {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-copy {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||||
|
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-copy:hover {
|
||||||
|
background: color-mix(in srgb, currentColor 14%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatpapers-status {
|
.chatpapers-status {
|
||||||
min-height: 1.2em;
|
min-height: 1.2em;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-status-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-top: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatpapers-status[data-kind="warn"] {
|
.chatpapers-status[data-kind="warn"] {
|
||||||
@@ -78,6 +137,10 @@
|
|||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatpapers-status[data-kind="info"] .chatpapers-status-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
.chatpapers-toolbar,
|
.chatpapers-toolbar,
|
||||||
.chatpapers-actions {
|
.chatpapers-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -85,14 +148,28 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatpapers-composer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.chatpapers-btn {
|
.chatpapers-btn {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||||
background: color-mix(in srgb, currentColor 6%, transparent);
|
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 4px 10px;
|
padding: 5px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-btn-label {
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatpapers-btn:hover:not(:disabled) {
|
.chatpapers-btn:hover:not(:disabled) {
|
||||||
@@ -114,6 +191,261 @@
|
|||||||
background: #1d4ed8;
|
background: #1d4ed8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatpapers-messages {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 180px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||||
|
-moz-user-select: text !important;
|
||||||
|
user-select: text !important;
|
||||||
|
cursor: text;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: color-mix(in srgb, currentColor 35%, transparent) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-messages::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-messages::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in srgb, currentColor 32%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-messages::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-bubble {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
-moz-user-select: text !important;
|
||||||
|
user-select: text !important;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-bubble .chatpapers-table-wrap {
|
||||||
|
word-break: normal;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-content {
|
||||||
|
-moz-user-select: text !important;
|
||||||
|
user-select: text !important;
|
||||||
|
cursor: text;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-content.chatpapers-md {
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md > :first-child {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md > :last-child {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md h1,
|
||||||
|
.chatpapers-md h2,
|
||||||
|
.chatpapers-md h3,
|
||||||
|
.chatpapers-md h4 {
|
||||||
|
margin: 0.45em 0 0.15em;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md h1 {
|
||||||
|
font-size: 1.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md h2 {
|
||||||
|
font-size: 1.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md h3,
|
||||||
|
.chatpapers-md h4 {
|
||||||
|
font-size: 1.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md p {
|
||||||
|
margin: 0.2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md ul,
|
||||||
|
.chatpapers-md ol {
|
||||||
|
margin: 0.2em 0;
|
||||||
|
padding-left: 1.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md li {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md li + li {
|
||||||
|
margin-top: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
padding: 0.05em 0.3em;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md pre {
|
||||||
|
margin: 0.35em 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: auto;
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md pre code {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md blockquote {
|
||||||
|
margin: 0.3em 0;
|
||||||
|
padding: 0 0.65em;
|
||||||
|
border-left: 3px solid color-mix(in srgb, currentColor 35%, transparent);
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md blockquote p {
|
||||||
|
margin: 0.15em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||||
|
margin: 0.4em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md a {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-table-wrap {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.45em 0;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md table.chatpapers-table,
|
||||||
|
.chatpapers-md .chatpapers-table {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: auto;
|
||||||
|
font-size: 0.92em;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: normal;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md .chatpapers-table th,
|
||||||
|
.chatpapers-md .chatpapers-table td {
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||||
|
padding: 0.35em 0.55em;
|
||||||
|
vertical-align: top;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md .chatpapers-table th {
|
||||||
|
font-weight: 700;
|
||||||
|
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-md .chatpapers-table tbody tr:nth-child(even) td {
|
||||||
|
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-math-inline {
|
||||||
|
display: inline;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-math-block {
|
||||||
|
display: block;
|
||||||
|
margin: 0.35em 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-math math {
|
||||||
|
font-size: 1.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-math-block math {
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-tex {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
padding: 0.05em 0.3em;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-user {
|
||||||
|
background: color-mix(in srgb, #3b82f6 18%, transparent);
|
||||||
|
align-self: flex-end;
|
||||||
|
max-width: 92%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-assistant {
|
||||||
|
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||||
|
align-self: flex-start;
|
||||||
|
max-width: 96%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-role {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.65;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.chatpapers-input {
|
.chatpapers-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -125,6 +457,8 @@
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
-moz-user-select: text;
|
||||||
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatpapers-loading {
|
.chatpapers-loading {
|
||||||
@@ -138,3 +472,89 @@
|
|||||||
margin: 4px 0 10px;
|
margin: 4px 0 10px;
|
||||||
max-width: 52em;
|
max-width: 52em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatpapers-multi-root {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-multi-root .chatpapers-header,
|
||||||
|
.chatpapers-multi-root .chatpapers-paper-chips,
|
||||||
|
.chatpapers-multi-root .chatpapers-status,
|
||||||
|
.chatpapers-multi-root .chatpapers-toolbar,
|
||||||
|
.chatpapers-multi-root .chatpapers-composer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-multi-root .chatpapers-messages {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 140px;
|
||||||
|
max-height: none;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-multi-root .chatpapers-empty-desc {
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-multi-root .chatpapers-composer {
|
||||||
|
margin-top: 2px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-multi-root .chatpapers-toolbar {
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog chrome: hide empty footer button area; thin scrollbars */
|
||||||
|
#dialog-button-box,
|
||||||
|
.dialog-button-box {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-table-wrap,
|
||||||
|
.chatpapers-multi-root {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: color-mix(in srgb, currentColor 35%, transparent) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-table-wrap::-webkit-scrollbar,
|
||||||
|
.chatpapers-multi-root::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-table-wrap::-webkit-scrollbar-thumb,
|
||||||
|
.chatpapers-multi-root::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in srgb, currentColor 32%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-paper-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 72px;
|
||||||
|
overflow: auto;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-chip {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||||
|
background: color-mix(in srgb, #2563eb 10%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,4 +21,26 @@ chat-cleared = Chat cleared
|
|||||||
chat-no-reply = No AI reply to save yet
|
chat-no-reply = No AI reply to save yet
|
||||||
chat-note-saved = Saved as note
|
chat-note-saved = Saved as note
|
||||||
chat-selection-added = Selection added to next message
|
chat-selection-added = Selection added to next message
|
||||||
chat-selection-empty = No text selected in the reader
|
chat-selection-empty = No text selected. Select text in the PDF first, then click this button (do not click elsewhere in between).
|
||||||
|
chat-copy = Copy
|
||||||
|
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-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-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
|
||||||
|
multi-compare = Compare
|
||||||
|
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
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
itemmenu-label = ChatPapers: Summarize
|
itemmenu-label = ChatPapers: Summarize
|
||||||
|
itemmenu-chat = ChatPapers: Open chat
|
||||||
|
itemmenu-compare = ChatPapers: Compare papers
|
||||||
|
|||||||
@@ -21,4 +21,27 @@ chat-cleared = 已清空对话
|
|||||||
chat-no-reply = 还没有可保存的 AI 回复
|
chat-no-reply = 还没有可保存的 AI 回复
|
||||||
chat-note-saved = 已保存为笔记
|
chat-note-saved = 已保存为笔记
|
||||||
chat-selection-added = 已将选区加入下一条消息
|
chat-selection-added = 已将选区加入下一条消息
|
||||||
chat-selection-empty = 阅读器中没有选中文本
|
chat-selection-empty = 未检测到选区。请先在 PDF 中拖选文字,再直接点「添加选区」(中间不要点别处)。
|
||||||
|
chat-copy = 复制
|
||||||
|
chat-copied = 已复制到剪贴板
|
||||||
|
chat-empty-title = 开始对话
|
||||||
|
chat-empty-desc = 可以一键总结本文,或提问方法、结果与局限等问题。
|
||||||
|
|
||||||
|
itemmenu-chat = ChatPapers:打开对话
|
||||||
|
itemmenu-compare = ChatPapers:多篇对照
|
||||||
|
|
||||||
|
multi-title = 多篇对照
|
||||||
|
multi-empty-title = 多论文整体阅读
|
||||||
|
multi-empty-desc = 先在输入框写研究主题,点「主题综述」生成可写入要点;也可对比、各自摘要或自由提问。
|
||||||
|
multi-placeholder = 输入研究主题或问题,例如:扩散模型在医学影像分割中的应用
|
||||||
|
multi-topic-review = 主题综述
|
||||||
|
multi-need-topic = 请先在输入框填写研究主题,再点「主题综述」
|
||||||
|
multi-compare = 对比差异
|
||||||
|
multi-summary-each = 各自摘要
|
||||||
|
multi-common = 共同主题
|
||||||
|
multi-close = 关闭
|
||||||
|
multi-no-items = 请先选择文献
|
||||||
|
multi-need-two = 请至少选择 2 篇文献再对照
|
||||||
|
multi-too-many = 已选择 { $count } 篇,将使用前 8 篇
|
||||||
|
multi-ready = 已选择 { $count } 篇文献,可开始整体阅读与主题综述
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
itemmenu-label = ChatPapers:总结
|
itemmenu-label = ChatPapers:总结
|
||||||
|
itemmenu-chat = ChatPapers:打开对话
|
||||||
|
itemmenu-compare = ChatPapers:多篇对照
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ pref("openrouterTitle", "ChatPapers");
|
|||||||
pref("temperature", "0.3");
|
pref("temperature", "0.3");
|
||||||
pref("maxTokens", 2048);
|
pref("maxTokens", 2048);
|
||||||
pref("timeoutMs", 120000);
|
pref("timeoutMs", 120000);
|
||||||
pref("maxContextChars", 80000);
|
pref("maxContextChars", 100000);
|
||||||
pref("answerLanguage", "zh-CN");
|
pref("answerLanguage", "zh-CN");
|
||||||
pref("systemPrompt", "");
|
pref("systemPrompt", "");
|
||||||
pref("summaryPrompt", "");
|
pref("summaryPrompt", "");
|
||||||
|
|||||||
Generated
+41
@@ -9,9 +9,12 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"katex": "^0.18.1",
|
||||||
|
"lucide": "^1.25.0",
|
||||||
"zotero-plugin-toolkit": "^5.1.0-beta.13"
|
"zotero-plugin-toolkit": "^5.1.0-beta.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/katex": "^0.16.8",
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@zotero-plugin/eslint-config": "^0.6.7",
|
"@zotero-plugin/eslint-config": "^0.6.7",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
@@ -1360,6 +1363,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/katex": {
|
||||||
|
"version": "0.16.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
|
||||||
|
"integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.0",
|
"version": "24.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz",
|
||||||
@@ -2860,6 +2870,31 @@
|
|||||||
"setimmediate": "^1.0.5"
|
"setimmediate": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/katex": {
|
||||||
|
"version": "0.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.1.tgz",
|
||||||
|
"integrity": "sha512-Td8GCYSxDAoMhHOlKmCFMJ/hz5qlAAb71n66Dryw9nfCVfumLo7nhuotbvKom/XPADmrYC3O5QR71EPq4DarJQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://opencollective.com/katex",
|
||||||
|
"https://github.com/sponsors/katex"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^8.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"katex": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/katex/node_modules/commander": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -2917,6 +2952,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide": {
|
||||||
|
"version": "1.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide/-/lucide-1.25.0.tgz",
|
||||||
|
"integrity": "sha512-Pg/9Ga1xTbrnI6GY/7J9krIYmJIkAf3PkK6wPmsAeUtAQsBdGxs1+dfvLqjf9nPI5aFm9+VquT75JGpO3QXSpQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
|
|||||||
+6
-1
@@ -29,9 +29,12 @@
|
|||||||
"update-deps": "npm update --save"
|
"update-deps": "npm update --save"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"katex": "^0.18.1",
|
||||||
|
"lucide": "^1.25.0",
|
||||||
"zotero-plugin-toolkit": "^5.1.0-beta.13"
|
"zotero-plugin-toolkit": "^5.1.0-beta.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/katex": "^0.16.8",
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@zotero-plugin/eslint-config": "^0.6.7",
|
"@zotero-plugin/eslint-config": "^0.6.7",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
@@ -46,7 +49,9 @@
|
|||||||
"endOfLine": "lf",
|
"endOfLine": "lf",
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": ["*.xhtml"],
|
"files": [
|
||||||
|
"*.xhtml"
|
||||||
|
],
|
||||||
"options": {
|
"options": {
|
||||||
"htmlWhitespaceSensitivity": "css"
|
"htmlWhitespaceSensitivity": "css"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { initLocale } from "./utils/locale";
|
|||||||
import { createZToolkit } from "./utils/ztoolkit";
|
import { createZToolkit } from "./utils/ztoolkit";
|
||||||
import { registerChatPane, registerPrefs } from "./modules/ui/readerPane";
|
import { registerChatPane, registerPrefs } from "./modules/ui/readerPane";
|
||||||
import { registerPrefsScripts } from "./modules/ui/prefs";
|
import { registerPrefsScripts } from "./modules/ui/prefs";
|
||||||
|
import { registerItemMenus } from "./modules/ui/itemMenu";
|
||||||
|
import { registerReaderSelectionHook } from "./modules/pdf/selection";
|
||||||
|
|
||||||
async function onStartup() {
|
async function onStartup() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -13,6 +15,7 @@ async function onStartup() {
|
|||||||
initLocale();
|
initLocale();
|
||||||
registerPrefs();
|
registerPrefs();
|
||||||
registerChatPane();
|
registerChatPane();
|
||||||
|
registerReaderSelectionHook();
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Zotero.getMainWindows().map((win) => onMainWindowLoad(win)),
|
Zotero.getMainWindows().map((win) => onMainWindowLoad(win)),
|
||||||
@@ -26,6 +29,7 @@ async function onMainWindowLoad(win: _ZoteroTypes.MainWindow): Promise<void> {
|
|||||||
win.MozXULElement.insertFTLIfNeeded(
|
win.MozXULElement.insertFTLIfNeeded(
|
||||||
`${addon.data.config.addonRef}-mainWindow.ftl`,
|
`${addon.data.config.addonRef}-mainWindow.ftl`,
|
||||||
);
|
);
|
||||||
|
registerItemMenus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onMainWindowUnload(_win: Window): Promise<void> {
|
async function onMainWindowUnload(_win: Window): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { ChatMessage } from "../llm/types";
|
||||||
|
|
||||||
|
/** Detect messages that already embed full paper dumps (should not be in UI history). */
|
||||||
|
export function isPaperDump(content: string): boolean {
|
||||||
|
return (
|
||||||
|
content.includes("【论文正文】") ||
|
||||||
|
content.includes("【论文 1】") ||
|
||||||
|
content.startsWith("【论文元数据】") ||
|
||||||
|
content.startsWith("已收到论文内容") ||
|
||||||
|
content.includes("【说明】以上为本次会话的论文材料")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep short Q&A turns for the model. Truncate very long assistant replies
|
||||||
|
* so paper context + latest question are less likely to be dropped.
|
||||||
|
*/
|
||||||
|
export function sanitizeChatHistory(
|
||||||
|
history: ChatMessage[],
|
||||||
|
options?: { maxMessages?: number; maxAssistantChars?: number },
|
||||||
|
): ChatMessage[] {
|
||||||
|
const maxMessages = options?.maxMessages ?? 10;
|
||||||
|
const maxAssistantChars = options?.maxAssistantChars ?? 5000;
|
||||||
|
|
||||||
|
const cleaned = history
|
||||||
|
.filter((m) => m.role === "user" || m.role === "assistant")
|
||||||
|
.filter((m) => !isPaperDump(m.content))
|
||||||
|
.map((m) => {
|
||||||
|
if (
|
||||||
|
m.role === "assistant" &&
|
||||||
|
m.content &&
|
||||||
|
m.content.length > maxAssistantChars
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
content:
|
||||||
|
m.content.slice(0, maxAssistantChars) +
|
||||||
|
"\n\n…(为保留论文上下文,历史回复已截断)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
return cleaned.slice(-maxMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin paper corpus near the start of the request, then prior Q&A, then the
|
||||||
|
* current task. Avoids stuffing full papers only into the latest message
|
||||||
|
* (which providers often truncate under context pressure on turn 2+).
|
||||||
|
*/
|
||||||
|
export function assemblePaperChatMessages(options: {
|
||||||
|
system: string;
|
||||||
|
paperBlock: string;
|
||||||
|
taskBlock: string;
|
||||||
|
history: ChatMessage[];
|
||||||
|
}): ChatMessage[] {
|
||||||
|
const messages: ChatMessage[] = [
|
||||||
|
{ role: "system", content: options.system },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `${options.paperBlock}
|
||||||
|
|
||||||
|
【说明】以上为本次会话的论文材料。后续所有问题与分析任务均基于这些论文;无需用户再次提供全文或文本。`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content:
|
||||||
|
"已收到并理解以上论文材料。请继续提问或下达分析任务,我将基于这些论文作答,不会要求你再次提供全文。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
messages.push(...sanitizeChatHistory(options.history));
|
||||||
|
messages.push({ role: "user", content: options.taskBlock });
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
@@ -1,20 +1,23 @@
|
|||||||
import { getPref } from "../../utils/prefs";
|
import { getPref } from "../../utils/prefs";
|
||||||
|
|
||||||
const DEFAULT_SYSTEM = `你是学术论文阅读助手 ChatPapers。请基于用户提供的论文文本回答问题。
|
const DEFAULT_SYSTEM = `你是学术论文阅读助手 ChatPapers。论文材料会在会话开始时提供,并在整个对话中持续有效。
|
||||||
要求:
|
要求:
|
||||||
1. 回答准确、简洁,优先使用论文原文依据。
|
1. 始终把已提供的【论文正文】/【论文 N】视为可用材料,直接据此回答或总结。
|
||||||
2. 若信息不足,请明确说明,不要编造。
|
2. 多轮对话时结合此前问答继续深入,不要假装没有论文或要求用户重新粘贴文本。
|
||||||
3. 尽量标注页码或引用短片段(若上下文含页分隔)。
|
3. 回答准确、简洁,优先引用正文依据;不要编造未出现的实验数据。
|
||||||
4. 使用用户偏好的语言回答。`;
|
4. 禁止要求用户「重新提供论文」或声称「只有参考文献/片段」。
|
||||||
|
5. 使用用户偏好的语言回答。`;
|
||||||
|
|
||||||
const DEFAULT_SUMMARY = `请对以下论文做结构化总结,使用 Markdown,包含:
|
const DEFAULT_SUMMARY = `请对【论文正文】做结构化总结,使用 Markdown,包含:
|
||||||
## 一句话概括
|
## 一句话概括
|
||||||
## 研究问题 / 动机
|
## 研究问题 / 动机
|
||||||
## 方法
|
## 方法
|
||||||
## 主要结果
|
## 主要结果
|
||||||
## 贡献与创新
|
## 贡献与创新
|
||||||
## 局限与可追问点
|
## 局限与可追问点
|
||||||
## 关键术语(可选)`;
|
## 关键术语(可选)
|
||||||
|
|
||||||
|
注意:直接输出总结内容,不要先解释「文本是否完整」。`;
|
||||||
|
|
||||||
export function getSystemPrompt(): string {
|
export function getSystemPrompt(): string {
|
||||||
const custom = (getPref("systemPrompt") || "").trim();
|
const custom = (getPref("systemPrompt") || "").trim();
|
||||||
|
|||||||
+65
-31
@@ -1,5 +1,6 @@
|
|||||||
import type { ChatMessage } from "../llm/types";
|
import type { ChatMessage } from "../llm/types";
|
||||||
import { getSummaryPrompt, getSystemPrompt } from "../llm/prompts";
|
import { getSummaryPrompt, getSystemPrompt } from "../llm/prompts";
|
||||||
|
import { assemblePaperChatMessages } from "../llm/history";
|
||||||
import {
|
import {
|
||||||
buildMetadataBlock,
|
buildMetadataBlock,
|
||||||
extractPdfContext,
|
extractPdfContext,
|
||||||
@@ -10,6 +11,7 @@ export interface BuiltContext {
|
|||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
extract: ExtractResult;
|
extract: ExtractResult;
|
||||||
warning?: string;
|
warning?: string;
|
||||||
|
ready: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildChatMessages(options: {
|
export async function buildChatMessages(options: {
|
||||||
@@ -23,55 +25,87 @@ export async function buildChatMessages(options: {
|
|||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
if (!extract.text) {
|
if (!extract.text) {
|
||||||
|
return {
|
||||||
|
messages: [],
|
||||||
|
extract,
|
||||||
|
warning:
|
||||||
|
extract.error ||
|
||||||
|
"未能提取 PDF 文本(可能是扫描版或尚无文本层)。请检查附件后重试。",
|
||||||
|
ready: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawInfo =
|
||||||
|
extract.rawLength && extract.rawLength !== extract.text.length
|
||||||
|
? `(原文 ${extract.rawLength} → 提交 ${extract.text.length})`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (extract.truncated) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
"未能提取 PDF 文本(可能是扫描版或尚无文本层)。请检查附件后重试。",
|
`已智能截断并优先保留正文${rawInfo},来源 ${extract.source}${
|
||||||
|
extract.pageCount ? ` · ${extract.pageCount} 页` : ""
|
||||||
|
}。`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
warnings.push(
|
||||||
|
`已载入论文文本 ${extract.text.length} 字符(${extract.source}${
|
||||||
|
extract.pageCount ? ` · ${extract.pageCount} 页` : ""
|
||||||
|
})。`,
|
||||||
);
|
);
|
||||||
} else if (extract.truncated) {
|
|
||||||
warnings.push("论文较长,已按设置截断上下文。");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = extract.parentItem
|
const meta = extract.parentItem
|
||||||
? buildMetadataBlock(extract.parentItem)
|
? buildMetadataBlock(extract.parentItem)
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const systemParts = [getSystemPrompt()];
|
const paperBlock = [
|
||||||
if (meta) {
|
meta ? `【论文元数据】\n${meta}` : "",
|
||||||
systemParts.push(`论文元数据:\n${meta}`);
|
`【论文正文】\n${extract.text}`,
|
||||||
}
|
]
|
||||||
if (extract.text) {
|
.filter(Boolean)
|
||||||
systemParts.push(
|
.join("\n\n");
|
||||||
`论文正文(页之间可能以换页符分隔):\n${extract.text}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages: ChatMessage[] = [
|
|
||||||
{ role: "system", content: systemParts.join("\n\n") },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Keep recent history (exclude old system)
|
|
||||||
const history = options.history
|
|
||||||
.filter((m) => m.role !== "system")
|
|
||||||
.slice(-20);
|
|
||||||
messages.push(...history);
|
|
||||||
|
|
||||||
|
let taskBlock: string;
|
||||||
if (options.mode === "summary") {
|
if (options.mode === "summary") {
|
||||||
const summaryAsk = getSummaryPrompt();
|
taskBlock = `【任务】
|
||||||
messages.push({
|
请直接基于本会话开头已提供的【论文正文】完成结构化总结。
|
||||||
role: "user",
|
严禁回复「请提供论文」「文本不完整无法总结」等推脱话术;信息不足时也请基于已有正文尽力总结,并简要注明局限。
|
||||||
content: summaryAsk,
|
|
||||||
});
|
${getSummaryPrompt()}`;
|
||||||
} else {
|
} else {
|
||||||
let content = options.userText.trim();
|
let question = options.userText.trim();
|
||||||
if (options.selection?.trim()) {
|
if (options.selection?.trim()) {
|
||||||
content =
|
question = `【选中原文】\n${options.selection.trim()}\n\n【问题】\n${question}`;
|
||||||
`【选中原文】\n${options.selection.trim()}\n\n【问题】\n${content}`;
|
} else {
|
||||||
|
question = `【问题】\n${question}`;
|
||||||
}
|
}
|
||||||
messages.push({ role: "user", content });
|
taskBlock = `【任务】
|
||||||
|
请直接基于本会话开头已提供的论文材料回答。
|
||||||
|
严禁要求用户再次提供论文全文。
|
||||||
|
|
||||||
|
${question}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const system = `${getSystemPrompt()}
|
||||||
|
|
||||||
|
硬性规则(必须遵守):
|
||||||
|
1. 论文材料已在本会话首条用户消息中提供(【论文正文】);后续每一轮提问仍以该材料为准。
|
||||||
|
2. 绝对禁止说「请提供论文文本」「只有引用/片段无法总结」之类的话。
|
||||||
|
3. 若正文有截断标记,仍须基于已给出的章节作答,并可选一句说明「部分中间内容经抽样」。
|
||||||
|
4. 结合此前对话继续深入回答,不要假装这是全新的、没有论文的会话。
|
||||||
|
5. 不要把参考文献列表误判为「没有正文」。`;
|
||||||
|
|
||||||
|
const messages = assemblePaperChatMessages({
|
||||||
|
system,
|
||||||
|
paperBlock,
|
||||||
|
taskBlock,
|
||||||
|
history: options.history,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
extract,
|
extract,
|
||||||
warning: warnings.length ? warnings.join(" ") : undefined,
|
warning: warnings.length ? warnings.join(" ") : undefined,
|
||||||
|
ready: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+230
-36
@@ -4,29 +4,55 @@ export interface ExtractResult {
|
|||||||
text: string;
|
text: string;
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
pageCount?: number;
|
pageCount?: number;
|
||||||
source: "pdfworker" | "fulltext" | "empty";
|
source: "pdfworker" | "attachmentText" | "reader" | "empty";
|
||||||
attachment?: Zotero.Item;
|
attachment?: Zotero.Item;
|
||||||
parentItem?: Zotero.Item;
|
parentItem?: Zotero.Item;
|
||||||
|
error?: string;
|
||||||
|
/** Original extracted length before trim/truncate */
|
||||||
|
rawLength?: number;
|
||||||
|
/** Length after dropping references */
|
||||||
|
bodyLength?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findPdfAttachment(item: Zotero.Item): Zotero.Item | undefined {
|
function resolvePDFWorker(): any {
|
||||||
if (item.isAttachment() && item.attachmentContentType === "application/pdf") {
|
const candidates = [
|
||||||
return item;
|
(Zotero as any).PDFWorker,
|
||||||
|
(globalThis as any).Zotero?.PDFWorker,
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
candidates.push(ztoolkit.getGlobal("Zotero" as any)?.PDFWorker);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
if (item.isRegularItem()) {
|
try {
|
||||||
const attachments = item.getAttachments().map((id) => Zotero.Items.get(id));
|
const win = Zotero.getMainWindow?.() as any;
|
||||||
return attachments.find(
|
if (win?.Zotero?.PDFWorker) candidates.push(win.Zotero.PDFWorker);
|
||||||
(att) => att?.isAttachment() && att.attachmentContentType === "application/pdf",
|
} catch {
|
||||||
);
|
// ignore
|
||||||
}
|
}
|
||||||
// Child attachment of a parent
|
for (const worker of candidates) {
|
||||||
if (item.isAttachment() && item.parentItemID) {
|
if (worker?.getFullText) return worker;
|
||||||
const parent = Zotero.Items.get(item.parentItemID);
|
|
||||||
return findPdfAttachment(parent);
|
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPdfAttachment(item: any): boolean {
|
||||||
|
if (!item || typeof item.isAttachment !== "function") return false;
|
||||||
|
if (!item.isAttachment()) return false;
|
||||||
|
try {
|
||||||
|
if (typeof item.isPDFAttachment === "function") {
|
||||||
|
return !!item.isPDFAttachment();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
const type = String(item.attachmentContentType || "").toLowerCase();
|
||||||
|
const name = String(
|
||||||
|
item.attachmentFilename || item.getField?.("title") || "",
|
||||||
|
).toLowerCase();
|
||||||
|
return type === "application/pdf" || name.endsWith(".pdf");
|
||||||
|
}
|
||||||
|
|
||||||
function getParentItem(item: Zotero.Item): Zotero.Item {
|
function getParentItem(item: Zotero.Item): Zotero.Item {
|
||||||
if (item.isRegularItem()) return item;
|
if (item.isRegularItem()) return item;
|
||||||
if (item.parentItemID) {
|
if (item.parentItemID) {
|
||||||
@@ -35,25 +61,127 @@ function getParentItem(item: Zotero.Item): Zotero.Item {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve the best PDF attachment for the current pane item / open reader. */
|
||||||
|
export function findPdfAttachment(item: Zotero.Item): Zotero.Item | undefined {
|
||||||
|
if (isPdfAttachment(item)) return item;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const readers =
|
||||||
|
(Zotero.Reader as any).getAll?.() ||
|
||||||
|
(Zotero.Reader as any)._readers ||
|
||||||
|
[];
|
||||||
|
for (const reader of readers) {
|
||||||
|
const attID = reader?.itemID || reader?._itemID;
|
||||||
|
if (!attID) continue;
|
||||||
|
const att = Zotero.Items.get(attID) as Zotero.Item | false;
|
||||||
|
if (!att || !isPdfAttachment(att)) continue;
|
||||||
|
if (
|
||||||
|
att.id === item.id ||
|
||||||
|
att.parentItemID === item.id ||
|
||||||
|
(item.parentItemID && att.parentItemID === item.parentItemID)
|
||||||
|
) {
|
||||||
|
return att;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("findPdfAttachment reader scan failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.isRegularItem()) {
|
||||||
|
const ids = item.getAttachments();
|
||||||
|
for (const id of ids) {
|
||||||
|
const att = Zotero.Items.get(id) as Zotero.Item | false;
|
||||||
|
if (att && isPdfAttachment(att)) return att;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.isAttachment() && item.parentItemID) {
|
||||||
|
const parent = Zotero.Items.get(item.parentItemID) as Zotero.Item | false;
|
||||||
|
if (parent && parent.isRegularItem()) {
|
||||||
|
return findPdfAttachment(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExtractedText(raw: unknown): string {
|
||||||
|
if (raw == null) return "";
|
||||||
|
if (typeof raw === "string") return raw.trim();
|
||||||
|
if (Array.isArray(raw)) return raw.join("").trim();
|
||||||
|
if (typeof raw === "object" && raw !== null && "text" in (raw as object)) {
|
||||||
|
return normalizeExtractedText((raw as { text: unknown }).text);
|
||||||
|
}
|
||||||
|
return String(raw).trim();
|
||||||
|
}
|
||||||
|
|
||||||
async function extractViaPdfWorker(attachment: Zotero.Item): Promise<string> {
|
async function extractViaPdfWorker(attachment: Zotero.Item): Promise<string> {
|
||||||
const worker = (Zotero as any).PDFWorker;
|
const worker = resolvePDFWorker();
|
||||||
if (!worker?.getFullText) {
|
if (!worker?.getFullText) {
|
||||||
throw new Error("PDFWorker unavailable");
|
throw new Error("PDFWorker unavailable");
|
||||||
}
|
}
|
||||||
const result = await worker.getFullText(attachment.id, null, true);
|
|
||||||
return (result?.text || "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function extractViaFulltext(attachment: Zotero.Item): Promise<string> {
|
const attempts: Array<() => Promise<unknown>> = [
|
||||||
try {
|
() => worker.getFullText(attachment.id),
|
||||||
const text = (attachment as any).attachmentText;
|
() => worker.getFullText(attachment.id, null, true),
|
||||||
if (typeof text === "string" && text.trim()) return text.trim();
|
() => worker.getFullText(attachment.id, true),
|
||||||
} catch {
|
];
|
||||||
// ignore
|
|
||||||
|
let lastError: unknown;
|
||||||
|
for (const run of attempts) {
|
||||||
|
try {
|
||||||
|
const result = await run();
|
||||||
|
const text = normalizeExtractedText(result);
|
||||||
|
if (text) return text;
|
||||||
|
if (result != null) return "";
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (lastError) throw lastError;
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function extractViaAttachmentText(attachment: Zotero.Item): Promise<string> {
|
||||||
|
const maybe = (attachment as any).attachmentText;
|
||||||
|
const value = typeof maybe?.then === "function" ? await maybe : maybe;
|
||||||
|
return normalizeExtractedText(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop bibliography / references tails so truncation does not keep
|
||||||
|
* "title+abstract + references" while dropping methods/results.
|
||||||
|
*/
|
||||||
|
export function stripReferencesSection(text: string): {
|
||||||
|
body: string;
|
||||||
|
removedRefs: boolean;
|
||||||
|
} {
|
||||||
|
const patterns = [
|
||||||
|
/\n[ \t]*(References|REFERENCES|Bibliography|BIBLIOGRAPHY|Works Cited|WORKS CITED)\s*\n/,
|
||||||
|
/\n[ \t]*(参考文献|引用文献|参考书目)\s*\n/,
|
||||||
|
];
|
||||||
|
|
||||||
|
let cut = -1;
|
||||||
|
for (const re of patterns) {
|
||||||
|
const m = re.exec(text);
|
||||||
|
if (!m || m.index == null) continue;
|
||||||
|
// Only treat as refs section if it appears in the latter half
|
||||||
|
if (m.index > text.length * 0.45) {
|
||||||
|
cut = cut < 0 ? m.index : Math.min(cut, m.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cut < 0) return { body: text, removedRefs: false };
|
||||||
|
return {
|
||||||
|
body: text.slice(0, cut).trimEnd(),
|
||||||
|
removedRefs: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer intro + evenly sampled body + conclusion.
|
||||||
|
* Avoid old head+tail strategy (tail is often References).
|
||||||
|
*/
|
||||||
export function truncateText(
|
export function truncateText(
|
||||||
text: string,
|
text: string,
|
||||||
maxChars: number,
|
maxChars: number,
|
||||||
@@ -61,13 +189,54 @@ export function truncateText(
|
|||||||
if (text.length <= maxChars) {
|
if (text.length <= maxChars) {
|
||||||
return { text, truncated: false };
|
return { text, truncated: false };
|
||||||
}
|
}
|
||||||
const head = Math.floor(maxChars * 0.7);
|
|
||||||
const tail = maxChars - head - 80;
|
const { body, removedRefs } = stripReferencesSection(text);
|
||||||
const sliced =
|
const source = body.length > 1000 ? body : text;
|
||||||
text.slice(0, head) +
|
|
||||||
"\n\n[... 中间内容已截断 ...]\n\n" +
|
if (source.length <= maxChars) {
|
||||||
text.slice(-Math.max(tail, 0));
|
const note = removedRefs
|
||||||
return { text: sliced, truncated: true };
|
? "\n\n[注:已省略文末 References/参考文献 以保留正文。]"
|
||||||
|
: "";
|
||||||
|
return { text: source + note, truncated: removedRefs };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budget: 40% head (title/abstract/intro), 45% middle samples, 15% tail (conclusion)
|
||||||
|
const headBudget = Math.floor(maxChars * 0.4);
|
||||||
|
const midBudget = Math.floor(maxChars * 0.45);
|
||||||
|
const tailBudget = Math.max(maxChars - headBudget - midBudget - 120, 800);
|
||||||
|
|
||||||
|
const head = source.slice(0, headBudget);
|
||||||
|
const tail = source.slice(-tailBudget);
|
||||||
|
const midRegionStart = headBudget;
|
||||||
|
const midRegionEnd = Math.max(midRegionStart, source.length - tailBudget);
|
||||||
|
const midRegion = source.slice(midRegionStart, midRegionEnd);
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
const chunkCount = 4;
|
||||||
|
const chunkSize = Math.floor(midBudget / chunkCount);
|
||||||
|
if (midRegion.length > 0 && chunkSize > 0) {
|
||||||
|
for (let i = 0; i < chunkCount; i++) {
|
||||||
|
const start = Math.floor((midRegion.length * i) / chunkCount);
|
||||||
|
chunks.push(midRegion.slice(start, start + chunkSize));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sliced = [
|
||||||
|
head,
|
||||||
|
"",
|
||||||
|
"[... 中间部分抽样保留(方法/实验/结果)...]",
|
||||||
|
"",
|
||||||
|
chunks.filter(Boolean).join("\n\n[...]\n\n"),
|
||||||
|
"",
|
||||||
|
"[... 后文(结论附近)...]",
|
||||||
|
"",
|
||||||
|
tail,
|
||||||
|
removedRefs ? "[注:已省略文末 References/参考文献。]" : "",
|
||||||
|
]
|
||||||
|
.filter((s, idx, arr) => !(s === "" && arr[idx - 1] === ""))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return { text: sliced.slice(0, maxChars), truncated: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractPdfContext(item: Zotero.Item): Promise<ExtractResult> {
|
export async function extractPdfContext(item: Zotero.Item): Promise<ExtractResult> {
|
||||||
@@ -79,30 +248,52 @@ export async function extractPdfContext(item: Zotero.Item): Promise<ExtractResul
|
|||||||
truncated: false,
|
truncated: false,
|
||||||
source: "empty",
|
source: "empty",
|
||||||
parentItem,
|
parentItem,
|
||||||
|
error: "当前条目下未找到 PDF 附件。",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
let raw = "";
|
let raw = "";
|
||||||
let source: ExtractResult["source"] = "empty";
|
let source: ExtractResult["source"] = "empty";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
raw = await extractViaPdfWorker(attachment);
|
raw = await extractViaPdfWorker(attachment);
|
||||||
if (raw) source = "pdfworker";
|
if (raw) source = "pdfworker";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
errors.push(`PDFWorker: ${String(e)}`);
|
||||||
ztoolkit.log("PDFWorker extract failed", e);
|
ztoolkit.log("PDFWorker extract failed", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
try {
|
try {
|
||||||
raw = await extractViaFulltext(attachment);
|
raw = await extractViaAttachmentText(attachment);
|
||||||
if (raw) source = "fulltext";
|
if (raw) source = "attachmentText";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ztoolkit.log("Fulltext extract failed", e);
|
errors.push(`attachmentText: ${String(e)}`);
|
||||||
|
ztoolkit.log("attachmentText extract failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxChars = Number(getPref("maxContextChars") ?? 80000);
|
if (!raw) {
|
||||||
|
return {
|
||||||
|
text: "",
|
||||||
|
truncated: false,
|
||||||
|
source: "empty",
|
||||||
|
attachment,
|
||||||
|
parentItem,
|
||||||
|
error:
|
||||||
|
errors.join(" | ") ||
|
||||||
|
"未能提取 PDF 文本(可能是扫描版/无文本层,或附件尚未索引)。",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawLength = raw.length;
|
||||||
|
const { body } = stripReferencesSection(raw);
|
||||||
|
const bodyLength = body.length;
|
||||||
|
|
||||||
|
const maxChars = Number(getPref("maxContextChars") ?? 100000);
|
||||||
const { text, truncated } = truncateText(raw, maxChars);
|
const { text, truncated } = truncateText(raw, maxChars);
|
||||||
const pageCount = raw ? raw.split("\f").length : undefined;
|
const pageCount = raw.includes("\f") ? raw.split("\f").length : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text,
|
text,
|
||||||
@@ -111,13 +302,16 @@ export async function extractPdfContext(item: Zotero.Item): Promise<ExtractResul
|
|||||||
source,
|
source,
|
||||||
attachment,
|
attachment,
|
||||||
parentItem,
|
parentItem,
|
||||||
|
rawLength,
|
||||||
|
bodyLength,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMetadataBlock(item: Zotero.Item): string {
|
export function buildMetadataBlock(item: Zotero.Item): string {
|
||||||
if (!getPref("sendMetadata")) return "";
|
if (!getPref("sendMetadata")) return "";
|
||||||
const title = item.getField("title") || "";
|
const title = item.getField("title") || "";
|
||||||
const creators = item.getCreators()
|
const creators = item
|
||||||
|
.getCreators()
|
||||||
.map((c) => [c.firstName, c.lastName].filter(Boolean).join(" "))
|
.map((c) => [c.firstName, c.lastName].filter(Boolean).join(" "))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { getPref } from "../../utils/prefs";
|
||||||
|
import type { ChatMessage } from "../llm/types";
|
||||||
|
import { getSystemPrompt } from "../llm/prompts";
|
||||||
|
import { assemblePaperChatMessages } from "../llm/history";
|
||||||
|
import {
|
||||||
|
buildMetadataBlock,
|
||||||
|
extractPdfContext,
|
||||||
|
truncateText,
|
||||||
|
type ExtractResult,
|
||||||
|
} from "./extractor";
|
||||||
|
|
||||||
|
export interface MultiPaperExtract {
|
||||||
|
item: Zotero.Item;
|
||||||
|
title: string;
|
||||||
|
extract: ExtractResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultiBuiltContext {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
papers: MultiPaperExtract[];
|
||||||
|
warning?: string;
|
||||||
|
ready: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MultiChatMode =
|
||||||
|
| "chat"
|
||||||
|
| "compare"
|
||||||
|
| "summary-each"
|
||||||
|
| "common-themes"
|
||||||
|
| "topic-review";
|
||||||
|
|
||||||
|
const MODE_PROMPTS: Record<
|
||||||
|
Exclude<MultiChatMode, "chat" | "topic-review">,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
compare: `请对比以上多篇论文,使用 Markdown 输出:
|
||||||
|
## 研究问题对比
|
||||||
|
## 方法差异
|
||||||
|
## 主要结果差异
|
||||||
|
## 贡献与侧重点
|
||||||
|
## 适用场景建议
|
||||||
|
## 一句话总结差异
|
||||||
|
|
||||||
|
要求:按论文标题指称各文;突出差异与互补,避免空泛套话。对比表请用标准 Markdown 表格(含表头分隔行)。`,
|
||||||
|
"summary-each": `请对以上每篇论文各写一段简要结构化摘要(动机/方法/结果/局限),最后用一小节总结它们的关系。使用 Markdown,按【论文标题】分节。`,
|
||||||
|
"common-themes": `请提炼以上论文的共同主题、共享方法、一致结论与主要分歧。使用 Markdown:
|
||||||
|
## 共同主题
|
||||||
|
## 方法共性
|
||||||
|
## 一致结论
|
||||||
|
## 主要分歧
|
||||||
|
## 综合启示`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildTopicReviewPrompt(topic: string): string {
|
||||||
|
return `用户写作/研究主题:
|
||||||
|
「${topic.trim()}」
|
||||||
|
|
||||||
|
请整体阅读本会话开头已提供的论文,完成「面向该主题写作」的文献综述与材料整理。使用 Markdown,结构如下:
|
||||||
|
|
||||||
|
## 主题界定
|
||||||
|
用 2–4 句说明该主题在所选文献中的边界与核心问题。
|
||||||
|
|
||||||
|
## 文献地图
|
||||||
|
用 Markdown 表格列出:论文 | 与主题的关系(核心/相关/边缘) | 关键贡献 | 可用证据或论点(各 1 句)。
|
||||||
|
|
||||||
|
## 共识与分歧
|
||||||
|
归纳各文在主题上的一致结论、争议点与未解决问题。
|
||||||
|
|
||||||
|
## 可写入综述的要点
|
||||||
|
按「可直接用于写作」的粒度,列出 5–10 条要点;每条注明依据哪几篇(用论文标题)。
|
||||||
|
|
||||||
|
## 推荐论述结构
|
||||||
|
给出一篇短综述/相关工作小节的建议大纲(三级标题即可)。
|
||||||
|
|
||||||
|
## 写作注意
|
||||||
|
指出证据不足、不可过度外推、以及引用时需核对原文的地方。
|
||||||
|
|
||||||
|
硬性要求:
|
||||||
|
1. 必须紧扣用户主题,弱相关内容一笔带过或标明边缘。
|
||||||
|
2. 用论文标题指称文献;信息不足也要基于已有正文尽力完成,禁止索要全文。
|
||||||
|
3. 表格必须是标准 Markdown 表格(含 | --- | 分隔行)。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deduplicate to regular items (or standalone PDFs) suitable for chat. */
|
||||||
|
export function normalizeLibraryItems(items: Zotero.Item[]): Zotero.Item[] {
|
||||||
|
const map = new Map<number, Zotero.Item>();
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item || item.isNote()) continue;
|
||||||
|
let target = item;
|
||||||
|
if (item.isAttachment() && item.parentItemID) {
|
||||||
|
const parent = Zotero.Items.get(item.parentItemID);
|
||||||
|
if (parent) target = parent;
|
||||||
|
}
|
||||||
|
if (target.isRegularItem() || target.isAttachment()) {
|
||||||
|
map.set(target.id, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildMultiChatMessages(options: {
|
||||||
|
items: Zotero.Item[];
|
||||||
|
history: ChatMessage[];
|
||||||
|
userText: string;
|
||||||
|
mode?: MultiChatMode;
|
||||||
|
}): Promise<MultiBuiltContext> {
|
||||||
|
const items = normalizeLibraryItems(options.items);
|
||||||
|
if (!items.length) {
|
||||||
|
return {
|
||||||
|
messages: [],
|
||||||
|
papers: [],
|
||||||
|
warning: "未选中可用文献。",
|
||||||
|
ready: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxTotal = Number(getPref("maxContextChars") ?? 100000);
|
||||||
|
const maxPapers = Math.min(items.length, 8);
|
||||||
|
const selected = items.slice(0, maxPapers);
|
||||||
|
// Leave headroom for system + history + task on follow-up turns
|
||||||
|
const historyLen = options.history.reduce(
|
||||||
|
(n, m) => n + (m.content?.length || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const reserve = Math.min(24000, Math.max(4000, Math.floor(historyLen * 0.5) + 4000));
|
||||||
|
const paperBudget = Math.max(12000, maxTotal - reserve);
|
||||||
|
const perBudget = Math.max(6000, Math.floor(paperBudget / selected.length));
|
||||||
|
|
||||||
|
const papers: MultiPaperExtract[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
if (items.length > maxPapers) {
|
||||||
|
warnings.push(`已限制为前 ${maxPapers} 篇(共选 ${items.length} 篇)。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of selected) {
|
||||||
|
const extract = await extractPdfContext(item);
|
||||||
|
const title =
|
||||||
|
(extract.parentItem || item).getField("title") ||
|
||||||
|
item.attachmentFilename ||
|
||||||
|
`Item ${item.id}`;
|
||||||
|
|
||||||
|
if (!extract.text) {
|
||||||
|
warnings.push(`《${title}》未能提取 PDF 文本,已跳过。`);
|
||||||
|
papers.push({ item, title, extract });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sliced = truncateText(extract.text, perBudget);
|
||||||
|
papers.push({
|
||||||
|
item,
|
||||||
|
title,
|
||||||
|
extract: {
|
||||||
|
...extract,
|
||||||
|
text: sliced.text,
|
||||||
|
truncated: extract.truncated || sliced.truncated,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const usable = papers.filter((p) => p.extract.text);
|
||||||
|
if (!usable.length) {
|
||||||
|
return {
|
||||||
|
messages: [],
|
||||||
|
papers,
|
||||||
|
warning: warnings.join(" ") || "所选文献均无法提取 PDF 文本。",
|
||||||
|
ready: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
warnings.push(
|
||||||
|
`已载入 ${usable.length} 篇论文(每篇上限约 ${perBudget} 字符)。`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const paperBlocks = usable.map((p, idx) => {
|
||||||
|
const meta = p.extract.parentItem
|
||||||
|
? buildMetadataBlock(p.extract.parentItem)
|
||||||
|
: "";
|
||||||
|
return [
|
||||||
|
`【论文 ${idx + 1}】${p.title}`,
|
||||||
|
meta ? `元数据:\n${meta}` : "",
|
||||||
|
`正文:\n${p.extract.text}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
const paperBlock = paperBlocks.join("\n\n----------\n\n");
|
||||||
|
|
||||||
|
const mode = options.mode || "chat";
|
||||||
|
let taskBlock: string;
|
||||||
|
if (mode === "chat") {
|
||||||
|
taskBlock = `【任务】
|
||||||
|
请基于本会话开头已提供的多篇论文回答用户问题,并结合此前对话继续深入。
|
||||||
|
若问题涉及某个写作主题/综述/相关工作,请按「整体阅读 → 提炼可写入要点」来组织答案,并标明各点依据哪几篇。
|
||||||
|
对比或并列信息时优先使用标准 Markdown 表格。
|
||||||
|
严禁要求用户再次提供全文。
|
||||||
|
|
||||||
|
【问题】
|
||||||
|
${options.userText.trim()}`;
|
||||||
|
} else if (mode === "topic-review") {
|
||||||
|
taskBlock = `【任务】
|
||||||
|
请直接完成下列「面向写作的主题综述」,不要索要额外文本。可结合此前对话中已讨论的内容继续深化。
|
||||||
|
|
||||||
|
${buildTopicReviewPrompt(options.userText)}`;
|
||||||
|
} else {
|
||||||
|
taskBlock = `【任务】
|
||||||
|
请直接完成下列分析,不要索要额外文本。可结合此前对话继续深化。
|
||||||
|
|
||||||
|
${MODE_PROMPTS[mode]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const system = `${getSystemPrompt()}
|
||||||
|
|
||||||
|
你正在进行多论文整体阅读与主题分析,目标是帮助用户写作相关工作/综述。硬性规则:
|
||||||
|
1. 论文材料已在本会话首条用户消息中以【论文 N】形式提供;后续每一轮仍以该材料为准。
|
||||||
|
2. 禁止说「请提供论文」或「只有片段无法分析」。
|
||||||
|
3. 用论文标题区分文献;信息不足也要基于已有正文尽力回答。
|
||||||
|
4. 输出使用 Markdown;含对比时使用标准表格(表头下一行必须是 |---|)。
|
||||||
|
5. 紧扣用户主题,区分核心证据与边缘相关内容。
|
||||||
|
6. 必须结合此前对话上下文持续讨论,不要当成没有论文的新会话。`;
|
||||||
|
|
||||||
|
const messages = assemblePaperChatMessages({
|
||||||
|
system,
|
||||||
|
paperBlock,
|
||||||
|
taskBlock,
|
||||||
|
history: options.history,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
papers,
|
||||||
|
warning: warnings.join(" "),
|
||||||
|
ready: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Read selected text from Zotero PDF/EPUB/Snapshot readers.
|
||||||
|
* Clicking sidebar buttons often clears DOM selection — prefer
|
||||||
|
* Reader internal selection popup / selectionRanges, and call
|
||||||
|
* capture on mousedown with preventDefault.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function tryString(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value.trim();
|
||||||
|
if (value == null) return "";
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function textFromSelectionRanges(ranges: any): string {
|
||||||
|
if (!ranges) return "";
|
||||||
|
if (typeof ranges === "string") return ranges.trim();
|
||||||
|
if (!Array.isArray(ranges)) return "";
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const r of ranges) {
|
||||||
|
if (!r) continue;
|
||||||
|
const t =
|
||||||
|
tryString(r.text) ||
|
||||||
|
tryString(r.toString?.()) ||
|
||||||
|
tryString(r.anchorText) ||
|
||||||
|
tryString(r.content);
|
||||||
|
if (t) parts.push(t);
|
||||||
|
}
|
||||||
|
return parts.join("\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function textFromReaderInternals(reader: any): string {
|
||||||
|
if (!reader) return "";
|
||||||
|
|
||||||
|
// zotero-plugin-toolkit path: selection popup annotation
|
||||||
|
try {
|
||||||
|
const popupText =
|
||||||
|
reader?._internalReader?._lastView?._selectionPopup?.annotation?.text ??
|
||||||
|
reader?._internalReader?._primaryView?._selectionPopup?.annotation?.text;
|
||||||
|
const t = tryString(popupText);
|
||||||
|
if (t) return t;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Official-ish internal ranges (zotero-dev recommendation)
|
||||||
|
const views = [
|
||||||
|
reader?._internalReader?._primaryView,
|
||||||
|
reader?._internalReader?._lastView,
|
||||||
|
reader?._internalReader?._secondaryView,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wrapped =
|
||||||
|
reader?._iframeWindow?.wrappedJSObject?._reader ||
|
||||||
|
reader?._iframeWindow?.wrappedJSObject?.reader;
|
||||||
|
if (wrapped) {
|
||||||
|
views.push(wrapped._primaryView, wrapped._lastView, wrapped._secondaryView);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const view of views) {
|
||||||
|
if (!view) continue;
|
||||||
|
try {
|
||||||
|
const fromRanges = textFromSelectionRanges(view._selectionRanges);
|
||||||
|
if (fromRanges) return fromRanges;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const t = tryString(view._selectionPopup?.annotation?.text);
|
||||||
|
if (t) return t;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM selection fallbacks inside reader iframe(s)
|
||||||
|
const windows: Window[] = [];
|
||||||
|
try {
|
||||||
|
if (reader._iframeWindow) windows.push(reader._iframeWindow);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const iframe =
|
||||||
|
reader?._iframe ||
|
||||||
|
reader?._internalReader?._primaryView?._iframe ||
|
||||||
|
reader?._internalReader?._lastView?._iframe;
|
||||||
|
const cw = iframe?.contentWindow;
|
||||||
|
if (cw) windows.push(cw);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const win of windows) {
|
||||||
|
try {
|
||||||
|
const t = tryString(win.getSelection?.()?.toString?.());
|
||||||
|
if (t) return t;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const t = tryString(win.document?.getSelection?.()?.toString?.());
|
||||||
|
if (t) return t;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function listCandidateReaders(): any[] {
|
||||||
|
const list: any[] = [];
|
||||||
|
try {
|
||||||
|
const tabID = (globalThis as any).Zotero_Tabs?.selectedID;
|
||||||
|
if (tabID) {
|
||||||
|
const byTab = Zotero.Reader.getByTabID?.(tabID);
|
||||||
|
if (byTab) list.push(byTab);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const all =
|
||||||
|
(Zotero.Reader as any).getAll?.() ||
|
||||||
|
(Zotero.Reader as any)._readers ||
|
||||||
|
[];
|
||||||
|
for (const r of all) {
|
||||||
|
if (r && !list.includes(r)) list.push(r);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last non-empty selection cached while user selects in the reader. */
|
||||||
|
let cachedSelection = "";
|
||||||
|
let cachedAt = 0;
|
||||||
|
|
||||||
|
export function cacheReaderSelection(text: string) {
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t) return;
|
||||||
|
cachedSelection = t;
|
||||||
|
cachedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedReaderSelection(maxAgeMs = 120000): string {
|
||||||
|
if (!cachedSelection) return "";
|
||||||
|
if (Date.now() - cachedAt > maxAgeMs) return "";
|
||||||
|
return cachedSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReaderSelectedText(): string {
|
||||||
|
// 1) Toolkit helper when available
|
||||||
|
try {
|
||||||
|
const readers = listCandidateReaders();
|
||||||
|
for (const reader of readers) {
|
||||||
|
try {
|
||||||
|
const viaToolkit = (ztoolkit as any).Reader?.getSelectedText?.(reader);
|
||||||
|
const t = tryString(viaToolkit);
|
||||||
|
if (t) {
|
||||||
|
cacheReaderSelection(t);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
const t = textFromReaderInternals(reader);
|
||||||
|
if (t) {
|
||||||
|
cacheReaderSelection(t);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("getReaderSelectedText failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Recent cache (survives button focus stealing selection)
|
||||||
|
const cached = getCachedReaderSelection();
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep selection cache fresh when user selects text in PDF reader.
|
||||||
|
* Call once at plugin startup.
|
||||||
|
*/
|
||||||
|
export function registerReaderSelectionHook() {
|
||||||
|
try {
|
||||||
|
Zotero.Reader.registerEventListener(
|
||||||
|
"renderTextSelectionPopup",
|
||||||
|
(event: any) => {
|
||||||
|
try {
|
||||||
|
const text =
|
||||||
|
tryString(event?.params?.annotation?.text) ||
|
||||||
|
tryString(event?.params?.text) ||
|
||||||
|
textFromReaderInternals(event?.reader);
|
||||||
|
if (text) cacheReaderSelection(text);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("selection popup hook failed", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addon.data.config.addonID,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("registerReaderSelectionHook failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
+234
-69
@@ -1,10 +1,28 @@
|
|||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
Cpu,
|
||||||
|
Eraser,
|
||||||
|
Highlighter,
|
||||||
|
MessageSquareText,
|
||||||
|
NotebookPen,
|
||||||
|
SendHorizontal,
|
||||||
|
Sparkles,
|
||||||
|
Square,
|
||||||
|
User,
|
||||||
|
type IconNode,
|
||||||
|
} from "lucide";
|
||||||
import { chatStream, getRuntimeConfig } from "../llm/client";
|
import { chatStream, getRuntimeConfig } from "../llm/client";
|
||||||
import { LlmError, type ChatMessage } from "../llm/types";
|
import { LlmError, type ChatMessage } from "../llm/types";
|
||||||
import { getProviderPreset } from "../llm/providers";
|
import { getProviderPreset } from "../llm/providers";
|
||||||
import { buildChatMessages } from "../pdf/context";
|
import { buildChatMessages } from "../pdf/context";
|
||||||
|
import { getReaderSelectedText } from "../pdf/selection";
|
||||||
import { clearSession, loadSession, saveSession } from "../storage/sessions";
|
import { clearSession, loadSession, saveSession } from "../storage/sessions";
|
||||||
import { createChildNote } from "../zotero/notes";
|
import { createChildNote } from "../zotero/notes";
|
||||||
import { getString } from "../../utils/locale";
|
import { getString } from "../../utils/locale";
|
||||||
|
import { markdownToHtml } from "../../utils/markdown";
|
||||||
|
import { createLucideIcon } from "../../utils/icons";
|
||||||
import {
|
import {
|
||||||
createAbortHandle,
|
createAbortHandle,
|
||||||
isAbortError,
|
isAbortError,
|
||||||
@@ -22,8 +40,11 @@ export class ChatView {
|
|||||||
private messagesEl!: HTMLElement;
|
private messagesEl!: HTMLElement;
|
||||||
private inputEl!: HTMLTextAreaElement;
|
private inputEl!: HTMLTextAreaElement;
|
||||||
private statusEl!: HTMLElement;
|
private statusEl!: HTMLElement;
|
||||||
|
private statusIconEl!: HTMLElement;
|
||||||
|
private statusTextEl!: HTMLElement;
|
||||||
private sendBtn!: HTMLButtonElement;
|
private sendBtn!: HTMLButtonElement;
|
||||||
private stopBtn!: HTMLButtonElement;
|
private stopBtn!: HTMLButtonElement;
|
||||||
|
private emptyEl!: HTMLElement;
|
||||||
|
|
||||||
constructor(doc: Document, body: HTMLElement, item: Zotero.Item) {
|
constructor(doc: Document, body: HTMLElement, item: Zotero.Item) {
|
||||||
this.doc = doc;
|
this.doc = doc;
|
||||||
@@ -40,36 +61,78 @@ export class ChatView {
|
|||||||
const providerLabel = preset?.label || cfg.provider || "未配置";
|
const providerLabel = preset?.label || cfg.provider || "未配置";
|
||||||
|
|
||||||
const header = this.el("div", "chatpapers-header");
|
const header = this.el("div", "chatpapers-header");
|
||||||
header.append(
|
const brand = this.el("div", "chatpapers-brand");
|
||||||
this.el("div", "chatpapers-title", this.item.getField("title") || "ChatPapers"),
|
brand.append(
|
||||||
this.el("div", "chatpapers-meta", `${providerLabel} · ${cfg.model || "无模型"}`),
|
createLucideIcon(this.doc, MessageSquareText, {
|
||||||
|
size: 18,
|
||||||
|
className: "chatpapers-brand-icon",
|
||||||
|
}),
|
||||||
|
this.el("div", "chatpapers-brand-text", "ChatPapers"),
|
||||||
);
|
);
|
||||||
|
const titleRow = this.el("div", "chatpapers-title-row");
|
||||||
|
titleRow.append(
|
||||||
|
this.el(
|
||||||
|
"div",
|
||||||
|
"chatpapers-title",
|
||||||
|
this.item.getField("title") || "ChatPapers",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const meta = this.el("div", "chatpapers-meta");
|
||||||
|
meta.append(
|
||||||
|
createLucideIcon(this.doc, Cpu, { size: 13, className: "chatpapers-meta-icon" }),
|
||||||
|
this.el("span", undefined, `${providerLabel} · ${cfg.model || "无模型"}`),
|
||||||
|
);
|
||||||
|
header.append(brand, titleRow, meta);
|
||||||
|
|
||||||
this.messagesEl = this.el("div", "chatpapers-messages");
|
this.messagesEl = this.el("div", "chatpapers-messages");
|
||||||
|
this.emptyEl = this.el("div", "chatpapers-empty");
|
||||||
|
this.emptyEl.append(
|
||||||
|
createLucideIcon(this.doc, Sparkles, {
|
||||||
|
size: 28,
|
||||||
|
className: "chatpapers-empty-icon",
|
||||||
|
}),
|
||||||
|
this.el("div", "chatpapers-empty-title", getString("chat-empty-title")),
|
||||||
|
this.el("div", "chatpapers-empty-desc", getString("chat-empty-desc")),
|
||||||
|
);
|
||||||
|
|
||||||
this.statusEl = this.el("div", "chatpapers-status");
|
this.statusEl = this.el("div", "chatpapers-status");
|
||||||
|
this.statusIconEl = this.el("span", "chatpapers-status-icon");
|
||||||
|
this.statusTextEl = this.el("span", "chatpapers-status-text");
|
||||||
|
this.statusEl.append(this.statusIconEl, this.statusTextEl);
|
||||||
|
|
||||||
const toolbar = this.el("div", "chatpapers-toolbar");
|
const toolbar = this.el("div", "chatpapers-toolbar");
|
||||||
const summarizeBtn = this.btn(getString("chat-summarize"), () =>
|
toolbar.append(
|
||||||
this.runSummary(),
|
this.iconBtn(Sparkles, getString("chat-summarize"), () => this.runSummary()),
|
||||||
|
this.iconBtn(
|
||||||
|
Highlighter,
|
||||||
|
getString("chat-add-selection"),
|
||||||
|
() => this.captureSelection(),
|
||||||
|
undefined,
|
||||||
|
{ preserveSelection: true },
|
||||||
|
),
|
||||||
|
this.iconBtn(Eraser, getString("chat-clear"), () => this.clearChat()),
|
||||||
|
this.iconBtn(NotebookPen, getString("chat-save-note"), () =>
|
||||||
|
this.saveLastNote(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const addSelBtn = this.btn(getString("chat-add-selection"), () =>
|
|
||||||
this.captureSelection(),
|
|
||||||
);
|
|
||||||
const clearBtn = this.btn(getString("chat-clear"), () => this.clearChat());
|
|
||||||
const saveBtn = this.btn(getString("chat-save-note"), () => this.saveLastNote());
|
|
||||||
toolbar.append(summarizeBtn, addSelBtn, clearBtn, saveBtn);
|
|
||||||
|
|
||||||
|
const composer = this.el("div", "chatpapers-composer");
|
||||||
this.inputEl = this.doc.createElement("textarea");
|
this.inputEl = this.doc.createElement("textarea");
|
||||||
this.inputEl.className = "chatpapers-input";
|
this.inputEl.className = "chatpapers-input";
|
||||||
this.inputEl.rows = 3;
|
this.inputEl.rows = 3;
|
||||||
this.inputEl.placeholder = getString("chat-placeholder");
|
this.inputEl.placeholder = getString("chat-placeholder");
|
||||||
|
|
||||||
const actions = this.el("div", "chatpapers-actions");
|
const actions = this.el("div", "chatpapers-actions");
|
||||||
this.sendBtn = this.btn(getString("chat-send"), () => this.send());
|
this.sendBtn = this.iconBtn(
|
||||||
this.sendBtn.classList.add("chatpapers-primary");
|
SendHorizontal,
|
||||||
this.stopBtn = this.btn(getString("chat-stop"), () => this.stop());
|
getString("chat-send"),
|
||||||
|
() => this.send(),
|
||||||
|
"chatpapers-primary",
|
||||||
|
);
|
||||||
|
this.stopBtn = this.iconBtn(Square, getString("chat-stop"), () => this.stop());
|
||||||
this.stopBtn.disabled = true;
|
this.stopBtn.disabled = true;
|
||||||
actions.append(this.sendBtn, this.stopBtn);
|
actions.append(this.sendBtn, this.stopBtn);
|
||||||
|
composer.append(this.inputEl, actions);
|
||||||
|
|
||||||
this.inputEl.addEventListener("keydown", (ev) => {
|
this.inputEl.addEventListener("keydown", (ev) => {
|
||||||
const kev = ev as KeyboardEvent;
|
const kev = ev as KeyboardEvent;
|
||||||
@@ -79,7 +142,13 @@ export class ChatView {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.body.append(header, this.messagesEl, this.statusEl, toolbar, this.inputEl, actions);
|
this.body.append(
|
||||||
|
header,
|
||||||
|
this.messagesEl,
|
||||||
|
this.statusEl,
|
||||||
|
toolbar,
|
||||||
|
composer,
|
||||||
|
);
|
||||||
|
|
||||||
const keys = this.sessionKeys();
|
const keys = this.sessionKeys();
|
||||||
this.messages = await loadSession(keys.itemKey, keys.attachmentKey);
|
this.messages = await loadSession(keys.itemKey, keys.attachmentKey);
|
||||||
@@ -95,15 +164,12 @@ export class ChatView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sessionKeys(): { itemKey: string; attachmentKey: string } {
|
private sessionKeys(): { itemKey: string; attachmentKey: string } {
|
||||||
const parent =
|
const parent = this.item.isRegularItem()
|
||||||
this.item.isRegularItem()
|
|
||||||
? this.item
|
|
||||||
: this.item.parentItemID
|
|
||||||
? Zotero.Items.get(this.item.parentItemID)
|
|
||||||
: this.item;
|
|
||||||
const attachment = this.item.isAttachment()
|
|
||||||
? this.item
|
? this.item
|
||||||
: undefined;
|
: this.item.parentItemID
|
||||||
|
? Zotero.Items.get(this.item.parentItemID)
|
||||||
|
: this.item;
|
||||||
|
const attachment = this.item.isAttachment() ? this.item : undefined;
|
||||||
return {
|
return {
|
||||||
itemKey: parent.key,
|
itemKey: parent.key,
|
||||||
attachmentKey: attachment?.key || "",
|
attachmentKey: attachment?.key || "",
|
||||||
@@ -117,67 +183,154 @@ export class ChatView {
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
private btn(label: string, onClick: () => void): HTMLButtonElement {
|
private iconBtn(
|
||||||
|
icon: IconNode,
|
||||||
|
label: string,
|
||||||
|
onClick: () => void,
|
||||||
|
extraClass?: string,
|
||||||
|
options?: { preserveSelection?: boolean },
|
||||||
|
): HTMLButtonElement {
|
||||||
const b = this.doc.createElement("button");
|
const b = this.doc.createElement("button");
|
||||||
b.type = "button";
|
b.type = "button";
|
||||||
b.className = "chatpapers-btn";
|
b.className = extraClass
|
||||||
b.textContent = label;
|
? `chatpapers-btn ${extraClass}`
|
||||||
b.addEventListener("click", onClick);
|
: "chatpapers-btn";
|
||||||
|
b.title = label;
|
||||||
|
b.append(
|
||||||
|
createLucideIcon(this.doc, icon, { size: 15 }),
|
||||||
|
this.el("span", "chatpapers-btn-label", label),
|
||||||
|
);
|
||||||
|
if (options?.preserveSelection) {
|
||||||
|
// Prevent focus steal that clears PDF text selection
|
||||||
|
b.addEventListener("mousedown", (ev) => {
|
||||||
|
if ((ev as MouseEvent).button !== 0) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
onClick();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
b.addEventListener("click", onClick);
|
||||||
|
}
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
private setStatus(text: string, kind: "info" | "warn" | "error" | "" = "info") {
|
private setStatus(
|
||||||
this.statusEl.textContent = text;
|
text: string,
|
||||||
|
kind: "info" | "warn" | "error" | "" = "info",
|
||||||
|
) {
|
||||||
|
this.statusTextEl.textContent = text;
|
||||||
this.statusEl.dataset.kind = kind;
|
this.statusEl.dataset.kind = kind;
|
||||||
|
this.statusIconEl.replaceChildren();
|
||||||
|
if (!text) return;
|
||||||
|
const icon =
|
||||||
|
kind === "error" || kind === "warn"
|
||||||
|
? Sparkles
|
||||||
|
: Check;
|
||||||
|
this.statusIconEl.append(
|
||||||
|
createLucideIcon(this.doc, icon, { size: 13 }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderMessages() {
|
private renderMessages() {
|
||||||
this.messagesEl.replaceChildren();
|
this.messagesEl.replaceChildren();
|
||||||
for (const msg of this.messages) {
|
const visible = this.messages.filter((m) => m.role !== "system");
|
||||||
if (msg.role === "system") continue;
|
if (!visible.length) {
|
||||||
|
this.messagesEl.append(this.emptyEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const msg of visible) {
|
||||||
const bubble = this.el(
|
const bubble = this.el(
|
||||||
"div",
|
"div",
|
||||||
`chatpapers-bubble chatpapers-${msg.role}`,
|
`chatpapers-bubble chatpapers-${msg.role}`,
|
||||||
);
|
);
|
||||||
const role = this.el(
|
const roleRow = this.el("div", "chatpapers-role");
|
||||||
"div",
|
const roleLeft = this.el("div", "chatpapers-role-left");
|
||||||
"chatpapers-role",
|
roleLeft.append(
|
||||||
msg.role === "user" ? "You" : "AI",
|
createLucideIcon(this.doc, msg.role === "user" ? User : Bot, {
|
||||||
|
size: 13,
|
||||||
|
className: "chatpapers-role-icon",
|
||||||
|
}),
|
||||||
|
this.el("span", undefined, msg.role === "user" ? "You" : "AI"),
|
||||||
);
|
);
|
||||||
const content = this.el("div", "chatpapers-content", msg.content);
|
roleRow.append(roleLeft);
|
||||||
bubble.append(role, content);
|
if (msg.role === "assistant" && msg.content) {
|
||||||
|
const copyBtn = this.doc.createElement("button");
|
||||||
|
copyBtn.type = "button";
|
||||||
|
copyBtn.className = "chatpapers-copy";
|
||||||
|
copyBtn.title = getString("chat-copy");
|
||||||
|
copyBtn.append(
|
||||||
|
createLucideIcon(this.doc, Copy, { size: 12 }),
|
||||||
|
this.el("span", undefined, getString("chat-copy")),
|
||||||
|
);
|
||||||
|
copyBtn.addEventListener("click", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.copyText(msg.content);
|
||||||
|
});
|
||||||
|
roleRow.append(copyBtn);
|
||||||
|
}
|
||||||
|
const content = this.el("div", "chatpapers-content");
|
||||||
|
this.fillContent(content, msg.content, msg.role === "assistant");
|
||||||
|
bubble.append(roleRow, content);
|
||||||
this.messagesEl.append(bubble);
|
this.messagesEl.append(bubble);
|
||||||
}
|
}
|
||||||
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fillContent(el: HTMLElement, text: string, asMarkdown: boolean) {
|
||||||
|
if (asMarkdown) {
|
||||||
|
el.classList.add("chatpapers-md");
|
||||||
|
el.innerHTML = markdownToHtml(text || "");
|
||||||
|
} else {
|
||||||
|
el.classList.remove("chatpapers-md");
|
||||||
|
el.textContent = text || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private copyText(text: string) {
|
||||||
|
try {
|
||||||
|
new ztoolkit.Clipboard().addText(text, "text/unicode").copy();
|
||||||
|
this.setStatus(getString("chat-copied"), "info");
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("Clipboard helper failed", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
(Zotero.Utilities as any).Internal.copyTextToClipboard(text);
|
||||||
|
this.setStatus(getString("chat-copied"), "info");
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log(e);
|
||||||
|
this.setStatus(String(e), "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private appendAssistantPlaceholder(): HTMLElement {
|
private appendAssistantPlaceholder(): HTMLElement {
|
||||||
const bubble = this.el("div", "chatpapers-bubble chatpapers-assistant");
|
const bubble = this.el("div", "chatpapers-bubble chatpapers-assistant");
|
||||||
bubble.append(
|
const roleRow = this.el("div", "chatpapers-role");
|
||||||
this.el("div", "chatpapers-role", "AI"),
|
const roleLeft = this.el("div", "chatpapers-role-left");
|
||||||
this.el("div", "chatpapers-content", ""),
|
roleLeft.append(
|
||||||
|
createLucideIcon(this.doc, Bot, { size: 13 }),
|
||||||
|
this.el("span", undefined, "AI"),
|
||||||
);
|
);
|
||||||
|
roleRow.append(roleLeft);
|
||||||
|
const content = this.el("div", "chatpapers-content chatpapers-md");
|
||||||
|
bubble.append(roleRow, content);
|
||||||
this.messagesEl.append(bubble);
|
this.messagesEl.append(bubble);
|
||||||
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
||||||
return bubble.querySelector(".chatpapers-content") as HTMLElement;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
private captureSelection() {
|
private captureSelection() {
|
||||||
try {
|
try {
|
||||||
const reader = Zotero.Reader.getByTabID?.(Zotero_Tabs.selectedID);
|
const text = getReaderSelectedText();
|
||||||
// @ts-expect-error internal reader APIs vary
|
this.selectionText = text;
|
||||||
const win = reader?._iframeWindow || reader?._internalReader?._primaryView?._iframe?.contentWindow;
|
|
||||||
const sel = win?.getSelection?.()?.toString?.() || "";
|
|
||||||
if (!sel.trim()) {
|
|
||||||
// try main window selection as fallback
|
|
||||||
const mainSel = this.doc.defaultView?.getSelection?.()?.toString?.() || "";
|
|
||||||
this.selectionText = mainSel.trim();
|
|
||||||
} else {
|
|
||||||
this.selectionText = sel.trim();
|
|
||||||
}
|
|
||||||
if (this.selectionText) {
|
if (this.selectionText) {
|
||||||
|
const preview =
|
||||||
|
this.selectionText.length > 80
|
||||||
|
? `${this.selectionText.slice(0, 80)}…`
|
||||||
|
: this.selectionText;
|
||||||
this.setStatus(
|
this.setStatus(
|
||||||
`${getString("chat-selection-added")} (${this.selectionText.length} chars)`,
|
`${getString("chat-selection-added")} (${this.selectionText.length} chars): ${preview}`,
|
||||||
"info",
|
"info",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -233,32 +386,43 @@ export class ChatView {
|
|||||||
});
|
});
|
||||||
this.selectionText = "";
|
this.selectionText = "";
|
||||||
|
|
||||||
if (built.warning) {
|
if (!built.ready) {
|
||||||
this.setStatus(built.warning, "warn");
|
this.setStatus(built.warning || "无法读取 PDF 文本", "error");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "chat") {
|
if (built.warning) {
|
||||||
this.messages.push({ role: "user", content: userText });
|
this.setStatus(built.warning, "info");
|
||||||
} else {
|
|
||||||
this.messages.push({ role: "user", content: userText });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.messages.push({ role: "user", content: userText });
|
||||||
this.renderMessages();
|
this.renderMessages();
|
||||||
|
|
||||||
const contentEl = this.appendAssistantPlaceholder();
|
const contentEl = this.appendAssistantPlaceholder();
|
||||||
let full = "";
|
let full = "";
|
||||||
|
let lastRender = 0;
|
||||||
await chatStream({
|
await chatStream({
|
||||||
messages: built.messages,
|
messages: built.messages,
|
||||||
signal: this.abort.signal,
|
signal: this.abort.signal,
|
||||||
onDelta: (delta) => {
|
onDelta: (delta) => {
|
||||||
full += delta;
|
full += delta;
|
||||||
contentEl.textContent = full;
|
const now = Date.now();
|
||||||
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
if (now - lastRender > 80) {
|
||||||
|
lastRender = now;
|
||||||
|
this.fillContent(contentEl, full, true);
|
||||||
|
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.fillContent(contentEl, full || "(empty)", true);
|
||||||
|
|
||||||
this.messages.push({ role: "assistant", content: full || "(empty)" });
|
this.messages.push({ role: "assistant", content: full || "(empty)" });
|
||||||
await this.persist();
|
await this.persist();
|
||||||
this.setStatus(getString("chat-done"), "info");
|
const chars = built.extract.text?.length || 0;
|
||||||
|
this.setStatus(
|
||||||
|
`${getString("chat-done")} · 已基于 ${chars} 字符论文文本`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isAbortError(e)) {
|
if (isAbortError(e)) {
|
||||||
this.setStatus(getString("chat-stopped"), "warn");
|
this.setStatus(getString("chat-stopped"), "warn");
|
||||||
@@ -283,17 +447,18 @@ export class ChatView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async saveLastNote() {
|
private async saveLastNote() {
|
||||||
const last = [...this.messages].reverse().find((m) => m.role === "assistant");
|
const last = [...this.messages]
|
||||||
|
.reverse()
|
||||||
|
.find((m) => m.role === "assistant");
|
||||||
if (!last) {
|
if (!last) {
|
||||||
this.setStatus(getString("chat-no-reply"), "warn");
|
this.setStatus(getString("chat-no-reply"), "warn");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parent =
|
const parent = this.item.isRegularItem()
|
||||||
this.item.isRegularItem()
|
? this.item
|
||||||
? this.item
|
: this.item.parentItemID
|
||||||
: this.item.parentItemID
|
? Zotero.Items.get(this.item.parentItemID)
|
||||||
? Zotero.Items.get(this.item.parentItemID)
|
: this.item;
|
||||||
: this.item;
|
|
||||||
const title = `ChatPapers · ${new Date().toLocaleString()}`;
|
const title = `ChatPapers · ${new Date().toLocaleString()}`;
|
||||||
try {
|
try {
|
||||||
await createChildNote({
|
await createChildNote({
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { config } from "../../../package.json";
|
||||||
|
import { getString } from "../../utils/locale";
|
||||||
|
import { openMultiChatDialog } from "./multiChatDialog";
|
||||||
|
|
||||||
|
const MENU_CHAT_ID = "chatpapers-itemmenu-chat";
|
||||||
|
const MENU_COMPARE_ID = "chatpapers-itemmenu-compare";
|
||||||
|
|
||||||
|
function getSelectedItems(): Zotero.Item[] {
|
||||||
|
try {
|
||||||
|
const pane = Zotero.getActiveZoteroPane?.();
|
||||||
|
const items = pane?.getSelectedItems?.() || [];
|
||||||
|
return items.filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerItemMenus() {
|
||||||
|
const icon = `chrome://${config.addonRef}/content/icons/favicon@0.5x.png`;
|
||||||
|
|
||||||
|
ztoolkit.Menu.register("item", {
|
||||||
|
tag: "menuitem",
|
||||||
|
id: MENU_CHAT_ID,
|
||||||
|
label: getString("itemmenu-chat"),
|
||||||
|
icon,
|
||||||
|
commandListener: () => {
|
||||||
|
const items = getSelectedItems();
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { config } from "../../../package.json";
|
||||||
|
import { getString } from "../../utils/locale";
|
||||||
|
import { normalizeLibraryItems } from "../pdf/multiContext";
|
||||||
|
import { MultiChatView } from "./multiChatView";
|
||||||
|
|
||||||
|
let activeDialog: any = null;
|
||||||
|
let activeView: MultiChatView | null = null;
|
||||||
|
let onDialogResize: (() => void) | null = null;
|
||||||
|
|
||||||
|
function ensureDialogStyles(doc: Document) {
|
||||||
|
const id = "chatpapers-styles";
|
||||||
|
if (doc.getElementById(id)) return;
|
||||||
|
const link = doc.createElement("link");
|
||||||
|
link.id = id;
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.type = "text/css";
|
||||||
|
link.href = `chrome://${config.addonRef}/content/chatpapers.css`;
|
||||||
|
doc.documentElement?.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefer a large window that still fits the current screen. */
|
||||||
|
function getPreferredDialogSize(): { width: number; height: number } {
|
||||||
|
try {
|
||||||
|
const win = Zotero.getMainWindow?.() as Window | undefined;
|
||||||
|
const screen = win?.screen;
|
||||||
|
const aw = screen?.availWidth || win?.outerWidth || 1280;
|
||||||
|
const ah = screen?.availHeight || win?.outerHeight || 800;
|
||||||
|
return {
|
||||||
|
width: Math.max(880, Math.min(Math.round(aw * 0.9), aw - 48)),
|
||||||
|
height: Math.max(640, Math.min(Math.round(ah * 0.88), ah - 48)),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { width: 1100, height: 780 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDialogButtonBox(doc: Document) {
|
||||||
|
const selectors = [
|
||||||
|
"#dialog-button-box",
|
||||||
|
".dialog-button-box",
|
||||||
|
"dialog > vbox.dialog-button-box",
|
||||||
|
"[anonid='buttons']",
|
||||||
|
];
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const nodes = Array.from(doc.querySelectorAll(sel));
|
||||||
|
for (const node of nodes) {
|
||||||
|
(node as HTMLElement).style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep chat root filling the window so composer stays pinned to the bottom. */
|
||||||
|
function fitMultiChatRoot(win: Window) {
|
||||||
|
const doc = win.document;
|
||||||
|
hideDialogButtonBox(doc);
|
||||||
|
|
||||||
|
const root = doc.getElementById(
|
||||||
|
"chatpapers-multi-root",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const html = doc.documentElement as HTMLElement | null;
|
||||||
|
const body = doc.body as HTMLElement | null;
|
||||||
|
if (html) {
|
||||||
|
html.style.height = "100%";
|
||||||
|
html.style.margin = "0";
|
||||||
|
html.style.padding = "0";
|
||||||
|
html.style.overflow = "hidden";
|
||||||
|
html.style.setProperty("scrollbar-width", "thin");
|
||||||
|
}
|
||||||
|
if (body) {
|
||||||
|
body.style.height = "100%";
|
||||||
|
body.style.margin = "0";
|
||||||
|
body.style.padding = "0";
|
||||||
|
body.style.overflow = "hidden";
|
||||||
|
body.style.display = "flex";
|
||||||
|
body.style.flexDirection = "column";
|
||||||
|
body.style.setProperty("scrollbar-width", "thin");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shrink leftover dialog grid padding around our cell
|
||||||
|
for (const node of Array.from(doc.querySelectorAll("hbox, vbox, dialog"))) {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
if (
|
||||||
|
el.id === "dialog-button-box" ||
|
||||||
|
el.classList?.contains("dialog-button-box")
|
||||||
|
) {
|
||||||
|
el.style.display = "none";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (el.tagName?.toLowerCase() === "dialog") {
|
||||||
|
el.style.padding = "0";
|
||||||
|
el.style.margin = "0";
|
||||||
|
el.style.height = "100%";
|
||||||
|
el.style.setProperty("scrollbar-width", "thin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = 4;
|
||||||
|
const h = Math.max(360, (win.innerHeight || 600) - pad);
|
||||||
|
root.style.width = "100%";
|
||||||
|
root.style.height = `${h}px`;
|
||||||
|
root.style.maxHeight = `${h}px`;
|
||||||
|
root.style.minHeight = "0";
|
||||||
|
root.style.flex = "1 1 auto";
|
||||||
|
root.style.overflow = "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openMultiChatDialog(items: Zotero.Item[]) {
|
||||||
|
const selected = normalizeLibraryItems(items);
|
||||||
|
if (!selected.length) {
|
||||||
|
new ztoolkit.ProgressWindow("ChatPapers")
|
||||||
|
.createLine({
|
||||||
|
text: getString("multi-no-items"),
|
||||||
|
type: "fail",
|
||||||
|
})
|
||||||
|
.show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.length > 8) {
|
||||||
|
new ztoolkit.ProgressWindow("ChatPapers")
|
||||||
|
.createLine({
|
||||||
|
text: getString("multi-too-many", {
|
||||||
|
args: { count: selected.length },
|
||||||
|
}),
|
||||||
|
type: "default",
|
||||||
|
})
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close previous dialog if any
|
||||||
|
try {
|
||||||
|
if (activeDialog?.window && onDialogResize) {
|
||||||
|
activeDialog.window.removeEventListener("resize", onDialogResize);
|
||||||
|
}
|
||||||
|
activeView?.destroy();
|
||||||
|
activeDialog?.window?.close?.();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
activeView = null;
|
||||||
|
activeDialog = null;
|
||||||
|
onDialogResize = null;
|
||||||
|
|
||||||
|
const size = getPreferredDialogSize();
|
||||||
|
|
||||||
|
const dialogHelper = new ztoolkit.Dialog(1, 1)
|
||||||
|
.addCell(0, 0, {
|
||||||
|
tag: "div",
|
||||||
|
namespace: "html",
|
||||||
|
id: "chatpapers-multi-root",
|
||||||
|
styles: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
minWidth: "640px",
|
||||||
|
minHeight: "0",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.setDialogData({
|
||||||
|
loadCallback: () => {
|
||||||
|
try {
|
||||||
|
const win = dialogHelper.window;
|
||||||
|
const doc = win.document;
|
||||||
|
ensureDialogStyles(doc);
|
||||||
|
hideDialogButtonBox(doc);
|
||||||
|
fitMultiChatRoot(win);
|
||||||
|
onDialogResize = () => fitMultiChatRoot(win);
|
||||||
|
win.addEventListener("resize", onDialogResize);
|
||||||
|
|
||||||
|
const root = doc.getElementById(
|
||||||
|
"chatpapers-multi-root",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (!root) return;
|
||||||
|
activeView = new MultiChatView(doc, root, selected.slice(0, 8));
|
||||||
|
void activeView.mount().then(() => fitMultiChatRoot(win));
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("openMultiChatDialog load failed", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unloadCallback: () => {
|
||||||
|
try {
|
||||||
|
if (activeDialog?.window && onDialogResize) {
|
||||||
|
activeDialog.window.removeEventListener("resize", onDialogResize);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
onDialogResize = null;
|
||||||
|
activeView?.destroy();
|
||||||
|
activeView = null;
|
||||||
|
activeDialog = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
activeDialog = dialogHelper;
|
||||||
|
dialogHelper.open(
|
||||||
|
`ChatPapers · ${Math.min(selected.length, 8)} papers`,
|
||||||
|
{
|
||||||
|
centerscreen: true,
|
||||||
|
resizable: true,
|
||||||
|
noDialogMode: true,
|
||||||
|
fitContent: false,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Copy,
|
||||||
|
GitCompare,
|
||||||
|
Layers,
|
||||||
|
ListTree,
|
||||||
|
MessageSquareText,
|
||||||
|
NotebookPen,
|
||||||
|
SendHorizontal,
|
||||||
|
Sparkles,
|
||||||
|
Square,
|
||||||
|
User,
|
||||||
|
type IconNode,
|
||||||
|
} from "lucide";
|
||||||
|
import { chatStream, getRuntimeConfig } from "../llm/client";
|
||||||
|
import { LlmError, type ChatMessage } from "../llm/types";
|
||||||
|
import { getProviderPreset } from "../llm/providers";
|
||||||
|
import {
|
||||||
|
buildMultiChatMessages,
|
||||||
|
normalizeLibraryItems,
|
||||||
|
type MultiChatMode,
|
||||||
|
} from "../pdf/multiContext";
|
||||||
|
import { createChildNote } from "../zotero/notes";
|
||||||
|
import { getString } from "../../utils/locale";
|
||||||
|
import { markdownToHtml } from "../../utils/markdown";
|
||||||
|
import { createLucideIcon } from "../../utils/icons";
|
||||||
|
import {
|
||||||
|
createAbortHandle,
|
||||||
|
isAbortError,
|
||||||
|
type AbortHandle,
|
||||||
|
} from "../../utils/abort";
|
||||||
|
|
||||||
|
export class MultiChatView {
|
||||||
|
private body: HTMLElement;
|
||||||
|
private doc: Document;
|
||||||
|
private items: Zotero.Item[];
|
||||||
|
private messages: ChatMessage[] = [];
|
||||||
|
private abort?: AbortHandle;
|
||||||
|
|
||||||
|
private messagesEl!: HTMLElement;
|
||||||
|
private inputEl!: HTMLTextAreaElement;
|
||||||
|
private statusTextEl!: HTMLElement;
|
||||||
|
private statusEl!: HTMLElement;
|
||||||
|
private sendBtn!: HTMLButtonElement;
|
||||||
|
private stopBtn!: HTMLButtonElement;
|
||||||
|
private emptyEl!: HTMLElement;
|
||||||
|
|
||||||
|
constructor(doc: Document, body: HTMLElement, items: Zotero.Item[]) {
|
||||||
|
this.doc = doc;
|
||||||
|
this.body = body;
|
||||||
|
this.items = normalizeLibraryItems(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
async mount(): Promise<void> {
|
||||||
|
this.body.replaceChildren();
|
||||||
|
this.body.classList.add("chatpapers-root", "chatpapers-multi-root");
|
||||||
|
|
||||||
|
const cfg = getRuntimeConfig();
|
||||||
|
const preset = cfg.provider ? getProviderPreset(cfg.provider) : undefined;
|
||||||
|
const providerLabel = preset?.label || cfg.provider || "未配置";
|
||||||
|
|
||||||
|
const header = this.el("div", "chatpapers-header");
|
||||||
|
const brand = this.el("div", "chatpapers-brand");
|
||||||
|
brand.append(
|
||||||
|
createLucideIcon(this.doc, MessageSquareText, {
|
||||||
|
size: 18,
|
||||||
|
className: "chatpapers-brand-icon",
|
||||||
|
}),
|
||||||
|
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} 篇)`;
|
||||||
|
|
||||||
|
header.append(
|
||||||
|
brand,
|
||||||
|
this.el("div", "chatpapers-title", titles.join(" · ") + more),
|
||||||
|
this.el(
|
||||||
|
"div",
|
||||||
|
"chatpapers-meta",
|
||||||
|
`${providerLabel} · ${cfg.model || "无模型"}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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.messagesEl = this.el("div", "chatpapers-messages");
|
||||||
|
this.emptyEl = this.el("div", "chatpapers-empty");
|
||||||
|
this.emptyEl.append(
|
||||||
|
createLucideIcon(this.doc, GitCompare, {
|
||||||
|
size: 28,
|
||||||
|
className: "chatpapers-empty-icon",
|
||||||
|
}),
|
||||||
|
this.el("div", "chatpapers-empty-title", getString("multi-empty-title")),
|
||||||
|
this.el("div", "chatpapers-empty-desc", getString("multi-empty-desc")),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.statusEl = this.el("div", "chatpapers-status");
|
||||||
|
this.statusTextEl = this.el("span", "chatpapers-status-text");
|
||||||
|
this.statusEl.append(this.statusTextEl);
|
||||||
|
|
||||||
|
const toolbar = this.el("div", "chatpapers-toolbar");
|
||||||
|
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.iconBtn(Sparkles, getString("chat-save-note"), () =>
|
||||||
|
this.saveLastNote(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const composer = this.el("div", "chatpapers-composer");
|
||||||
|
this.inputEl = this.doc.createElement("textarea");
|
||||||
|
this.inputEl.className = "chatpapers-input";
|
||||||
|
this.inputEl.rows = 3;
|
||||||
|
this.inputEl.placeholder = getString("multi-placeholder");
|
||||||
|
|
||||||
|
const actions = this.el("div", "chatpapers-actions");
|
||||||
|
this.sendBtn = this.iconBtn(
|
||||||
|
SendHorizontal,
|
||||||
|
getString("chat-send"),
|
||||||
|
() => this.send(),
|
||||||
|
"chatpapers-primary",
|
||||||
|
);
|
||||||
|
this.stopBtn = this.iconBtn(Square, getString("chat-stop"), () =>
|
||||||
|
this.stop(),
|
||||||
|
);
|
||||||
|
this.stopBtn.disabled = true;
|
||||||
|
actions.append(this.sendBtn, this.stopBtn);
|
||||||
|
composer.append(this.inputEl, actions);
|
||||||
|
|
||||||
|
this.inputEl.addEventListener("keydown", (ev) => {
|
||||||
|
const kev = ev as KeyboardEvent;
|
||||||
|
if (kev.key === "Enter" && !kev.shiftKey) {
|
||||||
|
kev.preventDefault();
|
||||||
|
void this.send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.body.append(
|
||||||
|
header,
|
||||||
|
paperList,
|
||||||
|
this.messagesEl,
|
||||||
|
this.statusEl,
|
||||||
|
toolbar,
|
||||||
|
composer,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.renderMessages();
|
||||||
|
if (!cfg.provider || !cfg.baseUrl) {
|
||||||
|
this.setStatus(getString("chat-need-config"), "warn");
|
||||||
|
} else {
|
||||||
|
this.setStatus(
|
||||||
|
getString("multi-ready", { args: { count: this.items.length } }),
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.stop();
|
||||||
|
this.body.replaceChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
private el(tag: string, className?: string, text?: string): HTMLElement {
|
||||||
|
const node = this.doc.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private iconBtn(
|
||||||
|
icon: IconNode,
|
||||||
|
label: string,
|
||||||
|
onClick: () => void,
|
||||||
|
extraClass?: string,
|
||||||
|
): HTMLButtonElement {
|
||||||
|
const b = this.doc.createElement("button");
|
||||||
|
b.type = "button";
|
||||||
|
b.className = extraClass
|
||||||
|
? `chatpapers-btn ${extraClass}`
|
||||||
|
: "chatpapers-btn";
|
||||||
|
b.title = label;
|
||||||
|
b.append(
|
||||||
|
createLucideIcon(this.doc, icon, { size: 15 }),
|
||||||
|
this.el("span", "chatpapers-btn-label", label),
|
||||||
|
);
|
||||||
|
b.addEventListener("click", onClick);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatus(text: string, kind: "info" | "warn" | "error" | "" = "info") {
|
||||||
|
this.statusTextEl.textContent = text;
|
||||||
|
this.statusEl.dataset.kind = kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderMessages() {
|
||||||
|
this.messagesEl.replaceChildren();
|
||||||
|
const visible = this.messages.filter((m) => m.role !== "system");
|
||||||
|
if (!visible.length) {
|
||||||
|
this.messagesEl.append(this.emptyEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const msg of visible) {
|
||||||
|
const bubble = this.el(
|
||||||
|
"div",
|
||||||
|
`chatpapers-bubble chatpapers-${msg.role}`,
|
||||||
|
);
|
||||||
|
const roleRow = this.el("div", "chatpapers-role");
|
||||||
|
const roleLeft = this.el("div", "chatpapers-role-left");
|
||||||
|
roleLeft.append(
|
||||||
|
createLucideIcon(this.doc, msg.role === "user" ? User : Bot, {
|
||||||
|
size: 13,
|
||||||
|
}),
|
||||||
|
this.el("span", undefined, msg.role === "user" ? "You" : "AI"),
|
||||||
|
);
|
||||||
|
roleRow.append(roleLeft);
|
||||||
|
if (msg.role === "assistant" && msg.content) {
|
||||||
|
const copyBtn = this.doc.createElement("button");
|
||||||
|
copyBtn.type = "button";
|
||||||
|
copyBtn.className = "chatpapers-copy";
|
||||||
|
copyBtn.append(
|
||||||
|
createLucideIcon(this.doc, Copy, { size: 12 }),
|
||||||
|
this.el("span", undefined, getString("chat-copy")),
|
||||||
|
);
|
||||||
|
copyBtn.addEventListener("click", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.copyText(msg.content);
|
||||||
|
});
|
||||||
|
roleRow.append(copyBtn);
|
||||||
|
}
|
||||||
|
const content = this.el("div", "chatpapers-content");
|
||||||
|
this.fillContent(content, msg.content, msg.role === "assistant");
|
||||||
|
bubble.append(roleRow, content);
|
||||||
|
this.messagesEl.append(bubble);
|
||||||
|
}
|
||||||
|
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fillContent(el: HTMLElement, text: string, asMarkdown: boolean) {
|
||||||
|
if (asMarkdown) {
|
||||||
|
el.classList.add("chatpapers-md");
|
||||||
|
el.innerHTML = markdownToHtml(text || "");
|
||||||
|
} else {
|
||||||
|
el.classList.remove("chatpapers-md");
|
||||||
|
el.textContent = text || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private copyText(text: string) {
|
||||||
|
try {
|
||||||
|
new ztoolkit.Clipboard().addText(text, "text/unicode").copy();
|
||||||
|
this.setStatus(getString("chat-copied"), "info");
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
(Zotero.Utilities as any).Internal.copyTextToClipboard(text);
|
||||||
|
this.setStatus(getString("chat-copied"), "info");
|
||||||
|
} catch (e) {
|
||||||
|
this.setStatus(String(e), "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendAssistantPlaceholder(): HTMLElement {
|
||||||
|
const bubble = this.el("div", "chatpapers-bubble chatpapers-assistant");
|
||||||
|
const roleRow = this.el("div", "chatpapers-role");
|
||||||
|
const roleLeft = this.el("div", "chatpapers-role-left");
|
||||||
|
roleLeft.append(
|
||||||
|
createLucideIcon(this.doc, Bot, { size: 13 }),
|
||||||
|
this.el("span", undefined, "AI"),
|
||||||
|
);
|
||||||
|
roleRow.append(roleLeft);
|
||||||
|
const content = this.el("div", "chatpapers-content chatpapers-md");
|
||||||
|
bubble.append(roleRow, content);
|
||||||
|
this.messagesEl.append(bubble);
|
||||||
|
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setBusy(busy: boolean) {
|
||||||
|
this.sendBtn.disabled = busy;
|
||||||
|
this.stopBtn.disabled = !busy;
|
||||||
|
this.inputEl.disabled = busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stop() {
|
||||||
|
this.abort?.abort();
|
||||||
|
this.abort = undefined;
|
||||||
|
this.setBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async send() {
|
||||||
|
const text = this.inputEl.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
this.inputEl.value = "";
|
||||||
|
await this.run("chat", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runMode(mode: MultiChatMode, label: string) {
|
||||||
|
await 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) {
|
||||||
|
this.setStatus(getString("multi-need-topic"), "warn");
|
||||||
|
this.inputEl.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.inputEl.value = "";
|
||||||
|
await this.run("topic-review", topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async run(mode: MultiChatMode, userText: string) {
|
||||||
|
this.stop();
|
||||||
|
this.abort = createAbortHandle();
|
||||||
|
this.setBusy(true);
|
||||||
|
this.setStatus(getString("chat-thinking"), "info");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const built = await buildMultiChatMessages({
|
||||||
|
items: this.items,
|
||||||
|
history: this.messages,
|
||||||
|
userText,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!built.ready) {
|
||||||
|
this.setStatus(built.warning || "无法构建多论文上下文", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (built.warning) this.setStatus(built.warning, "info");
|
||||||
|
|
||||||
|
this.messages.push({ role: "user", content: userText });
|
||||||
|
this.renderMessages();
|
||||||
|
|
||||||
|
const contentEl = this.appendAssistantPlaceholder();
|
||||||
|
let full = "";
|
||||||
|
let lastRender = 0;
|
||||||
|
await chatStream({
|
||||||
|
messages: built.messages,
|
||||||
|
signal: this.abort.signal,
|
||||||
|
onDelta: (delta) => {
|
||||||
|
full += delta;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastRender > 80) {
|
||||||
|
lastRender = now;
|
||||||
|
this.fillContent(contentEl, full, true);
|
||||||
|
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.fillContent(contentEl, full || "(empty)", true);
|
||||||
|
this.messages.push({ role: "assistant", content: full || "(empty)" });
|
||||||
|
this.setStatus(
|
||||||
|
`${getString("chat-done")} · ${built.papers.filter((p) => p.extract.text).length} 篇`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (isAbortError(e)) this.setStatus(getString("chat-stopped"), "warn");
|
||||||
|
else if (e instanceof LlmError) this.setStatus(e.message, "error");
|
||||||
|
else this.setStatus(String(e), "error");
|
||||||
|
} finally {
|
||||||
|
this.setBusy(false);
|
||||||
|
this.abort = undefined;
|
||||||
|
this.renderMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveLastNote() {
|
||||||
|
const last = [...this.messages]
|
||||||
|
.reverse()
|
||||||
|
.find((m) => m.role === "assistant");
|
||||||
|
if (!last) {
|
||||||
|
this.setStatus(getString("chat-no-reply"), "warn");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parent = this.items.find((i) => i.isRegularItem()) || this.items[0];
|
||||||
|
if (!parent) return;
|
||||||
|
const noteParent = parent.isAttachment() && parent.parentItemID
|
||||||
|
? Zotero.Items.get(parent.parentItemID) || parent
|
||||||
|
: parent;
|
||||||
|
try {
|
||||||
|
await createChildNote({
|
||||||
|
parentItem: noteParent,
|
||||||
|
title: `ChatPapers 多篇对照 · ${new Date().toLocaleString()}`,
|
||||||
|
bodyMarkdown: last.content,
|
||||||
|
});
|
||||||
|
this.setStatus(getString("chat-note-saved"), "info");
|
||||||
|
new ztoolkit.ProgressWindow("ChatPapers")
|
||||||
|
.createLine({ text: getString("chat-note-saved"), type: "success" })
|
||||||
|
.show();
|
||||||
|
} catch (e) {
|
||||||
|
this.setStatus(String(e), "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { markdownToNoteHtml } from "../../utils/markdown";
|
||||||
|
|
||||||
export async function createChildNote(options: {
|
export async function createChildNote(options: {
|
||||||
parentItem: Zotero.Item;
|
parentItem: Zotero.Item;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -7,94 +9,8 @@ export async function createChildNote(options: {
|
|||||||
note.libraryID = options.parentItem.libraryID;
|
note.libraryID = options.parentItem.libraryID;
|
||||||
note.parentID = options.parentItem.id;
|
note.parentID = options.parentItem.id;
|
||||||
|
|
||||||
const html = markdownToSimpleHtml(options.title, options.bodyMarkdown);
|
const html = markdownToNoteHtml(options.title, options.bodyMarkdown);
|
||||||
note.setNote(html);
|
note.setNote(html);
|
||||||
await note.saveTx();
|
await note.saveTx();
|
||||||
return note;
|
return note;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
|
||||||
return s
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimal markdown → HTML for Zotero notes */
|
|
||||||
export function markdownToSimpleHtml(title: string, md: string): string {
|
|
||||||
const lines = md.replace(/\r\n/g, "\n").split("\n");
|
|
||||||
const parts: string[] = [
|
|
||||||
`<h1>${escapeHtml(title)}</h1>`,
|
|
||||||
`<p><i>Generated by ChatPapers · ${new Date().toLocaleString()}</i></p>`,
|
|
||||||
];
|
|
||||||
|
|
||||||
let inList = false;
|
|
||||||
let inCode = false;
|
|
||||||
const codeBuf: string[] = [];
|
|
||||||
|
|
||||||
const closeList = () => {
|
|
||||||
if (inList) {
|
|
||||||
parts.push("</ul>");
|
|
||||||
inList = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith("```")) {
|
|
||||||
if (inCode) {
|
|
||||||
parts.push(`<pre>${escapeHtml(codeBuf.join("\n"))}</pre>`);
|
|
||||||
codeBuf.length = 0;
|
|
||||||
inCode = false;
|
|
||||||
} else {
|
|
||||||
closeList();
|
|
||||||
inCode = true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (inCode) {
|
|
||||||
codeBuf.push(line);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const heading = /^(#{1,3})\s+(.*)$/.exec(line);
|
|
||||||
if (heading) {
|
|
||||||
closeList();
|
|
||||||
const level = heading[1].length;
|
|
||||||
parts.push(`<h${level + 1}>${escapeHtml(heading[2])}</h${level + 1}>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const li = /^[-*]\s+(.*)$/.exec(line);
|
|
||||||
if (li) {
|
|
||||||
if (!inList) {
|
|
||||||
parts.push("<ul>");
|
|
||||||
inList = true;
|
|
||||||
}
|
|
||||||
parts.push(`<li>${inlineFormat(li[1])}</li>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!line.trim()) {
|
|
||||||
closeList();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
closeList();
|
|
||||||
parts.push(`<p>${inlineFormat(line)}</p>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeList();
|
|
||||||
if (inCode) {
|
|
||||||
parts.push(`<pre>${escapeHtml(codeBuf.join("\n"))}</pre>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function inlineFormat(text: string): string {
|
|
||||||
let s = escapeHtml(text);
|
|
||||||
s = s.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
|
|
||||||
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { IconNode } from "lucide";
|
||||||
|
|
||||||
|
export type LucideIcon = IconNode;
|
||||||
|
|
||||||
|
export function createLucideIcon(
|
||||||
|
doc: Document,
|
||||||
|
icon: LucideIcon,
|
||||||
|
options: {
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
className?: string;
|
||||||
|
} = {},
|
||||||
|
): Element {
|
||||||
|
const size = options.size ?? 16;
|
||||||
|
const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
svg.setAttribute("width", String(size));
|
||||||
|
svg.setAttribute("height", String(size));
|
||||||
|
svg.setAttribute("viewBox", "0 0 24 24");
|
||||||
|
svg.setAttribute("fill", "none");
|
||||||
|
svg.setAttribute("stroke", "currentColor");
|
||||||
|
svg.setAttribute("stroke-width", String(options.strokeWidth ?? 2));
|
||||||
|
svg.setAttribute("stroke-linecap", "round");
|
||||||
|
svg.setAttribute("stroke-linejoin", "round");
|
||||||
|
svg.setAttribute("aria-hidden", "true");
|
||||||
|
svg.classList.add("chatpapers-icon");
|
||||||
|
if (options.className) {
|
||||||
|
for (const c of options.className.split(/\s+/)) {
|
||||||
|
if (c) svg.classList.add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of icon) {
|
||||||
|
const [tag, attrs] = node;
|
||||||
|
const el = doc.createElementNS("http://www.w3.org/2000/svg", tag);
|
||||||
|
for (const [key, value] of Object.entries(attrs)) {
|
||||||
|
el.setAttribute(key, String(value));
|
||||||
|
}
|
||||||
|
svg.appendChild(el);
|
||||||
|
}
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* Markdown + LaTeX → HTML for chat bubbles and Zotero notes.
|
||||||
|
* LaTeX is rendered to MathML via KaTeX (native in Firefox / Zotero).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import katex from "katex";
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTex(tex: string, displayMode: boolean): string {
|
||||||
|
const cleaned = tex.trim();
|
||||||
|
if (!cleaned) return "";
|
||||||
|
try {
|
||||||
|
const mathml = katex.renderToString(cleaned, {
|
||||||
|
throwOnError: false,
|
||||||
|
displayMode,
|
||||||
|
output: "mathml",
|
||||||
|
strict: "ignore",
|
||||||
|
trust: false,
|
||||||
|
});
|
||||||
|
const cls = displayMode
|
||||||
|
? "chatpapers-math chatpapers-math-block"
|
||||||
|
: "chatpapers-math chatpapers-math-inline";
|
||||||
|
return `<span class="${cls}">${mathml}</span>`;
|
||||||
|
} catch {
|
||||||
|
return `<code class="chatpapers-tex">${escapeHtml(cleaned)}</code>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protect code / math segments with placeholders, then restore after MD.
|
||||||
|
*/
|
||||||
|
function extractProtected(md: string): { text: string; slots: string[] } {
|
||||||
|
const slots: string[] = [];
|
||||||
|
const put = (html: string) => {
|
||||||
|
const id = slots.length;
|
||||||
|
slots.push(html);
|
||||||
|
return `%%CPMATH${id}%%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let text = md;
|
||||||
|
|
||||||
|
// Fenced code blocks first (no math inside)
|
||||||
|
text = text.replace(/```([^\n`]*)\n?([\s\S]*?)```/g, (_m, _lang, code: string) => {
|
||||||
|
const body = String(code).replace(/^\n+|\n+$/g, "");
|
||||||
|
return put(`<pre><code>${escapeHtml(body)}</code></pre>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inline code
|
||||||
|
text = text.replace(/`([^`\n]+)`/g, (_m, code: string) =>
|
||||||
|
put(`<code>${escapeHtml(code)}</code>`),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display: $$...$$
|
||||||
|
text = text.replace(/\$\$([\s\S]+?)\$\$/g, (_m, tex: string) =>
|
||||||
|
put(renderTex(tex, true)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display: \[...\]
|
||||||
|
text = text.replace(/\\\[([\s\S]+?)\\\]/g, (_m, tex: string) =>
|
||||||
|
put(renderTex(tex, true)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Environments: \begin{equation*}...\end{equation*} etc.
|
||||||
|
text = text.replace(
|
||||||
|
/\\begin\{(equation\*?|align\*?|gather\*?|multline\*?)\}([\s\S]+?)\\end\{\1\}/g,
|
||||||
|
(_m, env: string, body: string) =>
|
||||||
|
put(renderTex(`\\begin{${env}}${body}\\end{${env}}`, true)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Inline: \(...\)
|
||||||
|
text = text.replace(/\\\(([\s\S]+?)\\\)/g, (_m, tex: string) =>
|
||||||
|
put(renderTex(tex, false)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Inline: $...$ (single line, non-empty)
|
||||||
|
text = text.replace(/\$([^\$\n]+?)\$/g, (_m, tex: string) => {
|
||||||
|
// Skip currency-like: $5 or $12.99 without latex commands/letters pattern
|
||||||
|
const t = tex.trim();
|
||||||
|
if (/^\d+([.,]\d+)?$/.test(t)) return `$${tex}$`;
|
||||||
|
return put(renderTex(tex, false));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { text, slots };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreSlots(html: string, slots: string[]): string {
|
||||||
|
return html.replace(/%%CPMATH(\d+)%%/g, (_m, id: string) => {
|
||||||
|
const i = Number(id);
|
||||||
|
return slots[i] ?? "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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>',
|
||||||
|
);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTableSeparator(line: string): boolean {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t.includes("|") || !t.includes("-")) return false;
|
||||||
|
const cells = t
|
||||||
|
.replace(/^\|/, "")
|
||||||
|
.replace(/\|$/, "")
|
||||||
|
.split("|");
|
||||||
|
return (
|
||||||
|
cells.length > 0 &&
|
||||||
|
cells.every((c) => /^\s*:?-{3,}:?\s*$/.test(c))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTableRow(line: string): boolean {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t.includes("|") || isTableSeparator(t)) return false;
|
||||||
|
return splitTableCells(t).length >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTableCells(line: string): string[] {
|
||||||
|
let t = line.trim();
|
||||||
|
if (t.startsWith("|")) t = t.slice(1);
|
||||||
|
if (t.endsWith("|")) t = t.slice(0, -1);
|
||||||
|
return t.split("|").map((c) => c.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAlignments(sepLine: string): Array<"left" | "center" | "right" | ""> {
|
||||||
|
return splitTableCells(sepLine).map((cell) => {
|
||||||
|
const left = cell.startsWith(":");
|
||||||
|
const right = cell.endsWith(":");
|
||||||
|
if (left && right) return "center";
|
||||||
|
if (right) return "right";
|
||||||
|
if (left) return "left";
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTableHtml(
|
||||||
|
header: string[],
|
||||||
|
aligns: Array<"left" | "center" | "right" | "">,
|
||||||
|
rows: string[][],
|
||||||
|
): string {
|
||||||
|
const alignAttr = (i: number) => {
|
||||||
|
const a = aligns[i] || "";
|
||||||
|
return a ? ` style="text-align:${a}"` : "";
|
||||||
|
};
|
||||||
|
const ths = header
|
||||||
|
.map((c, i) => `<th${alignAttr(i)}>${inlineFormat(c)}</th>`)
|
||||||
|
.join("");
|
||||||
|
const trs = rows
|
||||||
|
.map(
|
||||||
|
(row) =>
|
||||||
|
`<tr>${row
|
||||||
|
.map((c, i) => `<td${alignAttr(i)}>${inlineFormat(c)}</td>`)
|
||||||
|
.join("")}</tr>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
return `<div class="chatpapers-table-wrap"><table class="chatpapers-table"><thead><tr>${ths}</tr></thead><tbody>${trs}</tbody></table></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdownLines(normalized: string): string {
|
||||||
|
const lines = normalized.split("\n");
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
let inList: "ul" | "ol" | null = null;
|
||||||
|
let inBlockquote = false;
|
||||||
|
let paraBuf: string[] = [];
|
||||||
|
|
||||||
|
const closeList = () => {
|
||||||
|
if (inList) {
|
||||||
|
parts.push(`</${inList}>`);
|
||||||
|
inList = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeQuote = () => {
|
||||||
|
if (inBlockquote) {
|
||||||
|
parts.push("</blockquote>");
|
||||||
|
inBlockquote = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushPara = () => {
|
||||||
|
if (!paraBuf.length) return;
|
||||||
|
const text = paraBuf.join(" ").trim();
|
||||||
|
paraBuf = [];
|
||||||
|
if (!text) return;
|
||||||
|
// Standalone math placeholder → no wrapping <p> for block math
|
||||||
|
if (/^%%CPMATH\d+%%$/.test(text) && text.includes("MATH")) {
|
||||||
|
parts.push(inlineFormat(text));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parts.push(`<p>${inlineFormat(text)}</p>`);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
// GFM table: header + separator + body rows
|
||||||
|
if (
|
||||||
|
isTableRow(line) &&
|
||||||
|
i + 1 < lines.length &&
|
||||||
|
isTableSeparator(lines[i + 1])
|
||||||
|
) {
|
||||||
|
flushPara();
|
||||||
|
closeList();
|
||||||
|
closeQuote();
|
||||||
|
const header = splitTableCells(line);
|
||||||
|
const aligns = parseAlignments(lines[i + 1]);
|
||||||
|
const rows: string[][] = [];
|
||||||
|
i += 2;
|
||||||
|
while (i < lines.length && isTableRow(lines[i])) {
|
||||||
|
rows.push(splitTableCells(lines[i]));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
i--; // compensate for for-loop increment
|
||||||
|
parts.push(renderTableHtml(header, aligns, rows));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already-rendered block from slot sitting alone
|
||||||
|
if (/^\s*%%CPMATH\d+%%\s*$/.test(line)) {
|
||||||
|
flushPara();
|
||||||
|
closeList();
|
||||||
|
closeQuote();
|
||||||
|
parts.push(line.trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^---+\s*$/.test(line) || /^\*\*\*+\s*$/.test(line)) {
|
||||||
|
flushPara();
|
||||||
|
closeList();
|
||||||
|
closeQuote();
|
||||||
|
parts.push("<hr/>");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
||||||
|
if (heading) {
|
||||||
|
flushPara();
|
||||||
|
closeList();
|
||||||
|
closeQuote();
|
||||||
|
const level = Math.min(heading[1].length, 4);
|
||||||
|
const title = heading[2].trim();
|
||||||
|
if (title) {
|
||||||
|
parts.push(`<h${level}>${inlineFormat(title)}</h${level}>`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bq = /^>\s?(.*)$/.exec(line);
|
||||||
|
if (bq) {
|
||||||
|
flushPara();
|
||||||
|
closeList();
|
||||||
|
const quoteText = bq[1].trim();
|
||||||
|
if (!quoteText) continue;
|
||||||
|
if (!inBlockquote) {
|
||||||
|
parts.push("<blockquote>");
|
||||||
|
inBlockquote = true;
|
||||||
|
}
|
||||||
|
parts.push(`<p>${inlineFormat(quoteText)}</p>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
closeQuote();
|
||||||
|
|
||||||
|
const ol = /^(\d+)\.\s+(.*)$/.exec(line);
|
||||||
|
if (ol) {
|
||||||
|
flushPara();
|
||||||
|
if (inList !== "ol") {
|
||||||
|
closeList();
|
||||||
|
parts.push("<ol>");
|
||||||
|
inList = "ol";
|
||||||
|
}
|
||||||
|
parts.push(`<li>${inlineFormat(ol[2].trim())}</li>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ul = /^[-*+]\s+(.*)$/.exec(line);
|
||||||
|
if (ul) {
|
||||||
|
flushPara();
|
||||||
|
if (inList !== "ul") {
|
||||||
|
closeList();
|
||||||
|
parts.push("<ul>");
|
||||||
|
inList = "ul";
|
||||||
|
}
|
||||||
|
parts.push(`<li>${inlineFormat(ul[1].trim())}</li>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!line.trim()) {
|
||||||
|
flushPara();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeList();
|
||||||
|
paraBuf.push(line.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
flushPara();
|
||||||
|
closeList();
|
||||||
|
closeQuote();
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert markdown (+ LaTeX) body to safe HTML fragment. */
|
||||||
|
export function markdownToHtml(md: string): string {
|
||||||
|
const normalized = (md || "")
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
if (!normalized) return "";
|
||||||
|
|
||||||
|
const { text, slots } = extractProtected(normalized);
|
||||||
|
const html = renderMarkdownLines(text);
|
||||||
|
return restoreSlots(html, slots) || `<p>${escapeHtml(md || "")}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Markdown note body with title header for Zotero notes. */
|
||||||
|
export function markdownToNoteHtml(title: string, md: string): string {
|
||||||
|
return [
|
||||||
|
`<h1>${escapeHtml(title)}</h1>`,
|
||||||
|
`<p><i>Generated by ChatPapers · ${new Date().toLocaleString()}</i></p>`,
|
||||||
|
markdownToHtml(md),
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
Vendored
+20
@@ -6,7 +6,11 @@ export type FluentMessageId =
|
|||||||
| 'chat-add-selection'
|
| 'chat-add-selection'
|
||||||
| 'chat-clear'
|
| 'chat-clear'
|
||||||
| 'chat-cleared'
|
| 'chat-cleared'
|
||||||
|
| 'chat-copied'
|
||||||
|
| 'chat-copy'
|
||||||
| 'chat-done'
|
| 'chat-done'
|
||||||
|
| 'chat-empty-desc'
|
||||||
|
| 'chat-empty-title'
|
||||||
| 'chat-loading'
|
| 'chat-loading'
|
||||||
| 'chat-need-config'
|
| 'chat-need-config'
|
||||||
| 'chat-no-reply'
|
| 'chat-no-reply'
|
||||||
@@ -22,7 +26,23 @@ export type FluentMessageId =
|
|||||||
| 'chat-thinking'
|
| 'chat-thinking'
|
||||||
| 'item-section-chat-head'
|
| 'item-section-chat-head'
|
||||||
| 'item-section-chat-sidenav'
|
| 'item-section-chat-sidenav'
|
||||||
|
| 'itemmenu-chat'
|
||||||
|
| 'itemmenu-compare'
|
||||||
| 'itemmenu-label'
|
| 'itemmenu-label'
|
||||||
|
| 'multi-close'
|
||||||
|
| 'multi-common'
|
||||||
|
| 'multi-compare'
|
||||||
|
| 'multi-empty-desc'
|
||||||
|
| 'multi-empty-title'
|
||||||
|
| 'multi-need-topic'
|
||||||
|
| 'multi-need-two'
|
||||||
|
| 'multi-no-items'
|
||||||
|
| 'multi-placeholder'
|
||||||
|
| 'multi-ready'
|
||||||
|
| 'multi-summary-each'
|
||||||
|
| 'multi-title'
|
||||||
|
| 'multi-too-many'
|
||||||
|
| 'multi-topic-review'
|
||||||
| 'prefs-answer-language'
|
| 'prefs-answer-language'
|
||||||
| 'prefs-api-key'
|
| 'prefs-api-key'
|
||||||
| 'prefs-base-url'
|
| 'prefs-base-url'
|
||||||
|
|||||||
Reference in New Issue
Block a user