first commit
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
# Usage:
|
||||||
|
# Copy this file as `.env` and fill in the variables below as instructed.
|
||||||
|
|
||||||
|
# If you are developing more than one plugin, you can store the bin path and
|
||||||
|
# profile path in the system environment variables, which can be omitted here.
|
||||||
|
|
||||||
|
# The path of the Zotero binary file.
|
||||||
|
# The path delimiter should be escaped as `\\` for win32.
|
||||||
|
# The path is `*/Zotero.app/Contents/MacOS/zotero` for MacOS.
|
||||||
|
ZOTERO_PLUGIN_ZOTERO_BIN_PATH = /path/to/zotero.exe
|
||||||
|
|
||||||
|
# The path of the profile used for development.
|
||||||
|
# Start the profile manager by `/path/to/zotero.exe -p` to create a profile for development.
|
||||||
|
# @see https://www.zotero.org/support/kb/profile_directory
|
||||||
|
ZOTERO_PLUGIN_PROFILE_PATH = /path/to/profile
|
||||||
|
|
||||||
|
# The directory where the database is located.
|
||||||
|
# If this field is kept empty, Zotero will start with the default data.
|
||||||
|
# @see https://www.zotero.org/support/zotero_data
|
||||||
|
ZOTERO_PLUGIN_DATA_DIR =
|
||||||
|
|
||||||
|
# Custom commands to kill Zotero processes.
|
||||||
|
# Commands for different platforms are already built into zotero-plugin,
|
||||||
|
# if the built-in commands are not suitable for your needs, please modify this variable.
|
||||||
|
# ZOTERO_PLUGIN_KILL_COMMAND =
|
||||||
|
|
||||||
|
# GitHub Token
|
||||||
|
# For scaffold auto create release and upload assets.
|
||||||
|
# Fill in this variable if you are publishing locally instead of CI.
|
||||||
|
# GITHUB_TOKEN =
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# dot files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# TSC
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
# Scaffold
|
||||||
|
.env
|
||||||
|
.scaffold
|
||||||
|
build
|
||||||
|
logs
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.vscode
|
||||||
|
build
|
||||||
|
logs
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"macabeus.vscode-fluent"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
// 使用 IntelliSense 了解相关属性。
|
||||||
|
// 悬停以查看现有属性的描述。
|
||||||
|
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Start",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "start"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Build",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "build"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"editor.formatOnType": false,
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
|
}
|
||||||
Vendored
+45
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"appendElement - full": {
|
||||||
|
"scope": "javascript,typescript",
|
||||||
|
"prefix": "appendElement",
|
||||||
|
"body": [
|
||||||
|
"appendElement({",
|
||||||
|
"\ttag: '${1:div}',",
|
||||||
|
"\tid: '${2:id}',",
|
||||||
|
"\tnamespace: '${3:html}',",
|
||||||
|
"\tclassList: ['${4:class}'],",
|
||||||
|
"\tstyles: {${5:style}: '$6'},",
|
||||||
|
"\tproperties: {},",
|
||||||
|
"\tattributes: {},",
|
||||||
|
"\t[{ '${7:onload}', (e: Event) => $8, ${9:false} }],",
|
||||||
|
"\tcheckExistanceParent: ${10:HTMLElement},",
|
||||||
|
"\tignoreIfExists: ${11:true},",
|
||||||
|
"\tskipIfExists: ${12:true},",
|
||||||
|
"\tremoveIfExists: ${13:true},",
|
||||||
|
"\tcustomCheck: (doc: Document, options: ElementOptions) => ${14:true},",
|
||||||
|
"\tchildren: [$15]",
|
||||||
|
"}, ${16:container});"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"appendElement - minimum": {
|
||||||
|
"scope": "javascript,typescript",
|
||||||
|
"prefix": "appendElement",
|
||||||
|
"body": "appendElement({ tag: '$1' }, $2);"
|
||||||
|
},
|
||||||
|
"register Notifier": {
|
||||||
|
"scope": "javascript,typescript",
|
||||||
|
"prefix": "registerObserver",
|
||||||
|
"body": [
|
||||||
|
"registerObserver({",
|
||||||
|
"\t notify: (",
|
||||||
|
"\t\tevent: _ZoteroTypes.Notifier.Event,",
|
||||||
|
"\t\ttype: _ZoteroTypes.Notifier.Type,",
|
||||||
|
"\t\tids: string[],",
|
||||||
|
"\t\textraData: _ZoteroTypes.anyObj",
|
||||||
|
"\t) => {",
|
||||||
|
"\t\t$0",
|
||||||
|
"\t}",
|
||||||
|
"});"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published
|
||||||
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<http://www.gnu.org/licenses/>.
|
||||||
Vendored
+58
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Most of this code is from Zotero team's official Make It Red example[1]
|
||||||
|
* or the Zotero 7 documentation[2].
|
||||||
|
* [1] https://github.com/zotero/make-it-red
|
||||||
|
* [2] https://www.zotero.org/support/dev/zotero_7_for_developers
|
||||||
|
*/
|
||||||
|
|
||||||
|
var chromeHandle;
|
||||||
|
|
||||||
|
function install(data, reason) {}
|
||||||
|
|
||||||
|
async function startup({ id, version, resourceURI, rootURI }, reason) {
|
||||||
|
var aomStartup = Components.classes[
|
||||||
|
"@mozilla.org/addons/addon-manager-startup;1"
|
||||||
|
].getService(Components.interfaces.amIAddonManagerStartup);
|
||||||
|
var manifestURI = Services.io.newURI(rootURI + "manifest.json");
|
||||||
|
chromeHandle = aomStartup.registerChrome(manifestURI, [
|
||||||
|
["content", "__addonRef__", rootURI + "content/"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global variables for plugin code.
|
||||||
|
* The `_globalThis` is the global root variable of the plugin sandbox environment
|
||||||
|
* and all child variables assigned to it is globally accessible.
|
||||||
|
* See `src/index.ts` for details.
|
||||||
|
*/
|
||||||
|
const ctx = { rootURI };
|
||||||
|
ctx._globalThis = ctx;
|
||||||
|
|
||||||
|
Services.scriptloader.loadSubScript(
|
||||||
|
`${rootURI}/content/scripts/__addonRef__.js`,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await Zotero.__addonInstance__.hooks.onStartup();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMainWindowLoad({ window }, reason) {
|
||||||
|
await Zotero.__addonInstance__?.hooks.onMainWindowLoad(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMainWindowUnload({ window }, reason) {
|
||||||
|
await Zotero.__addonInstance__?.hooks.onMainWindowUnload(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shutdown({ id, version, resourceURI, rootURI }, reason) {
|
||||||
|
if (reason === APP_SHUTDOWN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Zotero.__addonInstance__?.hooks.onShutdown();
|
||||||
|
|
||||||
|
if (chromeHandle) {
|
||||||
|
chromeHandle.destruct();
|
||||||
|
chromeHandle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uninstall(data, reason) {}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
.chatpapers-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
min-height: 320px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-title {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-meta {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-bubble {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
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;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-status {
|
||||||
|
min-height: 1.2em;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-status[data-kind="warn"] {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-status[data-kind="error"] {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-toolbar,
|
||||||
|
.chatpapers-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||||
|
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-btn:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-primary {
|
||||||
|
background: #2563eb;
|
||||||
|
border-color: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-primary:hover:not(:disabled) {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 64px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||||
|
padding: 8px;
|
||||||
|
font: inherit;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-loading {
|
||||||
|
padding: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatpapers-pref-hint {
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 4px 0 10px;
|
||||||
|
max-width: 52em;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="context-fill" stroke="context-stroke">
|
||||||
|
<path d="M3.5 4.5h13a1.5 1.5 0 0 1 1.5 1.5v7a1.5 1.5 0 0 1-1.5 1.5H8l-3.2 2.4a.5.5 0 0 1-.8-.4V14.5H3.5A1.5 1.5 0 0 1 2 13V6a1.5 1.5 0 0 1 1.5-1.5z"/>
|
||||||
|
<circle cx="7" cy="9.5" r="1"/>
|
||||||
|
<circle cx="10" cy="9.5" r="1"/>
|
||||||
|
<circle cx="13" cy="9.5" r="1"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 392 B |
Binary file not shown.
|
After Width: | Height: | Size: 677 B |
Binary file not shown.
|
After Width: | Height: | Size: 836 B |
@@ -0,0 +1,127 @@
|
|||||||
|
<linkset>
|
||||||
|
<html:link rel="localization" href="__addonRef__-preferences.ftl" />
|
||||||
|
</linkset>
|
||||||
|
<groupbox
|
||||||
|
onload="Zotero.__addonInstance__.hooks.onPrefsEvent('load', { window })"
|
||||||
|
>
|
||||||
|
<label><html:h2 data-l10n-id="prefs-section-provider"></html:h2></label>
|
||||||
|
<html:p id="chatpapers-pref-hint" class="chatpapers-pref-hint"></html:p>
|
||||||
|
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label
|
||||||
|
for="chatpapers-pref-provider"
|
||||||
|
data-l10n-id="prefs-provider"
|
||||||
|
></html:label>
|
||||||
|
<html:select id="chatpapers-pref-provider" preference="provider">
|
||||||
|
</html:select>
|
||||||
|
<button id="chatpapers-pref-reset" data-l10n-id="prefs-reset"></button>
|
||||||
|
</hbox>
|
||||||
|
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label
|
||||||
|
for="chatpapers-pref-apiBaseUrl"
|
||||||
|
data-l10n-id="prefs-base-url"
|
||||||
|
></html:label>
|
||||||
|
<html:input
|
||||||
|
type="text"
|
||||||
|
id="chatpapers-pref-apiBaseUrl"
|
||||||
|
preference="apiBaseUrl"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label
|
||||||
|
for="zotero-prefpane-__addonRef__-apiKey"
|
||||||
|
data-l10n-id="prefs-api-key"
|
||||||
|
></html:label>
|
||||||
|
<html:input
|
||||||
|
type="password"
|
||||||
|
id="zotero-prefpane-__addonRef__-apiKey"
|
||||||
|
preference="apiKey"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label
|
||||||
|
for="chatpapers-pref-model"
|
||||||
|
data-l10n-id="prefs-model"
|
||||||
|
></html:label>
|
||||||
|
<html:input
|
||||||
|
type="text"
|
||||||
|
id="chatpapers-pref-model"
|
||||||
|
preference="model"
|
||||||
|
></html:input>
|
||||||
|
<button
|
||||||
|
id="chatpapers-pref-refresh-models"
|
||||||
|
data-l10n-id="prefs-refresh-models"
|
||||||
|
></button>
|
||||||
|
</hbox>
|
||||||
|
</groupbox>
|
||||||
|
|
||||||
|
<groupbox>
|
||||||
|
<label><html:h2 data-l10n-id="prefs-section-generation"></html:h2></label>
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label data-l10n-id="prefs-temperature"></html:label>
|
||||||
|
<html:input
|
||||||
|
type="text"
|
||||||
|
preference="temperature"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label data-l10n-id="prefs-max-tokens"></html:label>
|
||||||
|
<html:input
|
||||||
|
type="number"
|
||||||
|
preference="maxTokens"
|
||||||
|
min="256"
|
||||||
|
max="128000"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label data-l10n-id="prefs-timeout"></html:label>
|
||||||
|
<html:input
|
||||||
|
type="number"
|
||||||
|
preference="timeoutMs"
|
||||||
|
min="10000"
|
||||||
|
step="1000"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label data-l10n-id="prefs-max-context"></html:label>
|
||||||
|
<html:input
|
||||||
|
type="number"
|
||||||
|
preference="maxContextChars"
|
||||||
|
min="5000"
|
||||||
|
step="1000"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label data-l10n-id="prefs-answer-language"></html:label>
|
||||||
|
<html:input type="text" preference="answerLanguage"></html:input>
|
||||||
|
</hbox>
|
||||||
|
<checkbox preference="sendMetadata" data-l10n-id="prefs-send-metadata" />
|
||||||
|
</groupbox>
|
||||||
|
|
||||||
|
<groupbox>
|
||||||
|
<label><html:h2 data-l10n-id="prefs-section-openrouter"></html:h2></label>
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label data-l10n-id="prefs-openrouter-referer"></html:label>
|
||||||
|
<html:input
|
||||||
|
type="text"
|
||||||
|
preference="openrouterReferer"
|
||||||
|
></html:input>
|
||||||
|
</hbox>
|
||||||
|
<hbox align="center">
|
||||||
|
<html:label data-l10n-id="prefs-openrouter-title"></html:label>
|
||||||
|
<html:input type="text" preference="openrouterTitle"></html:input>
|
||||||
|
</hbox>
|
||||||
|
</groupbox>
|
||||||
|
|
||||||
|
<groupbox>
|
||||||
|
<label><html:h2 data-l10n-id="prefs-section-prompts"></html:h2></label>
|
||||||
|
<html:label data-l10n-id="prefs-system-prompt"></html:label>
|
||||||
|
<html:textarea preference="systemPrompt" rows="4"></html:textarea>
|
||||||
|
<html:label data-l10n-id="prefs-summary-prompt"></html:label>
|
||||||
|
<html:textarea preference="summaryPrompt" rows="4"></html:textarea>
|
||||||
|
</groupbox>
|
||||||
|
|
||||||
|
<html:p data-l10n-id="prefs-privacy" class="chatpapers-pref-hint"></html:p>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.makeItRed {
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
startup-begin = ChatPapers is loading
|
||||||
|
startup-finish = ChatPapers is ready
|
||||||
|
prefs-title = ChatPapers
|
||||||
|
|
||||||
|
item-section-chat-head = ChatPapers
|
||||||
|
item-section-chat-sidenav = Chat with paper
|
||||||
|
|
||||||
|
chat-loading = Loading ChatPapers…
|
||||||
|
chat-placeholder = Ask a question about this paper…
|
||||||
|
chat-send = Send
|
||||||
|
chat-stop = Stop
|
||||||
|
chat-summarize = Summarize
|
||||||
|
chat-add-selection = Add selection
|
||||||
|
chat-clear = Clear
|
||||||
|
chat-save-note = Save as note
|
||||||
|
chat-need-config = Configure a provider in Preferences first.
|
||||||
|
chat-thinking = Thinking…
|
||||||
|
chat-done = Done
|
||||||
|
chat-stopped = Stopped
|
||||||
|
chat-cleared = Chat cleared
|
||||||
|
chat-no-reply = No AI reply to save yet
|
||||||
|
chat-note-saved = Saved as note
|
||||||
|
chat-selection-added = Selection added to next message
|
||||||
|
chat-selection-empty = No text selected in the reader
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
itemmenu-label = ChatPapers: Summarize
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
prefs-title = ChatPapers
|
||||||
|
prefs-section-provider = AI Provider
|
||||||
|
prefs-section-generation = Generation
|
||||||
|
prefs-section-openrouter = OpenRouter (optional)
|
||||||
|
prefs-section-prompts = Prompts
|
||||||
|
prefs-provider = Provider
|
||||||
|
prefs-reset = Reset defaults
|
||||||
|
prefs-base-url = Base URL
|
||||||
|
prefs-api-key = API Key
|
||||||
|
prefs-model = Model
|
||||||
|
prefs-refresh-models = Refresh models
|
||||||
|
prefs-temperature = Temperature
|
||||||
|
prefs-max-tokens = Max tokens
|
||||||
|
prefs-timeout = Timeout (ms)
|
||||||
|
prefs-max-context = Max context chars
|
||||||
|
prefs-answer-language = Answer language
|
||||||
|
prefs-send-metadata =
|
||||||
|
.label = Include item metadata in prompts
|
||||||
|
prefs-openrouter-referer = HTTP-Referer
|
||||||
|
prefs-openrouter-title = X-Title
|
||||||
|
prefs-system-prompt = System prompt (leave empty for default)
|
||||||
|
prefs-summary-prompt = Summary prompt (leave empty for default)
|
||||||
|
prefs-hint-empty = Choose Ollama, LM Studio, OpenRouter, or another provider.
|
||||||
|
prefs-hint-local = Local provider: requests stay on this machine. Start the server first.
|
||||||
|
prefs-hint-openrouter = Cloud aggregator: paper text is sent to OpenRouter / upstream models.
|
||||||
|
prefs-hint-cloud = Cloud provider: paper text is sent to the selected API endpoint.
|
||||||
|
prefs-models-empty = No models returned.
|
||||||
|
prefs-models-pick = Enter a model id from the list (showing up to 30):
|
||||||
|
prefs-privacy = Privacy: ChatPapers only sends content when you click Send/Summarize, and only to the endpoint you configured. API keys are stored in local Zotero preferences.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
startup-begin = ChatPapers 加载中
|
||||||
|
startup-finish = ChatPapers 已就绪
|
||||||
|
prefs-title = ChatPapers
|
||||||
|
|
||||||
|
item-section-chat-head = ChatPapers
|
||||||
|
item-section-chat-sidenav = 论文对话
|
||||||
|
|
||||||
|
chat-loading = 正在加载 ChatPapers…
|
||||||
|
chat-placeholder = 就这篇论文提问…
|
||||||
|
chat-send = 发送
|
||||||
|
chat-stop = 停止
|
||||||
|
chat-summarize = 总结
|
||||||
|
chat-add-selection = 添加选区
|
||||||
|
chat-clear = 清空
|
||||||
|
chat-save-note = 保存为笔记
|
||||||
|
chat-need-config = 请先在偏好设置中配置 AI Provider
|
||||||
|
chat-thinking = 思考中…
|
||||||
|
chat-done = 完成
|
||||||
|
chat-stopped = 已停止
|
||||||
|
chat-cleared = 已清空对话
|
||||||
|
chat-no-reply = 还没有可保存的 AI 回复
|
||||||
|
chat-note-saved = 已保存为笔记
|
||||||
|
chat-selection-added = 已将选区加入下一条消息
|
||||||
|
chat-selection-empty = 阅读器中没有选中文本
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
itemmenu-label = ChatPapers:总结
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
prefs-title = ChatPapers
|
||||||
|
prefs-section-provider = AI 接入
|
||||||
|
prefs-section-generation = 生成参数
|
||||||
|
prefs-section-openrouter = OpenRouter(可选)
|
||||||
|
prefs-section-prompts = 提示词
|
||||||
|
prefs-provider = Provider
|
||||||
|
prefs-reset = 重置为预设默认
|
||||||
|
prefs-base-url = Base URL
|
||||||
|
prefs-api-key = API Key
|
||||||
|
prefs-model = 模型
|
||||||
|
prefs-refresh-models = 刷新模型列表
|
||||||
|
prefs-temperature = 温度
|
||||||
|
prefs-max-tokens = 最大 Token
|
||||||
|
prefs-timeout = 超时(毫秒)
|
||||||
|
prefs-max-context = 最大上下文字符
|
||||||
|
prefs-answer-language = 回答语言
|
||||||
|
prefs-send-metadata =
|
||||||
|
.label = 在提示词中附带条目元数据
|
||||||
|
prefs-openrouter-referer = HTTP-Referer
|
||||||
|
prefs-openrouter-title = X-Title
|
||||||
|
prefs-system-prompt = 系统提示词(留空使用默认)
|
||||||
|
prefs-summary-prompt = 总结提示词(留空使用默认)
|
||||||
|
prefs-hint-empty = 请选择 Ollama、LM Studio、OpenRouter 或其他 Provider。
|
||||||
|
prefs-hint-local = 本地 Provider:请求发往本机。请先启动对应服务。
|
||||||
|
prefs-hint-openrouter = 云端聚合:论文内容将发送到 OpenRouter / 上游模型。
|
||||||
|
prefs-hint-cloud = 云端 Provider:论文内容将发送到所选 API 端点。
|
||||||
|
prefs-models-empty = 未返回任何模型。
|
||||||
|
prefs-models-pick = 从列表中输入模型 ID(最多显示 30 个):
|
||||||
|
prefs-privacy = 隐私说明:仅在你点击发送/总结时,向你配置的端点发送内容。API Key 仅保存在本地 Zotero 偏好设置中。
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 2,
|
||||||
|
"name": "__addonName__",
|
||||||
|
"version": "__buildVersion__",
|
||||||
|
"description": "__description__",
|
||||||
|
"homepage_url": "__homepage__",
|
||||||
|
"author": "__author__",
|
||||||
|
"icons": {
|
||||||
|
"48": "content/icons/favicon@0.5x.png",
|
||||||
|
"96": "content/icons/favicon.png"
|
||||||
|
},
|
||||||
|
"applications": {
|
||||||
|
"zotero": {
|
||||||
|
"id": "__addonID__",
|
||||||
|
"update_url": "__updateURL__",
|
||||||
|
"strict_min_version": "9.0",
|
||||||
|
"strict_max_version": "9.*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
pref("provider", "");
|
||||||
|
pref("apiBaseUrl", "");
|
||||||
|
pref("apiKey", "");
|
||||||
|
pref("model", "");
|
||||||
|
pref("openrouterReferer", "https://github.com/chatpapers/chatpapers");
|
||||||
|
pref("openrouterTitle", "ChatPapers");
|
||||||
|
pref("temperature", "0.3");
|
||||||
|
pref("maxTokens", 2048);
|
||||||
|
pref("timeoutMs", 120000);
|
||||||
|
pref("maxContextChars", 80000);
|
||||||
|
pref("answerLanguage", "zh-CN");
|
||||||
|
pref("systemPrompt", "");
|
||||||
|
pref("summaryPrompt", "");
|
||||||
|
pref("sendMetadata", true);
|
||||||
|
pref("verboseLog", false);
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
# ChatPapers 开发文档
|
||||||
|
|
||||||
|
> 版本:v0.2(草案)
|
||||||
|
> 更新日期:2026-07-20
|
||||||
|
> 关联需求:[requirements.md](./requirements.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 技术选型
|
||||||
|
|
||||||
|
### 1.1 平台与形态
|
||||||
|
|
||||||
|
| 项 | 选择 | 理由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 宿主 | Zotero 9 Desktop | 需求明确;2026 年已有多款插件 bump 至 `9.*` |
|
||||||
|
| 扩展形态 | Bootstrapped Extension | Zotero 官方推荐路径(非 WebExtension 受限 API) |
|
||||||
|
| 清单 | `manifest.json` + `bootstrap.js` | Zotero 7+ 标准;9 需注意 bootstrap 导出方式 |
|
||||||
|
| 目标版本字段 | `strict_min_version: "9.0"`,`strict_max_version: "9.*"` | 与需求一致;若兼容 8 需额外回归 |
|
||||||
|
|
||||||
|
### 1.2 语言与工具链
|
||||||
|
|
||||||
|
| 项 | 选择 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 语言 | TypeScript | 类型安全;配合 `zotero-types` |
|
||||||
|
| 构建 | esbuild(经 `zotero-plugin-scaffold`) | 社区主流;热重载友好 |
|
||||||
|
| 脚手架 | [zotero-plugin-template](https://github.com/windingwind/zotero-plugin-template) + [zotero-plugin-scaffold](https://github.com/zotero-plugin-dev/zotero-plugin-scaffold) | 一键 build / start / release |
|
||||||
|
| UI Toolkit | `zotero-plugin-toolkit`(注意 Z9 API 迁移) | 菜单、快捷键、进度等;部分旧 API 已迁至原生 |
|
||||||
|
| 本地化 | Fluent(`.ftl`) | Zotero 未来方向;避免 DTD |
|
||||||
|
| 包管理 | pnpm 或 npm | 团队自定,文档示例用 npm |
|
||||||
|
|
||||||
|
### 1.3 Zotero 9 开发注意点
|
||||||
|
|
||||||
|
根据社区插件迁移经验,落地时需特别注意:
|
||||||
|
|
||||||
|
1. **`bootstrap.js` 导出方式**
|
||||||
|
Zotero 9 通过 `scope[method]` 解析生命周期函数。`bootstrap` 入口应产出**顶层** `function install/startup/shutdown/uninstall/...`,避免被 esbuild IIFE 包死导致钩子找不到。常见做法:对 `bootstrap.ts` **transpile 不 bundle**,或显式配置 esbuild 保留全局函数。
|
||||||
|
|
||||||
|
2. **废弃 `ChromeUtils.import`**
|
||||||
|
Firefox 128+ / Zotero 9 路径下优先使用 ESM / 官方推荐的模块加载方式,避免遗留 JSM import。
|
||||||
|
|
||||||
|
3. **UI 注册**
|
||||||
|
优先使用 Zotero 原生 PreferencePane、菜单注入点;Toolkit 大版本升级后核对 Breaking Changes。
|
||||||
|
|
||||||
|
4. **无重启启停**
|
||||||
|
`shutdown` / `onMainWindowUnload` 必须移除 DOM、监听、定时器、未完成的 fetch AbortController。
|
||||||
|
|
||||||
|
### 1.4 参考文档
|
||||||
|
|
||||||
|
- [Zotero 7 for Developers](https://www.zotero.org/support/dev/zotero_7_for_developers)(基础仍适用,9 在此之上演进)
|
||||||
|
- [Zotero Plugin Dev Docs](https://windingwind.github.io/doc-for-zotero-plugin-dev/)
|
||||||
|
- [Zotero Plugin Scaffold](https://zotero-plugin.dev/zotero-plugin-scaffold/)
|
||||||
|
- 官方示例:[make-it-red](https://github.com/zotero/make-it-red)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 工程结构(目标)
|
||||||
|
|
||||||
|
以 template 为起点,建议收敛为以下结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
chatpapers/
|
||||||
|
├── addon/ # 打包进 xpi 的静态资源
|
||||||
|
│ ├── bootstrap.js # 由 bootstrap.ts 转译生成(顶层函数)
|
||||||
|
│ ├── manifest.json
|
||||||
|
│ ├── prefs.js # 默认偏好
|
||||||
|
│ ├── locale/
|
||||||
|
│ │ ├── en-US/chatpapers.ftl
|
||||||
|
│ │ └── zh-CN/chatpapers.ftl
|
||||||
|
│ └── content/ # 图标、样式、偏好 XHTML 等
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts # 插件主入口(bundle)
|
||||||
|
│ ├── addon.ts # Addon 单例、生命周期编排
|
||||||
|
│ ├── hooks.ts # startup/shutdown/window hooks 业务
|
||||||
|
│ ├── modules/
|
||||||
|
│ │ ├── ui/
|
||||||
|
│ │ │ ├── readerPane.ts # PDF Reader 侧边栏注入
|
||||||
|
│ │ │ ├── chatView.ts # 对话 UI
|
||||||
|
│ │ │ └── prefs.ts # 偏好面板
|
||||||
|
│ │ ├── pdf/
|
||||||
|
│ │ │ ├── extractor.ts # 文本提取适配层
|
||||||
|
│ │ │ └── context.ts # 上下文裁剪 / 分块
|
||||||
|
│ │ ├── llm/
|
||||||
|
│ │ │ ├── client.ts # OpenAI 兼容客户端(stream / models)
|
||||||
|
│ │ │ ├── providers.ts # Ollama / LM Studio / OpenRouter 等预设
|
||||||
|
│ │ │ ├── prompts.ts # 系统提示与总结模板
|
||||||
|
│ │ │ └── types.ts
|
||||||
|
│ │ ├── storage/
|
||||||
|
│ │ │ ├── prefs.ts # Preference 读写封装
|
||||||
|
│ │ │ └── sessions.ts # 会话持久化
|
||||||
|
│ │ └── zotero/
|
||||||
|
│ │ ├── notes.ts # 创建/追加 Note
|
||||||
|
│ │ └── items.ts # 元数据读取
|
||||||
|
│ └── utils/
|
||||||
|
│ ├── logger.ts
|
||||||
|
│ └── abort.ts
|
||||||
|
├── docs/
|
||||||
|
│ ├── requirements.md
|
||||||
|
│ └── development.md
|
||||||
|
├── zotero-plugin.config.ts
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
└── readme.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**命名约定**
|
||||||
|
|
||||||
|
- 插件 ID:`yehongyu@njau.edu.cn`
|
||||||
|
- Pref 前缀:`extensions.chatpapers.*`
|
||||||
|
- Fluent 文件:`chatpapers.ftl`(避免与其它插件冲突)
|
||||||
|
- 日志前缀:`[ChatPapers]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 架构设计
|
||||||
|
|
||||||
|
### 3.1 分层
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ UI Layer(Reader Pane / Prefs / Menus) │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Application Services │
|
||||||
|
│ ChatSession · Summarize · SaveNote │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Adapters │
|
||||||
|
│ PdfExtractor · LlmClient · PrefStore │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Zotero Platform APIs │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
原则:
|
||||||
|
|
||||||
|
- UI 不直接 `fetch`;统一经 `LlmClient`
|
||||||
|
- PDF 提取细节隔离在 `PdfExtractor`,便于 Z9 API 变更时单点替换
|
||||||
|
- 所有偏好读写经 `PrefStore`,禁止散落魔法字符串
|
||||||
|
|
||||||
|
### 3.2 核心时序:提问
|
||||||
|
|
||||||
|
```text
|
||||||
|
User 发送
|
||||||
|
→ ChatView 收集:input + selection? + session history
|
||||||
|
→ ContextBuilder:metadata + pdfText(截断) + messages
|
||||||
|
→ LlmClient.chatStream(messages, AbortSignal)
|
||||||
|
→ 逐 chunk 更新 UI
|
||||||
|
→ 完成:写入 SessionStore
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 核心时序:保存笔记
|
||||||
|
|
||||||
|
```text
|
||||||
|
User 点击「保存为笔记」
|
||||||
|
→ 取当前 Assistant 消息(或总结全文)
|
||||||
|
→ Notes.createChildNote(item, { title, bodyHtmlOrMd })
|
||||||
|
→ Toast / 状态提示成功
|
||||||
|
```
|
||||||
|
|
||||||
|
Markdown → Zotero Note:优先转为简单 HTML(标题、段落、列表),保持可读。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 关键模块设计要点
|
||||||
|
|
||||||
|
### 4.1 Reader 侧边栏
|
||||||
|
|
||||||
|
- 在 Reader 窗口加载后注册侧边栏 Tab(具体 DOM/API 以 Zotero 9 Reader 为准,实现前用 DevTools 探测现网结构)
|
||||||
|
- Pane 与 `itemID` / `attachmentKey` 绑定;切换 PDF 时重置或切换会话
|
||||||
|
- 卸载时移除 Tab 与事件,防止重复注入
|
||||||
|
|
||||||
|
### 4.2 PDF 文本提取
|
||||||
|
|
||||||
|
实现顺序建议:
|
||||||
|
|
||||||
|
1. **优先**:使用 Zotero 已索引全文 / Reader 内可获取的文本 API(若可用)
|
||||||
|
2. **次选**:通过 PDF.js / 附件本地路径做页面级提取
|
||||||
|
3. **失败**:UI 提示「无文本层」,不发起无效大请求
|
||||||
|
|
||||||
|
`ContextBuilder` 策略(MVP):
|
||||||
|
|
||||||
|
- 若 `len(text) <= maxContextChars`:全文注入
|
||||||
|
- 否则:标题页元数据 + 前 N 字 + 后 M 字,或「先让模型做分段摘要」二选一(由需求待决策项决定)
|
||||||
|
|
||||||
|
### 4.3 LLM Client
|
||||||
|
|
||||||
|
MVP 以 **单一 OpenAI 兼容客户端** 覆盖 Ollama、LM Studio、OpenRouter 及各类云端;差异用 **Provider 预设表** 表达,而不是为每个厂商写一套协议。
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST {baseUrl}/chat/completions
|
||||||
|
Authorization: Bearer {apiKey} # 本地预设可省略
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"model": "...",
|
||||||
|
"messages": [...],
|
||||||
|
"stream": true,
|
||||||
|
"temperature": 0.3,
|
||||||
|
"max_tokens": 2048
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Provider 预设(与需求 §4.4 对齐)
|
||||||
|
|
||||||
|
| 预设 | 默认 Base URL | Key | 额外行为 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Ollama | `http://127.0.0.1:11434/v1` | 可空 | 可选 `GET /v1/models` 刷新列表;连接失败提示启动 Ollama |
|
||||||
|
| LM Studio | `http://127.0.0.1:1234/v1` | 可空 | 可选拉取 models;提示开启 Local Server |
|
||||||
|
| OpenRouter | `https://openrouter.ai/api/v1` | 必填 | 附加可选头 `HTTP-Referer`、`X-Title` |
|
||||||
|
| OpenAI / DeepSeek / 硅基流动 | 各官方 `/v1` | 必填 | 标准 Bearer |
|
||||||
|
| 自定义 | 用户输入 | 按需 | 无额外头 |
|
||||||
|
|
||||||
|
建议代码结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/modules/llm/
|
||||||
|
├── client.ts # 统一 chatStream / listModels
|
||||||
|
├── providers.ts # 预设常量:id → { baseUrl, needsKey, headers? }
|
||||||
|
├── url.ts # Base URL 规范化(补 /v1、去尾斜杠)
|
||||||
|
├── prompts.ts
|
||||||
|
└── types.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 解析 SSE `data: {...}`;处理 `[DONE]`
|
||||||
|
- `AbortController` 支持停止生成
|
||||||
|
- 统一错误类型:`AuthError` / `RateLimitError` / `NetworkError` / `LocalServerDown` / `ProviderError`
|
||||||
|
- **禁止**将 API Key 打进日志;verbose 模式下对 Header 脱敏
|
||||||
|
- Base URL 规范化:兼容 `http://127.0.0.1:11434` 与 `.../v1`
|
||||||
|
- 切换预设时:写入默认 Base URL,**不覆盖**用户已改过的自定义 URL(可用「重置为预设默认」按钮)
|
||||||
|
|
||||||
|
本地联调建议:
|
||||||
|
|
||||||
|
- Ollama:`ollama pull qwen2.5` → Base URL 默认 → model 填实际名
|
||||||
|
- LM Studio:加载模型并 Start Server → 端口若非 1234 则改 URL
|
||||||
|
- OpenRouter:填 Key + 模型如 `openai/gpt-4o-mini`
|
||||||
|
|
||||||
|
### 4.4 会话存储
|
||||||
|
|
||||||
|
MVP 可选方案:
|
||||||
|
|
||||||
|
| 方案 | 优点 | 缺点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A. Preferences 存 JSON | 实现快 | 容量与性能差 |
|
||||||
|
| B. 插件数据目录 JSON 文件 | 清晰、易清理 | 需处理路径与并发 |
|
||||||
|
| C. IndexedDB / SQLite | 可扩展 | 实现成本高 |
|
||||||
|
|
||||||
|
**建议 MVP 用 B**,路径形如:`{profile}/chatpapers/sessions/{itemKey}-{attachmentKey}.json`。
|
||||||
|
|
||||||
|
### 4.5 偏好面板
|
||||||
|
|
||||||
|
使用 `Zotero.PreferencePanes.register`(Z9 推荐原生 API)注册:
|
||||||
|
|
||||||
|
- **Provider**:下拉预设(Ollama / LM Studio / OpenRouter / … / 自定义)+「重置为预设默认」
|
||||||
|
- 基本:Base URL、Key(本地可空)、Model、「刷新模型列表」
|
||||||
|
- 生成:temperature、maxTokens、timeout、maxContextChars
|
||||||
|
- OpenRouter 高级:Referer / Title(可折叠)
|
||||||
|
- 提示词:system / summary 可编辑文本框
|
||||||
|
- 隐私说明:明确当前预设是本地还是云端
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 开发环境搭建
|
||||||
|
|
||||||
|
### 5.1 前置依赖
|
||||||
|
|
||||||
|
- Node.js 20+(LTS)
|
||||||
|
- 已安装 **Zotero 9** 桌面版
|
||||||
|
- Git
|
||||||
|
|
||||||
|
### 5.2 初始化(计划命令)
|
||||||
|
|
||||||
|
需求确认后,推荐从官方模板生成:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 示例:使用 zotero-plugin-template 克隆后改名
|
||||||
|
npx tiged windingwind/zotero-plugin-template chatpapers
|
||||||
|
cd chatpapers
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
随后修改:
|
||||||
|
|
||||||
|
- `package.json` → `addonName` / `addonID` / `addonRef`
|
||||||
|
- `addon/manifest.json` → `strict_*_version` 设为 Zotero 9
|
||||||
|
- 清理 demo 代码,按本文结构迁入模块
|
||||||
|
|
||||||
|
### 5.3 本地调试
|
||||||
|
|
||||||
|
典型脚本(以 scaffold 为准):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run start # 启动 Zotero + 加载未打包插件 + 热重载
|
||||||
|
npm run build # 产出 build/ 与 xpi
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
调试技巧:
|
||||||
|
|
||||||
|
1. 在 Zotero 中打开错误控制台(查看 `[ChatPapers]` 日志)
|
||||||
|
2. 对 Reader 窗口使用独立 DevTools(若可用)检查侧边栏 DOM
|
||||||
|
3. 用错误 Base URL 验证错误提示路径
|
||||||
|
4. 用本地 Ollama(`http://127.0.0.1:11434/v1`)验证兼容协议
|
||||||
|
|
||||||
|
### 5.4 环境变量
|
||||||
|
|
||||||
|
Scaffold 通常需要本机 Zotero 可执行文件路径,例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows 示例(按实际安装路径调整)
|
||||||
|
set ZOTERO_PLUGIN_ZOTERO_CMD=C:\Program Files\Zotero\zotero.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
具体变量名以当前 `zotero-plugin-scaffold` 文档为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 编码规范
|
||||||
|
|
||||||
|
1. **只改任务相关代码**;不顺手大重构模板无关文件
|
||||||
|
2. TypeScript `strict`;避免无必要 `any`
|
||||||
|
3. 异步:统一 `async/await`;外部请求必须可 Abort
|
||||||
|
4. UI 字符串全部走 Fluent,禁止硬编码中文/英文(开发期临时除外,合并前清掉)
|
||||||
|
5. 公共工具放 `utils/`;业务不互相深层耦合
|
||||||
|
6. 每个模块顶部保持短注释(「做什么」),避免长篇叙述
|
||||||
|
|
||||||
|
### 6.1 建议的 npm scripts
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"start": "zotero-plugin serve",
|
||||||
|
"build": "zotero-plugin build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"release": "zotero-plugin release"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(实际命令以模板生成结果为准。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 测试策略
|
||||||
|
|
||||||
|
| 层级 | 内容 | 时机 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 手工冒烟 | 安装、侧边栏、提问、总结、存笔记、禁用清理 | 每个里程碑 |
|
||||||
|
| 单元测试 | `ContextBuilder` 截断、URL 规范化、错误映射 | M2+ |
|
||||||
|
| 集成(可选) | scaffold + Mocha 在真实 Zotero 中跑 | M4 |
|
||||||
|
|
||||||
|
最低验收清单见需求文档里程碑 M1–M4。
|
||||||
|
|
||||||
|
**手工测试用例(MVP)**
|
||||||
|
|
||||||
|
1. 未配置 API Key → 发送时友好提示
|
||||||
|
2. 错误 Key → 401 可读错误
|
||||||
|
3. 正常 PDF → 流式回复(分别用 Ollama / OpenRouter 各测一次)
|
||||||
|
4. 选中文本提问 → 上下文包含选区
|
||||||
|
5. 超长 PDF → 截断提示仍可回答
|
||||||
|
6. 无文本 PDF → 明确失败原因
|
||||||
|
7. Ollama 未启动 → `LocalServerDown` 友好提示
|
||||||
|
8. 保存笔记 → 条目下可见 Note
|
||||||
|
9. 禁用插件 → 侧边栏与菜单消失,无报错
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 发布流程
|
||||||
|
|
||||||
|
1. 更新版本号(`package.json` / `manifest.json`)
|
||||||
|
2. `npm run build` 生成 `.xpi`
|
||||||
|
3. GitHub Release 上传 `.xpi`
|
||||||
|
4. 配置 `update_url` 指向 `updates.json`(Scaffold 可生成)
|
||||||
|
5. 在干净 Zotero 9 Profile 中验证安装与更新
|
||||||
|
|
||||||
|
版本号遵循 SemVer:`MAJOR.MINOR.PATCH`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 实施计划(与需求里程碑对齐)
|
||||||
|
|
||||||
|
| 阶段 | 工程任务 | 预估 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| M0 | 确认需求待决策项;冻结插件 ID / 协议 | 0.5d |
|
||||||
|
| M1 | 从 template 初始化;偏好页;Reader 占位 Pane | 2–3d |
|
||||||
|
| M2 | PDF 提取 + 非流式/流式 Client + 单轮对话 | 3–5d |
|
||||||
|
| M3 | 多轮 UI、总结模板、存 Note、Abort | 3–4d |
|
||||||
|
| M4 | 会话持久化、i18n、日志脱敏、打包与 README 使用说明 | 2–3d |
|
||||||
|
|
||||||
|
以上为单人全职粗估,随 Reader API 熟悉度波动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 目录外约定
|
||||||
|
|
||||||
|
- 密钥与真实 API Key **不得**提交仓库;提供 `.env.example`(若需要)仅含路径类配置
|
||||||
|
- `docs/` 变更随功能演进;重大行为变更先更新需求再改代码
|
||||||
|
- Issue / PR 标题建议前缀:`feat:` / `fix:` / `docs:` / `chore:`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 下一步(工程开工清单)
|
||||||
|
|
||||||
|
需求方确认 [requirements.md §10 待决策事项](./requirements.md#10-待决策事项需产品确认) 后,按序执行:
|
||||||
|
|
||||||
|
1. 从 `zotero-plugin-template` 初始化仓库代码
|
||||||
|
2. 改名与 Zotero 9 manifest
|
||||||
|
3. 落地 `PrefStore` + 空 Reader Pane
|
||||||
|
4. 实现 `LlmClient` 与最小 Chat UI
|
||||||
|
5. 接通 PDF 文本与「保存为笔记」
|
||||||
|
|
||||||
|
如需,可在确认待决策项后直接进入 M1 脚手架初始化。
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
# ChatPapers 需求文档
|
||||||
|
|
||||||
|
> 版本:v0.2(草案)
|
||||||
|
> 更新日期:2026-07-20
|
||||||
|
> 状态:待确认(Provider 预设范围已按产品意见更新)
|
||||||
|
> 关联:基于仓库根目录初步需求整理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
### 1.1 背景
|
||||||
|
|
||||||
|
研究人员日常在 Zotero 中管理大量 PDF 文献。现有 AI 阅读工具多为独立网页或外部应用,存在以下摩擦:
|
||||||
|
|
||||||
|
- 需反复上传 PDF,上下文与 Zotero 条目割裂
|
||||||
|
- 对话结果难以回流到文献库(笔记、标签、批注)
|
||||||
|
- 工作流在「文献管理器 ↔ AI 工具」之间频繁切换
|
||||||
|
|
||||||
|
### 1.2 产品目标
|
||||||
|
|
||||||
|
在 **Zotero 9** 内提供原生体验的 AI 论文助手,使用户能够:
|
||||||
|
|
||||||
|
1. **对着当前 PDF 提问**(选中文本 / 全文上下文)
|
||||||
|
2. **一键生成结构化总结**(摘要、贡献、方法、局限等)
|
||||||
|
3. **把有价值的 AI 输出沉淀为 Zotero Note**,与条目长期绑定
|
||||||
|
|
||||||
|
### 1.3 成功标准(MVP)
|
||||||
|
|
||||||
|
| 指标 | 目标 |
|
||||||
|
| --- | --- |
|
||||||
|
| 安装 | 用户可通过 `.xpi` 安装到 Zotero 9,启用后无需重启即可使用 |
|
||||||
|
| 可用性 | 打开带 PDF 附件的条目后,≤ 3 次点击进入对话 |
|
||||||
|
| 核心路径 | 「打开 PDF → 提问 → 得到带引用页码的回答 → 存为笔记」可完整走通 |
|
||||||
|
| 配置 | 一键切换常用 Provider 预设(含 Ollama / LM Studio / OpenRouter),并可自定义端点;错误提示可读、可排查 |
|
||||||
|
| 隐私 | API Key 仅存本地;本地 Provider 默认不外传;云端仅向用户所选端点发送 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 用户与场景
|
||||||
|
|
||||||
|
### 2.1 目标用户
|
||||||
|
|
||||||
|
| 角色 | 描述 | 核心诉求 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 研究生 / 科研人员 | 高频阅读、需要快速把握论文要点 | 总结、方法/实验追问 |
|
||||||
|
| 文献综述作者 | 批量阅读、需要可比的结构化输出 | 固定模板总结、笔记归档 |
|
||||||
|
| 隐私敏感用户 | 不愿使用封闭云端上传产品 | 自备 API / 本地模型兼容端点 |
|
||||||
|
|
||||||
|
### 2.2 核心用户故事
|
||||||
|
|
||||||
|
1. **作为**科研人员,**我希望**在阅读 PDF 时直接向 AI 提问选中段落,**以便**快速理解术语与论证。
|
||||||
|
2. **作为**科研人员,**我希望**一键生成论文结构化摘要并写入笔记,**以便**后续检索与写作引用。
|
||||||
|
3. **作为**用户,**我希望**使用自己的 API Key 与自定义 Base URL,**以便**接入公司代理、国产大模型或任意 OpenAI 兼容服务。
|
||||||
|
4. **作为**用户,**我希望**一键选择 Ollama / LM Studio / OpenRouter 等常用接入,**以便**本地离线或聚合云端模型都能立刻用上。
|
||||||
|
5. **作为**用户,**我希望**对话历史按「条目 / PDF」隔离保存,**以便**下次打开同一篇论文可续聊。
|
||||||
|
6. **作为**用户,**我希望**回答尽量标注依据页码或原文片段,**以便**回读核验,降低幻觉风险。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 范围定义
|
||||||
|
|
||||||
|
### 3.1 范围内(In Scope)— MVP
|
||||||
|
|
||||||
|
- Zotero 9 Bootstrapped 插件安装与生命周期管理
|
||||||
|
- PDF Reader 侧边栏 Chat UI(多轮对话)
|
||||||
|
- 当前打开 PDF 的文本提取与上下文组装
|
||||||
|
- 选中文本作为「引用上下文」提问
|
||||||
|
- 一键总结(可配置 Prompt 模板)
|
||||||
|
- OpenAI Chat Completions 兼容协议(含流式输出)作为统一接入层
|
||||||
|
- **常用 Provider 预设(MVP 必做)**:Ollama、LM Studio、OpenRouter,以及 OpenAI / DeepSeek 等(见 §4.4)
|
||||||
|
- 偏好设置页:Provider 预设、API Key、Base URL、Model、温度、最大 Token、语言、Prompt;本地端点可「探测可用模型」
|
||||||
|
- 将选中回复 / 总结保存为当前 Item 的子 Note
|
||||||
|
- 中英双语界面(Fluent:`zh-CN` / `en-US`)
|
||||||
|
|
||||||
|
### 3.2 范围内 — 后续版本(Post-MVP)
|
||||||
|
|
||||||
|
| 阶段 | 能力 |
|
||||||
|
| --- | --- |
|
||||||
|
| v0.2 | 多 PDF / 多条目联合对话;从主窗口条目菜单发起 Chat |
|
||||||
|
| v0.3 | 批注(Highlight)联动:基于高亮内容追问;回复一键生成高亮建议 |
|
||||||
|
| v0.4 | RAG:长文分块 + 向量检索(可插拔 Embedding Provider) |
|
||||||
|
| v0.5 | 本地服务健康检查增强、模型拉取引导、离线模式全局提示 |
|
||||||
|
| v0.6 | 批量总结 Collection;导出 Markdown / 结构化 JSON |
|
||||||
|
| 探索 | MCP Server;Anthropic / Gemini 原生协议;图表/公式 OCR |
|
||||||
|
|
||||||
|
### 3.3 范围外(Out of Scope)— 当前明确不做
|
||||||
|
|
||||||
|
- 不替代 Zotero 原生 PDF 阅读器
|
||||||
|
- 不做独立桌面端或 Web SaaS
|
||||||
|
- 不自建账号体系与云端同步对话(对话仅本地)
|
||||||
|
- 不提供付费模型转售 / 内置官方 Key
|
||||||
|
- 不保证扫描版纯图片 PDF 的 OCR(可作为后续可选引擎)
|
||||||
|
- 不兼容 Zotero 6 / 7 / 8(以 Zotero 9 为主;若成本低可放宽 `strict_min_version`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 功能需求
|
||||||
|
|
||||||
|
### 4.1 安装与入口
|
||||||
|
|
||||||
|
| ID | 需求 | 优先级 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| F-01 | 提供可安装的 `.xpi`;`manifest.json` 声明兼容 Zotero 9 | P0 |
|
||||||
|
| F-02 | 插件启用/禁用无需重启 Zotero;`shutdown` 清理 UI 与监听 | P0 |
|
||||||
|
| F-03 | PDF Reader 右侧边栏提供 ChatPapers Tab / Pane | P0 |
|
||||||
|
| F-04 | 主窗口「工具 → ChatPapers 设置」或偏好面板入口 | P0 |
|
||||||
|
| F-05 | 条目右键菜单:「用 ChatPapers 总结」(Post-MVP 可升为 P0) | P1 |
|
||||||
|
|
||||||
|
### 4.2 上下文与 PDF
|
||||||
|
|
||||||
|
| ID | 需求 | 优先级 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| F-10 | 识别当前 Reader 打开的 PDF Attachment,绑定 Item Key | P0 |
|
||||||
|
| F-11 | 提取 PDF 纯文本(优先 Zotero / PDF.js 已有全文能力) | P0 |
|
||||||
|
| F-12 | 超长文本按策略截断或分块(见 5.2);UI 提示「已截断」 | P0 |
|
||||||
|
| F-13 | 支持将 Reader 中选中文本注入本轮用户消息 | P0 |
|
||||||
|
| F-14 | 可选:附带条目标题、作者、年份、DOI、摘要到 System Prompt | P1 |
|
||||||
|
| F-15 | 扫描版 / 无文本层 PDF:明确提示无法提取,引导用户处理 | P1 |
|
||||||
|
|
||||||
|
### 4.3 对话与总结
|
||||||
|
|
||||||
|
| ID | 需求 | 优先级 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| F-20 | 多轮对话;支持停止生成(Abort) | P0 |
|
||||||
|
| F-21 | 流式展示 Token(SSE / stream) | P0 |
|
||||||
|
| F-22 | 内置「总结」动作:使用可编辑模板生成结构化摘要 | P0 |
|
||||||
|
| F-23 | 预设快捷指令:术语解释、方法复述、局限与质疑、相关工作 | P1 |
|
||||||
|
| F-24 | Markdown 渲染回复(标题、列表、代码块、引用) | P0 |
|
||||||
|
| F-25 | 复制回复;清空当前会话;新建会话 | P0 |
|
||||||
|
| F-26 | 会话按 `itemKey + attachmentKey` 本地持久化 | P1 |
|
||||||
|
| F-27 | 回答尽量要求模型标注页码 / 引用片段(Prompt 约束 + UI 展示) | P1 |
|
||||||
|
|
||||||
|
### 4.4 AI Provider
|
||||||
|
|
||||||
|
**设计原则**:不绑定单一厂商。MVP 以 **OpenAI Chat Completions 兼容协议** 为统一客户端;各服务通过「预设 + 可编辑 Base URL / Model / Key」接入。Ollama、LM Studio、OpenRouter 均为 **P0**。
|
||||||
|
|
||||||
|
| ID | 需求 | 优先级 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| F-30 | 统一客户端支持 OpenAI 兼容 `POST .../chat/completions`(含 stream) | P0 |
|
||||||
|
| F-31 | 可配置 Base URL、API Key、Model、Timeout;切换预设时自动填充默认值且允许覆写 | P0 |
|
||||||
|
| F-32 | 内置 Provider 预设表(见下方);含 **Ollama / LM Studio / OpenRouter** 及常用云端 | P0 |
|
||||||
|
| F-33 | 请求失败:展示 HTTP 状态、可理解错误与重试;本地服务未启动时给出启动提示 | P0 |
|
||||||
|
| F-34 | 本地 Provider(Ollama / LM Studio):可选「刷新模型列表」(调用兼容 `/models` 或等价接口) | P1 |
|
||||||
|
| F-35 | 本地 Provider:API Key 可为空;UI 不强制填写 | P0 |
|
||||||
|
| F-36 | OpenRouter:支持可选 `HTTP-Referer` / `X-Title` 等推荐头(可配置) | P1 |
|
||||||
|
| F-37 | 可选记录最近一次请求的 Token 用量(若 API 返回) | P2 |
|
||||||
|
| F-38 | Anthropic Messages / Gemini 原生协议(非兼容层) | P2(Post-MVP) |
|
||||||
|
|
||||||
|
#### MVP 内置 Provider 预设
|
||||||
|
|
||||||
|
| 预设 ID | 显示名 | 默认 Base URL | API Key | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `ollama` | Ollama | `http://127.0.0.1:11434/v1` | 可空 | 本机本地模型;需用户先 `ollama serve` |
|
||||||
|
| `lmstudio` | LM Studio | `http://127.0.0.1:1234/v1` | 可空 | 本机 Local Server;端口以用户设置为准 |
|
||||||
|
| `openrouter` | OpenRouter | `https://openrouter.ai/api/v1` | 必填 | 聚合多模型;模型名形如 `vendor/model` |
|
||||||
|
| `openai` | OpenAI | `https://api.openai.com/v1` | 必填 | 官方 API |
|
||||||
|
| `deepseek` | DeepSeek | `https://api.deepseek.com/v1` | 必填 | 常用国产云端 |
|
||||||
|
| `siliconflow` | 硅基流动 | `https://api.siliconflow.cn/v1` | 必填 | 常用聚合 |
|
||||||
|
| `custom` | 自定义 | 用户填写 | 按服务要求 | 任意 OpenAI 兼容网关 / 代理 |
|
||||||
|
|
||||||
|
> 说明:上述默认端口/路径以各产品当前常见配置为准;设置页需提示「若改过端口请手动改 Base URL」。
|
||||||
|
|
||||||
|
### 4.5 与 Zotero 数据联动
|
||||||
|
|
||||||
|
| ID | 需求 | 优先级 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| F-40 | 「保存为笔记」:创建子 Note,标题可配置(默认含日期) | P0 |
|
||||||
|
| F-41 | 可选将总结追加到已有 Note,而非总是新建 | P1 |
|
||||||
|
| F-42 | 不自动修改条目元数据(标题、标签等),除非用户显式操作 | P0 |
|
||||||
|
| F-43 | (后续)根据 AI 建议创建 Highlight Annotation | P2 |
|
||||||
|
|
||||||
|
### 4.6 设置与安全
|
||||||
|
|
||||||
|
| ID | 需求 | 优先级 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| F-50 | 偏好面板完整配置项(见附录 A) | P0 |
|
||||||
|
| F-51 | API Key 使用 Zotero Preferences / 安全存储惯例,不明文写入日志 | P0 |
|
||||||
|
| F-52 | 默认不上传全文以外的库数据;设置页说明「将发送哪些内容」 | P0 |
|
||||||
|
| F-53 | 「发送前预览上下文」开关(调试用) | P2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 非功能需求
|
||||||
|
|
||||||
|
### 5.1 兼容性
|
||||||
|
|
||||||
|
- 目标:Zotero 9(发布于 2026 年)
|
||||||
|
- `applications.zotero.strict_max_version`:`9.*`
|
||||||
|
- `strict_min_version`:建议 `9.0`(若需覆盖 8.x,需单独验证后下调)
|
||||||
|
- OS:Windows / macOS / Linux(随 Zotero 官方桌面端)
|
||||||
|
|
||||||
|
### 5.2 性能与限制
|
||||||
|
|
||||||
|
| 项 | 建议默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 单次上下文最大字符 | 80,000(可配置) | 超出则截断或分块摘要后再问 |
|
||||||
|
| 流式首字延迟 | 受模型与网络影响;UI 需有 loading | — |
|
||||||
|
| 并发请求 | 单会话同时仅 1 个进行中请求 | 新请求可取消旧请求 |
|
||||||
|
| 会话历史条数 | 默认保留最近 50 轮 | 可清理 |
|
||||||
|
|
||||||
|
### 5.3 可靠性
|
||||||
|
|
||||||
|
- 网络超时可配置(默认 120s)
|
||||||
|
- Provider 4xx/5xx 不导致插件崩溃
|
||||||
|
- PDF 提取失败有明确降级提示
|
||||||
|
|
||||||
|
### 5.4 可维护性
|
||||||
|
|
||||||
|
- TypeScript 源码 + ESLint
|
||||||
|
- 模块边界清晰:UI / PDF / LLM / Storage / Prefs
|
||||||
|
- 关键日志带命名空间前缀,可开关 verbose
|
||||||
|
|
||||||
|
### 5.5 国际化与无障碍
|
||||||
|
|
||||||
|
- Fluent 本地化,至少中英
|
||||||
|
- 侧边栏控件具备可访问名称;支持键盘发送(Enter / Ctrl+Enter 策略可配置)
|
||||||
|
|
||||||
|
### 5.6 许可
|
||||||
|
|
||||||
|
- 建议开源协议:AGPL-3.0 或 MIT(待决策)
|
||||||
|
- 第三方依赖遵循各自许可,在 NOTICE 中声明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 信息架构与交互草案
|
||||||
|
|
||||||
|
### 6.1 主要界面
|
||||||
|
|
||||||
|
1. **PDF Reader 侧边栏 — Chat**
|
||||||
|
- 顶部:当前论文短标题、模型名、设置入口
|
||||||
|
- 中部:消息列表(User / Assistant)
|
||||||
|
- 底部:输入框、附加选区按钮、发送、停止、快捷动作(总结)
|
||||||
|
2. **偏好面板 — ChatPapers**
|
||||||
|
- Provider、凭据、模型、生成参数、Prompt 模板、隐私说明
|
||||||
|
3. **(可选)独立对话窗口**:侧边栏空间不足时弹出(P2)
|
||||||
|
|
||||||
|
### 6.2 关键流程(MVP 主路径)
|
||||||
|
|
||||||
|
```text
|
||||||
|
安装插件 → 配置 API → 打开 PDF
|
||||||
|
→ 打开 ChatPapers 面板
|
||||||
|
→(可选)选中文本「添加到上下文」
|
||||||
|
→ 输入问题 / 点击「总结」
|
||||||
|
→ 流式显示回答
|
||||||
|
→「保存为笔记」→ 条目下新增 Note
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 数据与隐私
|
||||||
|
|
||||||
|
### 7.1 本地数据
|
||||||
|
|
||||||
|
| 数据 | 存储位置 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Provider 预设、API Key、模型等 | Zotero Preferences | 前缀如 `extensions.chatpapers.*` |
|
||||||
|
| 会话历史 | 插件本地存储(prefs JSON / SQLite / 文件,待技术选型) | 按条目隔离 |
|
||||||
|
| 笔记内容 | Zotero 数据库(用户主动保存时) | 标准 Note 条目 |
|
||||||
|
|
||||||
|
### 7.2 外发数据
|
||||||
|
|
||||||
|
仅在用户点击发送 / 总结时,向**用户当前所选** API 端点发送:
|
||||||
|
|
||||||
|
- System Prompt(含模板与元数据,按设置)
|
||||||
|
- 组装后的 PDF 文本片段 / 选区
|
||||||
|
- 对话历史(按窗口策略)
|
||||||
|
|
||||||
|
行为约定:
|
||||||
|
|
||||||
|
- 选择 **Ollama / LM Studio** 时,请求发往本机,不经过第三方云(除非用户改成远程 Base URL)
|
||||||
|
- 选择 **OpenRouter / OpenAI 等云端** 时,内容发往对应服务商,设置页需显著提示
|
||||||
|
- **不默认发送**:整个 Zotero 库、未打开条目、其他附件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 里程碑
|
||||||
|
|
||||||
|
| 里程碑 | 交付物 | 验收要点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| M0 | 需求/开发文档定稿 | 本文档与开发文档评审通过 |
|
||||||
|
| M1 | 插件骨架可安装 | Zotero 9 加载、侧边栏占位、偏好页 |
|
||||||
|
| M2 | PDF 文本 + 单轮问答 | 能对当前 PDF 提问并显示回复 |
|
||||||
|
| M3 | 流式 + 总结 + 存笔记 | MVP 主路径闭环 |
|
||||||
|
| M4 | 会话持久化 + i18n + 打包发布 | 可分发 `.xpi` 与更新清单 |
|
||||||
|
| M5+ | Post-MVP 能力 | 按优先级排期 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 风险与依赖
|
||||||
|
|
||||||
|
| 风险 | 影响 | 缓解 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Zotero 9 Reader / PDF API 变动 | 文本提取或侧边栏注入失败 | 跟进官方 changelog;抽象 Reader Adapter |
|
||||||
|
| 超长论文超出模型上下文 | 回答质量下降 | 分块摘要、RAG、截断提示 |
|
||||||
|
| 扫描版 PDF 无文本 | 核心功能不可用 | 明确提示;后续可选 OCR |
|
||||||
|
| API 费用与密钥泄露 | 用户损失 | 本地存储、日志脱敏、用量提示 |
|
||||||
|
| 竞品功能重叠(PapersGPT 等) | 差异化不足 | 聚焦「轻量、可自托管、笔记回流」 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 待决策事项(需产品确认)
|
||||||
|
|
||||||
|
请在开工前确认以下选项:
|
||||||
|
|
||||||
|
1. **开源协议**:AGPL-3.0 vs MIT?
|
||||||
|
2. **是否兼容 Zotero 8**:仅 9,还是 `8.*`–`9.*`?
|
||||||
|
3. **MVP 是否必须流式输出**:可先非流式降低复杂度?
|
||||||
|
4. **长文策略**:仅截断 / 先分块摘要再问答 / 直接上简单 RAG?
|
||||||
|
5. **默认 Provider 与默认模型**:建议默认 `openrouter` 或 `ollama`?各预设的默认 model 字符串?(当前草案:无强制默认云,首次打开引导选择)
|
||||||
|
6. **品牌名是否确定为 ChatPapers**(插件 ID、命名空间将据此固定)?
|
||||||
|
7. **对话历史是否需要跨设备同步**(当前默认:不同步)?
|
||||||
|
8. ~~Ollama / LM Studio / OpenRouter 是否进 MVP~~ → **已确认进 MVP(P0)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 A. 偏好项清单(草案)
|
||||||
|
|
||||||
|
| Preference Key | 类型 | 默认 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `extensions.chatpapers.provider` | string | `""`(首次引导) | 预设 ID:`ollama` / `lmstudio` / `openrouter` / … / `custom` |
|
||||||
|
| `extensions.chatpapers.apiBaseUrl` | string | 随预设 | API 根路径(可覆写) |
|
||||||
|
| `extensions.chatpapers.apiKey` | string | `""` | API Key;本地预设可空 |
|
||||||
|
| `extensions.chatpapers.model` | string | 随预设 | 模型名 |
|
||||||
|
| `extensions.chatpapers.openrouterReferer` | string | `https://github.com/chatpapers` | OpenRouter 可选 Referer |
|
||||||
|
| `extensions.chatpapers.openrouterTitle` | string | `ChatPapers` | OpenRouter 可选 X-Title |
|
||||||
|
| `extensions.chatpapers.temperature` | number | `0.3` | 温度 |
|
||||||
|
| `extensions.chatpapers.maxTokens` | number | `2048` | 最大生成 Token |
|
||||||
|
| `extensions.chatpapers.timeoutMs` | number | `120000` | 超时(本地大模型可调大) |
|
||||||
|
| `extensions.chatpapers.maxContextChars` | number | `80000` | 上下文上限 |
|
||||||
|
| `extensions.chatpapers.answerLanguage` | string | `zh-CN` | 回答语言偏好 |
|
||||||
|
| `extensions.chatpapers.systemPrompt` | string | (内置模板) | 可覆盖 |
|
||||||
|
| `extensions.chatpapers.summaryPrompt` | string | (内置模板) | 总结模板 |
|
||||||
|
| `extensions.chatpapers.sendMetadata` | bool | `true` | 是否附带条目元数据 |
|
||||||
|
| `extensions.chatpapers.verboseLog` | bool | `false` | 调试日志 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 B. 竞品参考(仅作范围对照,非抄袭目标)
|
||||||
|
|
||||||
|
- PapersGPT、Zotero-easyGPT、llm-for-zotero:侧边栏对话 / 多模型
|
||||||
|
- Zotero MCP 类插件:对外暴露库能力给 Cursor/Claude
|
||||||
|
|
||||||
|
ChatPapers MVP:单 PDF 对话 + 总结 + 笔记回流 + **多 Provider 预设(Ollama / LM Studio / OpenRouter 等)**。
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// @ts-check Let TS check this config file
|
||||||
|
|
||||||
|
import zotero from "@zotero-plugin/eslint-config";
|
||||||
|
|
||||||
|
export default zotero({
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ["**/*.ts"],
|
||||||
|
rules: {
|
||||||
|
// We disable this rule here because the template
|
||||||
|
// contains some unused examples and variables
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
Generated
+3774
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "chatpapers",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "AI paper reading and summarization plugin for Zotero 9",
|
||||||
|
"config": {
|
||||||
|
"addonName": "ChatPapers",
|
||||||
|
"addonID": "chatpapers@local",
|
||||||
|
"addonRef": "chatpapers",
|
||||||
|
"addonInstance": "ChatPapers",
|
||||||
|
"prefsPrefix": "extensions.zotero.chatpapers"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/chatpapers/chatpapers.git"
|
||||||
|
},
|
||||||
|
"author": "ChatPapers",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/chatpapers/chatpapers/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/chatpapers/chatpapers#readme",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"scripts": {
|
||||||
|
"start": "zotero-plugin serve",
|
||||||
|
"build": "zotero-plugin build && tsc --noEmit",
|
||||||
|
"lint:check": "prettier --check . && eslint .",
|
||||||
|
"lint:fix": "prettier --write . && eslint . --fix",
|
||||||
|
"release": "zotero-plugin release",
|
||||||
|
"update-deps": "npm update --save"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zotero-plugin-toolkit": "^5.1.0-beta.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.0",
|
||||||
|
"@zotero-plugin/eslint-config": "^0.6.7",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
|
"prettier": "^3.7.4",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"zotero-plugin-scaffold": "^0.8.2",
|
||||||
|
"zotero-types": "^4.1.0-beta.4"
|
||||||
|
},
|
||||||
|
"prettier": {
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.xhtml"],
|
||||||
|
"options": {
|
||||||
|
"htmlWhitespaceSensitivity": "css"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# ChatPapers
|
||||||
|
|
||||||
|
面向 **Zotero 9** 的 AI 论文阅读与总结插件:在 Zotero 内对 PDF 文献对话、摘要,并把结果保存为笔记。
|
||||||
|
|
||||||
|
## 功能(MVP)
|
||||||
|
|
||||||
|
- PDF / 条目侧栏 **ChatPapers** 对话面板
|
||||||
|
- 一键总结、添加选区、流式回复、停止生成
|
||||||
|
- 保存为 Zotero 子笔记
|
||||||
|
- Provider 预设:**Ollama / LM Studio / OpenRouter / OpenAI / DeepSeek / 硅基流动 / 自定义**
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
| 文档 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| [需求文档](docs/requirements.md) | 产品范围与验收 |
|
||||||
|
| [开发文档](docs/development.md) | 技术架构与开发说明 |
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build # 产出 .scaffold/build/*.xpi
|
||||||
|
npm run start # 需本机已安装 Zotero 9,并配置 .env(见 .env.example)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
1. `npm run build`
|
||||||
|
2. 打开 Zotero → 工具 → 插件 → 从文件安装
|
||||||
|
3. 选择 `.scaffold/build/chatpapers.xpi`(文件名以构建产物为准)
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
1. 偏好设置 → ChatPapers:选择 Provider(如 Ollama)并填写模型
|
||||||
|
2. 打开 PDF 或选中条目,在条目面板侧栏进入 **ChatPapers**
|
||||||
|
3. 提问 / 总结 → 可将回复「保存为笔记」
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
AGPL-3.0-or-later(基于 [zotero-plugin-template](https://github.com/windingwind/zotero-plugin-template))
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { config } from "../package.json";
|
||||||
|
import hooks from "./hooks";
|
||||||
|
import { createZToolkit } from "./utils/ztoolkit";
|
||||||
|
|
||||||
|
class Addon {
|
||||||
|
public data: {
|
||||||
|
alive: boolean;
|
||||||
|
config: typeof config;
|
||||||
|
env: "development" | "production";
|
||||||
|
initialized?: boolean;
|
||||||
|
ztoolkit: ZToolkit;
|
||||||
|
locale?: {
|
||||||
|
current: any;
|
||||||
|
};
|
||||||
|
prefs?: {
|
||||||
|
window: Window;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
public hooks: typeof hooks;
|
||||||
|
public api: object;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.data = {
|
||||||
|
alive: true,
|
||||||
|
config,
|
||||||
|
env: __env__,
|
||||||
|
initialized: false,
|
||||||
|
ztoolkit: createZToolkit(),
|
||||||
|
};
|
||||||
|
this.hooks = hooks;
|
||||||
|
this.api = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Addon;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { initLocale } from "./utils/locale";
|
||||||
|
import { createZToolkit } from "./utils/ztoolkit";
|
||||||
|
import { registerChatPane, registerPrefs } from "./modules/ui/readerPane";
|
||||||
|
import { registerPrefsScripts } from "./modules/ui/prefs";
|
||||||
|
|
||||||
|
async function onStartup() {
|
||||||
|
await Promise.all([
|
||||||
|
Zotero.initializationPromise,
|
||||||
|
Zotero.unlockPromise,
|
||||||
|
Zotero.uiReadyPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
initLocale();
|
||||||
|
registerPrefs();
|
||||||
|
registerChatPane();
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Zotero.getMainWindows().map((win) => onMainWindowLoad(win)),
|
||||||
|
);
|
||||||
|
|
||||||
|
addon.data.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMainWindowLoad(win: _ZoteroTypes.MainWindow): Promise<void> {
|
||||||
|
addon.data.ztoolkit = createZToolkit();
|
||||||
|
win.MozXULElement.insertFTLIfNeeded(
|
||||||
|
`${addon.data.config.addonRef}-mainWindow.ftl`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMainWindowUnload(_win: Window): Promise<void> {
|
||||||
|
ztoolkit.unregisterAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onShutdown(): void {
|
||||||
|
ztoolkit.unregisterAll();
|
||||||
|
addon.data.alive = false;
|
||||||
|
// @ts-expect-error - Plugin instance is not typed
|
||||||
|
delete Zotero[addon.data.config.addonInstance];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPrefsEvent(type: string, data: { [key: string]: any }) {
|
||||||
|
switch (type) {
|
||||||
|
case "load":
|
||||||
|
registerPrefsScripts(data.window);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
onStartup,
|
||||||
|
onShutdown,
|
||||||
|
onMainWindowLoad,
|
||||||
|
onMainWindowUnload,
|
||||||
|
onPrefsEvent,
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { BasicTool } from "zotero-plugin-toolkit";
|
||||||
|
import Addon from "./addon";
|
||||||
|
import { config } from "../package.json";
|
||||||
|
|
||||||
|
const basicTool = new BasicTool();
|
||||||
|
|
||||||
|
// @ts-expect-error - Plugin instance is not typed
|
||||||
|
if (!basicTool.getGlobal("Zotero")[config.addonInstance]) {
|
||||||
|
_globalThis.addon = new Addon();
|
||||||
|
defineGlobal("ztoolkit", () => {
|
||||||
|
return _globalThis.addon.data.ztoolkit;
|
||||||
|
});
|
||||||
|
// @ts-expect-error - Plugin instance is not typed
|
||||||
|
Zotero[config.addonInstance] = addon;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defineGlobal(name: Parameters<BasicTool["getGlobal"]>[0]): void;
|
||||||
|
function defineGlobal(name: string, getter: () => any): void;
|
||||||
|
function defineGlobal(name: string, getter?: () => any) {
|
||||||
|
Object.defineProperty(_globalThis, name, {
|
||||||
|
get() {
|
||||||
|
return getter ? getter() : basicTool.getGlobal(name);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { getPref } from "../../utils/prefs";
|
||||||
|
import type { AbortSignalLike } from "../../utils/abort";
|
||||||
|
import { getProviderPreset, isLocalProvider } from "./providers";
|
||||||
|
import type { ChatMessage, LlmRuntimeConfig } from "./types";
|
||||||
|
import { LlmError } from "./types";
|
||||||
|
import { joinUrl, normalizeBaseUrl } from "./url";
|
||||||
|
|
||||||
|
export function getRuntimeConfig(): LlmRuntimeConfig {
|
||||||
|
const provider = (getPref("provider") || "") as LlmRuntimeConfig["provider"];
|
||||||
|
const preset = provider ? getProviderPreset(provider) : undefined;
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
baseUrl: getPref("apiBaseUrl") || preset?.baseUrl || "",
|
||||||
|
apiKey: getPref("apiKey") || "",
|
||||||
|
model: getPref("model") || preset?.defaultModel || "",
|
||||||
|
temperature: Number(getPref("temperature") ?? 0.3),
|
||||||
|
maxTokens: Number(getPref("maxTokens") ?? 2048),
|
||||||
|
timeoutMs: Number(getPref("timeoutMs") ?? 120000),
|
||||||
|
openrouterReferer: getPref("openrouterReferer") || "",
|
||||||
|
openrouterTitle: getPref("openrouterTitle") || "ChatPapers",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertConfig(cfg: LlmRuntimeConfig): void {
|
||||||
|
if (!cfg.baseUrl) {
|
||||||
|
throw new LlmError(
|
||||||
|
"config",
|
||||||
|
"请先在设置中选择 Provider 并配置 Base URL。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!cfg.model) {
|
||||||
|
throw new LlmError("config", "请先配置模型名称(Model)。");
|
||||||
|
}
|
||||||
|
const preset = cfg.provider ? getProviderPreset(cfg.provider) : undefined;
|
||||||
|
if (preset?.needsKey && !cfg.apiKey) {
|
||||||
|
throw new LlmError("config", "当前 Provider 需要填写 API Key。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFetch(): typeof fetch {
|
||||||
|
const candidates = [
|
||||||
|
(globalThis as any).fetch,
|
||||||
|
typeof fetch !== "undefined" ? fetch : undefined,
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
candidates.push(ztoolkit.getGlobal("fetch" as any));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const win = Zotero.getMainWindow?.();
|
||||||
|
if (win) candidates.push((win as any).fetch?.bind(win));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
for (const fn of candidates) {
|
||||||
|
if (typeof fn === "function") return fn as typeof fetch;
|
||||||
|
}
|
||||||
|
throw new LlmError("network", "当前环境不支持 fetch,无法请求 AI 服务。");
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortError(): Error {
|
||||||
|
const err = new Error("Aborted");
|
||||||
|
err.name = "AbortError";
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwIfAborted(signal?: AbortSignalLike) {
|
||||||
|
if (signal?.aborted) throw abortError();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHeaders(cfg: LlmRuntimeConfig): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
if (cfg.apiKey) {
|
||||||
|
headers.Authorization = `Bearer ${cfg.apiKey}`;
|
||||||
|
}
|
||||||
|
if (cfg.provider === "openrouter") {
|
||||||
|
if (cfg.openrouterReferer) {
|
||||||
|
headers["HTTP-Referer"] = cfg.openrouterReferer;
|
||||||
|
}
|
||||||
|
if (cfg.openrouterTitle) {
|
||||||
|
headers["X-Title"] = cfg.openrouterTitle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapHttpError(status: number, body: string, cfg: LlmRuntimeConfig): LlmError {
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
return new LlmError("auth", `认证失败 (${status}):请检查 API Key。`, status);
|
||||||
|
}
|
||||||
|
if (status === 429) {
|
||||||
|
return new LlmError("rate_limit", `触发限流 (${status}),请稍后重试。`, status);
|
||||||
|
}
|
||||||
|
if (status >= 500 && isLocalProvider(cfg.provider)) {
|
||||||
|
return new LlmError(
|
||||||
|
"local_down",
|
||||||
|
`本地服务异常 (${status})。请确认 Ollama / LM Studio 已启动。`,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new LlmError(
|
||||||
|
"provider",
|
||||||
|
`请求失败 (${status}):${body.slice(0, 300) || "无响应内容"}`,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listModels(
|
||||||
|
signal?: AbortSignalLike,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const cfg = getRuntimeConfig();
|
||||||
|
assertConfig({ ...cfg, model: cfg.model || "placeholder" });
|
||||||
|
const url = joinUrl(cfg.baseUrl, "/models");
|
||||||
|
const doFetch = resolveFetch();
|
||||||
|
try {
|
||||||
|
const res = await doFetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: buildHeaders(cfg),
|
||||||
|
signal: signal as AbortSignal | undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw mapHttpError(res.status, text, cfg);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
data?: Array<{ id?: string }>;
|
||||||
|
};
|
||||||
|
return (data.data || [])
|
||||||
|
.map((m) => m.id || "")
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof LlmError) throw e;
|
||||||
|
if (signal?.aborted) throw abortError();
|
||||||
|
if (isLocalProvider(cfg.provider)) {
|
||||||
|
throw new LlmError(
|
||||||
|
"local_down",
|
||||||
|
`无法连接本地服务 (${normalizeBaseUrl(cfg.baseUrl)})。请先启动 Ollama / LM Studio。`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new LlmError("network", `网络错误:${String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chatStream(options: {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
onDelta: (text: string) => void;
|
||||||
|
signal?: AbortSignalLike;
|
||||||
|
}): Promise<string> {
|
||||||
|
const cfg = getRuntimeConfig();
|
||||||
|
assertConfig(cfg);
|
||||||
|
throwIfAborted(options.signal);
|
||||||
|
|
||||||
|
const url = joinUrl(cfg.baseUrl, "/chat/completions");
|
||||||
|
const body = {
|
||||||
|
model: cfg.model,
|
||||||
|
messages: options.messages,
|
||||||
|
stream: true,
|
||||||
|
temperature: cfg.temperature,
|
||||||
|
max_tokens: cfg.maxTokens,
|
||||||
|
};
|
||||||
|
|
||||||
|
const doFetch = resolveFetch();
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await doFetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: buildHeaders(cfg),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: options.signal as AbortSignal | undefined,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (options.signal?.aborted) throw abortError();
|
||||||
|
if (isLocalProvider(cfg.provider)) {
|
||||||
|
throw new LlmError(
|
||||||
|
"local_down",
|
||||||
|
`无法连接本地服务 (${normalizeBaseUrl(cfg.baseUrl)})。请先启动 Ollama / LM Studio。`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new LlmError("network", `网络错误:${String(e)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throwIfAborted(options.signal);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw mapHttpError(res.status, text, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.body) {
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
choices?: Array<{ message?: { content?: string } }>;
|
||||||
|
};
|
||||||
|
const content = data.choices?.[0]?.message?.content || "";
|
||||||
|
if (content) options.onDelta(content);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let full = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
throwIfAborted(options.signal);
|
||||||
|
const result = await reader.read();
|
||||||
|
const done = result.done;
|
||||||
|
const value = result.value;
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split(/\r?\n/);
|
||||||
|
buffer = lines.pop() || "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith("data:")) continue;
|
||||||
|
const payload = trimmed.slice(5).trim();
|
||||||
|
if (!payload || payload === "[DONE]") continue;
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(payload) as {
|
||||||
|
choices?: Array<{ delta?: { content?: string } }>;
|
||||||
|
};
|
||||||
|
const delta = json.choices?.[0]?.delta?.content || "";
|
||||||
|
if (delta) {
|
||||||
|
full += delta;
|
||||||
|
options.onDelta(delta);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed chunks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return full;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { getPref } from "../../utils/prefs";
|
||||||
|
|
||||||
|
const DEFAULT_SYSTEM = `你是学术论文阅读助手 ChatPapers。请基于用户提供的论文文本回答问题。
|
||||||
|
要求:
|
||||||
|
1. 回答准确、简洁,优先使用论文原文依据。
|
||||||
|
2. 若信息不足,请明确说明,不要编造。
|
||||||
|
3. 尽量标注页码或引用短片段(若上下文含页分隔)。
|
||||||
|
4. 使用用户偏好的语言回答。`;
|
||||||
|
|
||||||
|
const DEFAULT_SUMMARY = `请对以下论文做结构化总结,使用 Markdown,包含:
|
||||||
|
## 一句话概括
|
||||||
|
## 研究问题 / 动机
|
||||||
|
## 方法
|
||||||
|
## 主要结果
|
||||||
|
## 贡献与创新
|
||||||
|
## 局限与可追问点
|
||||||
|
## 关键术语(可选)`;
|
||||||
|
|
||||||
|
export function getSystemPrompt(): string {
|
||||||
|
const custom = (getPref("systemPrompt") || "").trim();
|
||||||
|
const lang = getPref("answerLanguage") || "zh-CN";
|
||||||
|
const base = custom || DEFAULT_SYSTEM;
|
||||||
|
return `${base}\n\n回答语言偏好:${lang}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSummaryPrompt(): string {
|
||||||
|
const custom = (getPref("summaryPrompt") || "").trim();
|
||||||
|
return custom || DEFAULT_SUMMARY;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { ProviderId, ProviderPreset } from "./types";
|
||||||
|
|
||||||
|
export const PROVIDER_PRESETS: Record<ProviderId, ProviderPreset> = {
|
||||||
|
ollama: {
|
||||||
|
id: "ollama",
|
||||||
|
label: "Ollama",
|
||||||
|
baseUrl: "http://127.0.0.1:11434/v1",
|
||||||
|
needsKey: false,
|
||||||
|
defaultModel: "qwen2.5",
|
||||||
|
},
|
||||||
|
lmstudio: {
|
||||||
|
id: "lmstudio",
|
||||||
|
label: "LM Studio",
|
||||||
|
baseUrl: "http://127.0.0.1:1234/v1",
|
||||||
|
needsKey: false,
|
||||||
|
defaultModel: "local-model",
|
||||||
|
},
|
||||||
|
openrouter: {
|
||||||
|
id: "openrouter",
|
||||||
|
label: "OpenRouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
needsKey: true,
|
||||||
|
defaultModel: "openai/gpt-4o-mini",
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
id: "openai",
|
||||||
|
label: "OpenAI",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
needsKey: true,
|
||||||
|
defaultModel: "gpt-4o-mini",
|
||||||
|
},
|
||||||
|
deepseek: {
|
||||||
|
id: "deepseek",
|
||||||
|
label: "DeepSeek",
|
||||||
|
baseUrl: "https://api.deepseek.com/v1",
|
||||||
|
needsKey: true,
|
||||||
|
defaultModel: "deepseek-chat",
|
||||||
|
},
|
||||||
|
siliconflow: {
|
||||||
|
id: "siliconflow",
|
||||||
|
label: "SiliconFlow",
|
||||||
|
baseUrl: "https://api.siliconflow.cn/v1",
|
||||||
|
needsKey: true,
|
||||||
|
defaultModel: "deepseek-ai/DeepSeek-V3",
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
id: "custom",
|
||||||
|
label: "Custom",
|
||||||
|
baseUrl: "",
|
||||||
|
needsKey: true,
|
||||||
|
defaultModel: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PROVIDER_ORDER: ProviderId[] = [
|
||||||
|
"ollama",
|
||||||
|
"lmstudio",
|
||||||
|
"openrouter",
|
||||||
|
"openai",
|
||||||
|
"deepseek",
|
||||||
|
"siliconflow",
|
||||||
|
"custom",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getProviderPreset(id: string): ProviderPreset | undefined {
|
||||||
|
return PROVIDER_PRESETS[id as ProviderId];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalProvider(id: string): boolean {
|
||||||
|
return id === "ollama" || id === "lmstudio";
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export type ProviderId =
|
||||||
|
| "ollama"
|
||||||
|
| "lmstudio"
|
||||||
|
| "openrouter"
|
||||||
|
| "openai"
|
||||||
|
| "deepseek"
|
||||||
|
| "siliconflow"
|
||||||
|
| "custom";
|
||||||
|
|
||||||
|
export interface ProviderPreset {
|
||||||
|
id: ProviderId;
|
||||||
|
label: string;
|
||||||
|
baseUrl: string;
|
||||||
|
needsKey: boolean;
|
||||||
|
defaultModel: string;
|
||||||
|
extraHeaders?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: "system" | "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LlmRuntimeConfig {
|
||||||
|
provider: ProviderId | "";
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
temperature: number;
|
||||||
|
maxTokens: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
openrouterReferer: string;
|
||||||
|
openrouterTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LlmErrorCode =
|
||||||
|
| "auth"
|
||||||
|
| "rate_limit"
|
||||||
|
| "network"
|
||||||
|
| "local_down"
|
||||||
|
| "provider"
|
||||||
|
| "config";
|
||||||
|
|
||||||
|
export class LlmError extends Error {
|
||||||
|
code: LlmErrorCode;
|
||||||
|
status?: number;
|
||||||
|
|
||||||
|
constructor(code: LlmErrorCode, message: string, status?: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = "LlmError";
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Normalize OpenAI-compatible base URL.
|
||||||
|
* Accepts both `http://host:port` and `http://host:port/v1`.
|
||||||
|
*/
|
||||||
|
export function normalizeBaseUrl(input: string): string {
|
||||||
|
let url = (input || "").trim().replace(/\/+$/, "");
|
||||||
|
if (!url) return "";
|
||||||
|
if (!/\/v\d+$/i.test(url)) {
|
||||||
|
url = `${url}/v1`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinUrl(baseUrl: string, path: string): string {
|
||||||
|
const base = normalizeBaseUrl(baseUrl).replace(/\/+$/, "");
|
||||||
|
const p = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
// Avoid /v1/v1/...
|
||||||
|
if (base.endsWith("/v1") && p.startsWith("/v1/")) {
|
||||||
|
return `${base}${p.slice(3)}`;
|
||||||
|
}
|
||||||
|
return `${base}${p}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { ChatMessage } from "../llm/types";
|
||||||
|
import { getSummaryPrompt, getSystemPrompt } from "../llm/prompts";
|
||||||
|
import {
|
||||||
|
buildMetadataBlock,
|
||||||
|
extractPdfContext,
|
||||||
|
type ExtractResult,
|
||||||
|
} from "./extractor";
|
||||||
|
|
||||||
|
export interface BuiltContext {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
extract: ExtractResult;
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildChatMessages(options: {
|
||||||
|
item: Zotero.Item;
|
||||||
|
history: ChatMessage[];
|
||||||
|
userText: string;
|
||||||
|
selection?: string;
|
||||||
|
mode?: "chat" | "summary";
|
||||||
|
}): Promise<BuiltContext> {
|
||||||
|
const extract = await extractPdfContext(options.item);
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (!extract.text) {
|
||||||
|
warnings.push(
|
||||||
|
"未能提取 PDF 文本(可能是扫描版或尚无文本层)。请检查附件后重试。",
|
||||||
|
);
|
||||||
|
} else if (extract.truncated) {
|
||||||
|
warnings.push("论文较长,已按设置截断上下文。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = extract.parentItem
|
||||||
|
? buildMetadataBlock(extract.parentItem)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const systemParts = [getSystemPrompt()];
|
||||||
|
if (meta) {
|
||||||
|
systemParts.push(`论文元数据:\n${meta}`);
|
||||||
|
}
|
||||||
|
if (extract.text) {
|
||||||
|
systemParts.push(
|
||||||
|
`论文正文(页之间可能以换页符分隔):\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);
|
||||||
|
|
||||||
|
if (options.mode === "summary") {
|
||||||
|
const summaryAsk = getSummaryPrompt();
|
||||||
|
messages.push({
|
||||||
|
role: "user",
|
||||||
|
content: summaryAsk,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let content = options.userText.trim();
|
||||||
|
if (options.selection?.trim()) {
|
||||||
|
content =
|
||||||
|
`【选中原文】\n${options.selection.trim()}\n\n【问题】\n${content}`;
|
||||||
|
}
|
||||||
|
messages.push({ role: "user", content });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
extract,
|
||||||
|
warning: warnings.length ? warnings.join(" ") : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { getPref } from "../../utils/prefs";
|
||||||
|
|
||||||
|
export interface ExtractResult {
|
||||||
|
text: string;
|
||||||
|
truncated: boolean;
|
||||||
|
pageCount?: number;
|
||||||
|
source: "pdfworker" | "fulltext" | "empty";
|
||||||
|
attachment?: Zotero.Item;
|
||||||
|
parentItem?: Zotero.Item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPdfAttachment(item: Zotero.Item): Zotero.Item | undefined {
|
||||||
|
if (item.isAttachment() && item.attachmentContentType === "application/pdf") {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
if (item.isRegularItem()) {
|
||||||
|
const attachments = item.getAttachments().map((id) => Zotero.Items.get(id));
|
||||||
|
return attachments.find(
|
||||||
|
(att) => att?.isAttachment() && att.attachmentContentType === "application/pdf",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Child attachment of a parent
|
||||||
|
if (item.isAttachment() && item.parentItemID) {
|
||||||
|
const parent = Zotero.Items.get(item.parentItemID);
|
||||||
|
return findPdfAttachment(parent);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParentItem(item: Zotero.Item): Zotero.Item {
|
||||||
|
if (item.isRegularItem()) return item;
|
||||||
|
if (item.parentItemID) {
|
||||||
|
return Zotero.Items.get(item.parentItemID) || item;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractViaPdfWorker(attachment: Zotero.Item): Promise<string> {
|
||||||
|
const worker = (Zotero as any).PDFWorker;
|
||||||
|
if (!worker?.getFullText) {
|
||||||
|
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> {
|
||||||
|
try {
|
||||||
|
const text = (attachment as any).attachmentText;
|
||||||
|
if (typeof text === "string" && text.trim()) return text.trim();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateText(
|
||||||
|
text: string,
|
||||||
|
maxChars: number,
|
||||||
|
): { text: string; truncated: boolean } {
|
||||||
|
if (text.length <= maxChars) {
|
||||||
|
return { text, truncated: false };
|
||||||
|
}
|
||||||
|
const head = Math.floor(maxChars * 0.7);
|
||||||
|
const tail = maxChars - head - 80;
|
||||||
|
const sliced =
|
||||||
|
text.slice(0, head) +
|
||||||
|
"\n\n[... 中间内容已截断 ...]\n\n" +
|
||||||
|
text.slice(-Math.max(tail, 0));
|
||||||
|
return { text: sliced, truncated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractPdfContext(item: Zotero.Item): Promise<ExtractResult> {
|
||||||
|
const attachment = findPdfAttachment(item);
|
||||||
|
const parentItem = getParentItem(item);
|
||||||
|
if (!attachment) {
|
||||||
|
return {
|
||||||
|
text: "",
|
||||||
|
truncated: false,
|
||||||
|
source: "empty",
|
||||||
|
parentItem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = "";
|
||||||
|
let source: ExtractResult["source"] = "empty";
|
||||||
|
try {
|
||||||
|
raw = await extractViaPdfWorker(attachment);
|
||||||
|
if (raw) source = "pdfworker";
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("PDFWorker extract failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
try {
|
||||||
|
raw = await extractViaFulltext(attachment);
|
||||||
|
if (raw) source = "fulltext";
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("Fulltext extract failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxChars = Number(getPref("maxContextChars") ?? 80000);
|
||||||
|
const { text, truncated } = truncateText(raw, maxChars);
|
||||||
|
const pageCount = raw ? raw.split("\f").length : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
truncated,
|
||||||
|
pageCount,
|
||||||
|
source,
|
||||||
|
attachment,
|
||||||
|
parentItem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMetadataBlock(item: Zotero.Item): string {
|
||||||
|
if (!getPref("sendMetadata")) return "";
|
||||||
|
const title = item.getField("title") || "";
|
||||||
|
const creators = item.getCreators()
|
||||||
|
.map((c) => [c.firstName, c.lastName].filter(Boolean).join(" "))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
const year = (item.getField("date") || "").slice(0, 4);
|
||||||
|
const doi = item.getField("DOI") || "";
|
||||||
|
const abstract = item.getField("abstractNote") || "";
|
||||||
|
return [
|
||||||
|
`标题:${title}`,
|
||||||
|
creators ? `作者:${creators}` : "",
|
||||||
|
year ? `年份:${year}` : "",
|
||||||
|
doi ? `DOI:${doi}` : "",
|
||||||
|
abstract ? `摘要:${abstract}` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { ChatMessage } from "../llm/types";
|
||||||
|
|
||||||
|
function sessionDir(): string {
|
||||||
|
const profile = (Zotero as any).Profile?.dir || (Zotero as any).getProfileDirectory?.()?.path;
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error("Cannot resolve Zotero profile directory");
|
||||||
|
}
|
||||||
|
return PathUtils.join(profile, "chatpapers", "sessions");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionPath(itemKey: string, attachmentKey: string): string {
|
||||||
|
const safe = `${itemKey}-${attachmentKey || "none"}`.replace(
|
||||||
|
/[^a-zA-Z0-9_-]/g,
|
||||||
|
"_",
|
||||||
|
);
|
||||||
|
return PathUtils.join(sessionDir(), `${safe}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSession(
|
||||||
|
itemKey: string,
|
||||||
|
attachmentKey: string,
|
||||||
|
): Promise<ChatMessage[]> {
|
||||||
|
try {
|
||||||
|
const path = sessionPath(itemKey, attachmentKey);
|
||||||
|
const exists = await IOUtils.exists(path);
|
||||||
|
if (!exists) return [];
|
||||||
|
const raw = await IOUtils.readUTF8(path);
|
||||||
|
const data = JSON.parse(raw) as { messages?: ChatMessage[] };
|
||||||
|
return (data.messages || []).filter((m) => m.role !== "system");
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("loadSession failed", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSession(
|
||||||
|
itemKey: string,
|
||||||
|
attachmentKey: string,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dir = sessionDir();
|
||||||
|
await IOUtils.makeDirectory(dir, { createAncestors: true, ignoreExisting: true });
|
||||||
|
const path = sessionPath(itemKey, attachmentKey);
|
||||||
|
const payload = JSON.stringify(
|
||||||
|
{
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
messages: messages.filter((m) => m.role !== "system").slice(-50),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
await IOUtils.writeUTF8(path, payload);
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("saveSession failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSession(
|
||||||
|
itemKey: string,
|
||||||
|
attachmentKey: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const path = sessionPath(itemKey, attachmentKey);
|
||||||
|
if (await IOUtils.exists(path)) {
|
||||||
|
await IOUtils.remove(path);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log("clearSession failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { chatStream, getRuntimeConfig } from "../llm/client";
|
||||||
|
import { LlmError, type ChatMessage } from "../llm/types";
|
||||||
|
import { getProviderPreset } from "../llm/providers";
|
||||||
|
import { buildChatMessages } from "../pdf/context";
|
||||||
|
import { clearSession, loadSession, saveSession } from "../storage/sessions";
|
||||||
|
import { createChildNote } from "../zotero/notes";
|
||||||
|
import { getString } from "../../utils/locale";
|
||||||
|
import {
|
||||||
|
createAbortHandle,
|
||||||
|
isAbortError,
|
||||||
|
type AbortHandle,
|
||||||
|
} from "../../utils/abort";
|
||||||
|
|
||||||
|
export class ChatView {
|
||||||
|
private body: HTMLElement;
|
||||||
|
private doc: Document;
|
||||||
|
private item: Zotero.Item;
|
||||||
|
private messages: ChatMessage[] = [];
|
||||||
|
private abort?: AbortHandle;
|
||||||
|
private selectionText = "";
|
||||||
|
|
||||||
|
private messagesEl!: HTMLElement;
|
||||||
|
private inputEl!: HTMLTextAreaElement;
|
||||||
|
private statusEl!: HTMLElement;
|
||||||
|
private sendBtn!: HTMLButtonElement;
|
||||||
|
private stopBtn!: HTMLButtonElement;
|
||||||
|
|
||||||
|
constructor(doc: Document, body: HTMLElement, item: Zotero.Item) {
|
||||||
|
this.doc = doc;
|
||||||
|
this.body = body;
|
||||||
|
this.item = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async mount(): Promise<void> {
|
||||||
|
this.body.replaceChildren();
|
||||||
|
this.body.classList.add("chatpapers-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");
|
||||||
|
header.append(
|
||||||
|
this.el("div", "chatpapers-title", this.item.getField("title") || "ChatPapers"),
|
||||||
|
this.el("div", "chatpapers-meta", `${providerLabel} · ${cfg.model || "无模型"}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.messagesEl = this.el("div", "chatpapers-messages");
|
||||||
|
this.statusEl = this.el("div", "chatpapers-status");
|
||||||
|
|
||||||
|
const toolbar = this.el("div", "chatpapers-toolbar");
|
||||||
|
const summarizeBtn = this.btn(getString("chat-summarize"), () =>
|
||||||
|
this.runSummary(),
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
|
||||||
|
this.inputEl = this.doc.createElement("textarea");
|
||||||
|
this.inputEl.className = "chatpapers-input";
|
||||||
|
this.inputEl.rows = 3;
|
||||||
|
this.inputEl.placeholder = getString("chat-placeholder");
|
||||||
|
|
||||||
|
const actions = this.el("div", "chatpapers-actions");
|
||||||
|
this.sendBtn = this.btn(getString("chat-send"), () => this.send());
|
||||||
|
this.sendBtn.classList.add("chatpapers-primary");
|
||||||
|
this.stopBtn = this.btn(getString("chat-stop"), () => this.stop());
|
||||||
|
this.stopBtn.disabled = true;
|
||||||
|
actions.append(this.sendBtn, this.stopBtn);
|
||||||
|
|
||||||
|
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, this.messagesEl, this.statusEl, toolbar, this.inputEl, actions);
|
||||||
|
|
||||||
|
const keys = this.sessionKeys();
|
||||||
|
this.messages = await loadSession(keys.itemKey, keys.attachmentKey);
|
||||||
|
this.renderMessages();
|
||||||
|
if (!cfg.provider || !cfg.baseUrl) {
|
||||||
|
this.setStatus(getString("chat-need-config"), "warn");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.stop();
|
||||||
|
this.body.replaceChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sessionKeys(): { itemKey: string; attachmentKey: string } {
|
||||||
|
const parent =
|
||||||
|
this.item.isRegularItem()
|
||||||
|
? this.item
|
||||||
|
: this.item.parentItemID
|
||||||
|
? Zotero.Items.get(this.item.parentItemID)
|
||||||
|
: this.item;
|
||||||
|
const attachment = this.item.isAttachment()
|
||||||
|
? this.item
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
itemKey: parent.key,
|
||||||
|
attachmentKey: attachment?.key || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 btn(label: string, onClick: () => void): HTMLButtonElement {
|
||||||
|
const b = this.doc.createElement("button");
|
||||||
|
b.type = "button";
|
||||||
|
b.className = "chatpapers-btn";
|
||||||
|
b.textContent = label;
|
||||||
|
b.addEventListener("click", onClick);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatus(text: string, kind: "info" | "warn" | "error" | "" = "info") {
|
||||||
|
this.statusEl.textContent = text;
|
||||||
|
this.statusEl.dataset.kind = kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderMessages() {
|
||||||
|
this.messagesEl.replaceChildren();
|
||||||
|
for (const msg of this.messages) {
|
||||||
|
if (msg.role === "system") continue;
|
||||||
|
const bubble = this.el(
|
||||||
|
"div",
|
||||||
|
`chatpapers-bubble chatpapers-${msg.role}`,
|
||||||
|
);
|
||||||
|
const role = this.el(
|
||||||
|
"div",
|
||||||
|
"chatpapers-role",
|
||||||
|
msg.role === "user" ? "You" : "AI",
|
||||||
|
);
|
||||||
|
const content = this.el("div", "chatpapers-content", msg.content);
|
||||||
|
bubble.append(role, content);
|
||||||
|
this.messagesEl.append(bubble);
|
||||||
|
}
|
||||||
|
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendAssistantPlaceholder(): HTMLElement {
|
||||||
|
const bubble = this.el("div", "chatpapers-bubble chatpapers-assistant");
|
||||||
|
bubble.append(
|
||||||
|
this.el("div", "chatpapers-role", "AI"),
|
||||||
|
this.el("div", "chatpapers-content", ""),
|
||||||
|
);
|
||||||
|
this.messagesEl.append(bubble);
|
||||||
|
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
||||||
|
return bubble.querySelector(".chatpapers-content") as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
private captureSelection() {
|
||||||
|
try {
|
||||||
|
const reader = Zotero.Reader.getByTabID?.(Zotero_Tabs.selectedID);
|
||||||
|
// @ts-expect-error internal reader APIs vary
|
||||||
|
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) {
|
||||||
|
this.setStatus(
|
||||||
|
`${getString("chat-selection-added")} (${this.selectionText.length} chars)`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.setStatus(getString("chat-selection-empty"), "warn");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ztoolkit.log(e);
|
||||||
|
this.setStatus(getString("chat-selection-empty"), "warn");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persist() {
|
||||||
|
const keys = this.sessionKeys();
|
||||||
|
await saveSession(keys.itemKey, keys.attachmentKey, this.messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.runChat(text, "chat");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runSummary() {
|
||||||
|
await this.runChat(getString("chat-summarize"), "summary");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runChat(userText: string, mode: "chat" | "summary") {
|
||||||
|
this.stop();
|
||||||
|
this.abort = createAbortHandle();
|
||||||
|
this.setBusy(true);
|
||||||
|
this.setStatus(getString("chat-thinking"), "info");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const built = await buildChatMessages({
|
||||||
|
item: this.item,
|
||||||
|
history: this.messages,
|
||||||
|
userText,
|
||||||
|
selection: this.selectionText,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
this.selectionText = "";
|
||||||
|
|
||||||
|
if (built.warning) {
|
||||||
|
this.setStatus(built.warning, "warn");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "chat") {
|
||||||
|
this.messages.push({ role: "user", content: userText });
|
||||||
|
} else {
|
||||||
|
this.messages.push({ role: "user", content: userText });
|
||||||
|
}
|
||||||
|
this.renderMessages();
|
||||||
|
|
||||||
|
const contentEl = this.appendAssistantPlaceholder();
|
||||||
|
let full = "";
|
||||||
|
await chatStream({
|
||||||
|
messages: built.messages,
|
||||||
|
signal: this.abort.signal,
|
||||||
|
onDelta: (delta) => {
|
||||||
|
full += delta;
|
||||||
|
contentEl.textContent = full;
|
||||||
|
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.messages.push({ role: "assistant", content: full || "(empty)" });
|
||||||
|
await this.persist();
|
||||||
|
this.setStatus(getString("chat-done"), "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 clearChat() {
|
||||||
|
this.messages = [];
|
||||||
|
const keys = this.sessionKeys();
|
||||||
|
await clearSession(keys.itemKey, keys.attachmentKey);
|
||||||
|
this.renderMessages();
|
||||||
|
this.setStatus(getString("chat-cleared"), "info");
|
||||||
|
}
|
||||||
|
|
||||||
|
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.item.isRegularItem()
|
||||||
|
? this.item
|
||||||
|
: this.item.parentItemID
|
||||||
|
? Zotero.Items.get(this.item.parentItemID)
|
||||||
|
: this.item;
|
||||||
|
const title = `ChatPapers · ${new Date().toLocaleString()}`;
|
||||||
|
try {
|
||||||
|
await createChildNote({
|
||||||
|
parentItem: parent,
|
||||||
|
title,
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { getPref, setPref } from "../../utils/prefs";
|
||||||
|
import { listModels } from "../llm/client";
|
||||||
|
import { LlmError } from "../llm/types";
|
||||||
|
import {
|
||||||
|
PROVIDER_ORDER,
|
||||||
|
PROVIDER_PRESETS,
|
||||||
|
getProviderPreset,
|
||||||
|
isLocalProvider,
|
||||||
|
} from "../llm/providers";
|
||||||
|
import { getString } from "../../utils/locale";
|
||||||
|
|
||||||
|
export function registerPrefsScripts(win: Window) {
|
||||||
|
const doc = win.document;
|
||||||
|
const providerSelect = doc.getElementById(
|
||||||
|
"chatpapers-pref-provider",
|
||||||
|
) as HTMLSelectElement | null;
|
||||||
|
const baseUrlInput = doc.getElementById(
|
||||||
|
"chatpapers-pref-apiBaseUrl",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
const modelInput = doc.getElementById(
|
||||||
|
"chatpapers-pref-model",
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
const resetBtn = doc.getElementById("chatpapers-pref-reset");
|
||||||
|
const refreshBtn = doc.getElementById("chatpapers-pref-refresh-models");
|
||||||
|
const hint = doc.getElementById("chatpapers-pref-hint");
|
||||||
|
|
||||||
|
if (providerSelect && providerSelect.options.length === 0) {
|
||||||
|
for (const id of PROVIDER_ORDER) {
|
||||||
|
const opt = doc.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = PROVIDER_PRESETS[id].label;
|
||||||
|
providerSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
const current = getPref("provider") || "";
|
||||||
|
if (current) providerSelect.value = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateHint = () => {
|
||||||
|
if (!hint) return;
|
||||||
|
const id = (providerSelect?.value || getPref("provider") || "") as string;
|
||||||
|
if (isLocalProvider(id)) {
|
||||||
|
hint.textContent = getString("prefs-hint-local");
|
||||||
|
} else if (id === "openrouter") {
|
||||||
|
hint.textContent = getString("prefs-hint-openrouter");
|
||||||
|
} else if (id) {
|
||||||
|
hint.textContent = getString("prefs-hint-cloud");
|
||||||
|
} else {
|
||||||
|
hint.textContent = getString("prefs-hint-empty");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
providerSelect?.addEventListener("change", () => {
|
||||||
|
const id = providerSelect.value;
|
||||||
|
setPref("provider", id);
|
||||||
|
const preset = getProviderPreset(id);
|
||||||
|
if (preset) {
|
||||||
|
// Always apply preset defaults on switch; user can edit after
|
||||||
|
setPref("apiBaseUrl", preset.baseUrl);
|
||||||
|
setPref("model", preset.defaultModel);
|
||||||
|
if (baseUrlInput) baseUrlInput.value = preset.baseUrl;
|
||||||
|
if (modelInput) modelInput.value = preset.defaultModel;
|
||||||
|
}
|
||||||
|
updateHint();
|
||||||
|
});
|
||||||
|
|
||||||
|
resetBtn?.addEventListener("command", () => {
|
||||||
|
const id = providerSelect?.value || getPref("provider");
|
||||||
|
const preset = getProviderPreset(id);
|
||||||
|
if (!preset) return;
|
||||||
|
setPref("apiBaseUrl", preset.baseUrl);
|
||||||
|
setPref("model", preset.defaultModel);
|
||||||
|
if (baseUrlInput) baseUrlInput.value = preset.baseUrl;
|
||||||
|
if (modelInput) modelInput.value = preset.defaultModel;
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshBtn?.addEventListener("command", async () => {
|
||||||
|
try {
|
||||||
|
refreshBtn.setAttribute("disabled", "true");
|
||||||
|
const models = await listModels();
|
||||||
|
if (!models.length) {
|
||||||
|
win.alert(getString("prefs-models-empty"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const picked = models[0];
|
||||||
|
const choice = win.prompt(
|
||||||
|
`${getString("prefs-models-pick")}\n\n${models.slice(0, 30).join("\n")}`,
|
||||||
|
getPref("model") || picked,
|
||||||
|
);
|
||||||
|
if (choice) {
|
||||||
|
setPref("model", choice);
|
||||||
|
if (modelInput) modelInput.value = choice;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof LlmError ? e.message : String(e);
|
||||||
|
win.alert(msg);
|
||||||
|
} finally {
|
||||||
|
refreshBtn.removeAttribute("disabled");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateHint();
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { config } from "../../../package.json";
|
||||||
|
import { getLocaleID, getString } from "../../utils/locale";
|
||||||
|
import { ChatView } from "./chatView";
|
||||||
|
|
||||||
|
const views = new WeakMap<HTMLElement, ChatView>();
|
||||||
|
|
||||||
|
function iconURL(file: string) {
|
||||||
|
return `chrome://${config.addonRef}/content/icons/${file}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerChatPane() {
|
||||||
|
Zotero.ItemPaneManager.registerSection({
|
||||||
|
paneID: "chatpapers-chat",
|
||||||
|
pluginID: config.addonID,
|
||||||
|
header: {
|
||||||
|
l10nID: getLocaleID("item-section-chat-head"),
|
||||||
|
icon: iconURL("chat.svg"),
|
||||||
|
},
|
||||||
|
sidenav: {
|
||||||
|
l10nID: getLocaleID("item-section-chat-sidenav"),
|
||||||
|
icon: iconURL("chat.svg"),
|
||||||
|
},
|
||||||
|
onInit: ({ body }) => {
|
||||||
|
const doc = body.ownerDocument;
|
||||||
|
if (doc) ensureStyles(doc);
|
||||||
|
},
|
||||||
|
onDestroy: ({ body }) => {
|
||||||
|
views.get(body)?.destroy();
|
||||||
|
views.delete(body);
|
||||||
|
},
|
||||||
|
onItemChange: ({ item, setEnabled, tabType }) => {
|
||||||
|
// Enable in reader and library item pane when there is an item
|
||||||
|
setEnabled(Boolean(item));
|
||||||
|
void tabType;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onRender: ({ body }) => {
|
||||||
|
body.replaceChildren();
|
||||||
|
const doc = body.ownerDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
const loading = doc.createElement("div");
|
||||||
|
loading.className = "chatpapers-loading";
|
||||||
|
loading.textContent = getString("chat-loading");
|
||||||
|
body.append(loading);
|
||||||
|
},
|
||||||
|
onAsyncRender: async ({ body, item }) => {
|
||||||
|
if (!item) return;
|
||||||
|
const doc = body.ownerDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
views.get(body)?.destroy();
|
||||||
|
const view = new ChatView(doc, body, item);
|
||||||
|
views.set(body, view);
|
||||||
|
await view.mount();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPrefs() {
|
||||||
|
Zotero.PreferencePanes.register({
|
||||||
|
pluginID: config.addonID,
|
||||||
|
src: rootURI + "content/preferences.xhtml",
|
||||||
|
label: getString("prefs-title"),
|
||||||
|
image: `chrome://${config.addonRef}/content/icons/favicon.png`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStyles(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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
export async function createChildNote(options: {
|
||||||
|
parentItem: Zotero.Item;
|
||||||
|
title: string;
|
||||||
|
bodyMarkdown: string;
|
||||||
|
}): Promise<Zotero.Item> {
|
||||||
|
const note = new Zotero.Item("note");
|
||||||
|
note.libraryID = options.parentItem.libraryID;
|
||||||
|
note.parentID = options.parentItem.id;
|
||||||
|
|
||||||
|
const html = markdownToSimpleHtml(options.title, options.bodyMarkdown);
|
||||||
|
note.setNote(html);
|
||||||
|
await note.saveTx();
|
||||||
|
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,102 @@
|
|||||||
|
/**
|
||||||
|
* Zotero plugin sandbox may not expose AbortController as a free global.
|
||||||
|
* Resolve from chrome globals, or fall back to a minimal stub.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AbortHandle = {
|
||||||
|
signal: AbortSignalLike;
|
||||||
|
abort: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AbortSignalLike = {
|
||||||
|
aborted: boolean;
|
||||||
|
reason?: unknown;
|
||||||
|
addEventListener?: (
|
||||||
|
type: "abort",
|
||||||
|
listener: () => void,
|
||||||
|
options?: { once?: boolean },
|
||||||
|
) => void;
|
||||||
|
removeEventListener?: (type: "abort", listener: () => void) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveNativeAbortController(): typeof AbortController | undefined {
|
||||||
|
const candidates = [
|
||||||
|
(globalThis as any).AbortController,
|
||||||
|
typeof AbortController !== "undefined" ? AbortController : undefined,
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
candidates.push(ztoolkit.getGlobal("AbortController" as any));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const win = Zotero.getMainWindow?.();
|
||||||
|
if (win) candidates.push((win as any).AbortController);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
for (const Ctor of candidates) {
|
||||||
|
if (typeof Ctor === "function") return Ctor;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StubAbortSignal implements AbortSignalLike {
|
||||||
|
aborted = false;
|
||||||
|
reason: unknown;
|
||||||
|
private listeners: Array<() => void> = [];
|
||||||
|
|
||||||
|
addEventListener(
|
||||||
|
type: "abort",
|
||||||
|
listener: () => void,
|
||||||
|
_options?: { once?: boolean },
|
||||||
|
) {
|
||||||
|
if (type === "abort") this.listeners.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type: "abort", listener: () => void) {
|
||||||
|
if (type !== "abort") return;
|
||||||
|
this.listeners = this.listeners.filter((l) => l !== listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
_abort(reason?: unknown) {
|
||||||
|
if (this.aborted) return;
|
||||||
|
this.aborted = true;
|
||||||
|
this.reason = reason;
|
||||||
|
for (const l of [...this.listeners]) l();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StubAbortController {
|
||||||
|
signal = new StubAbortSignal();
|
||||||
|
abort(reason?: unknown) {
|
||||||
|
const err = new Error("Aborted");
|
||||||
|
err.name = "AbortError";
|
||||||
|
this.signal._abort(reason ?? err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAbortHandle(): AbortHandle {
|
||||||
|
const Native = resolveNativeAbortController();
|
||||||
|
if (Native) {
|
||||||
|
const ctrl = new Native();
|
||||||
|
return {
|
||||||
|
signal: ctrl.signal as AbortSignalLike,
|
||||||
|
abort: () => ctrl.abort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const stub = new StubAbortController();
|
||||||
|
return {
|
||||||
|
signal: stub.signal,
|
||||||
|
abort: () => stub.abort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAbortError(e: unknown): boolean {
|
||||||
|
if (!e || typeof e !== "object") return false;
|
||||||
|
const err = e as { name?: string; message?: string };
|
||||||
|
return (
|
||||||
|
err.name === "AbortError" ||
|
||||||
|
String(err.message || "").toLowerCase().includes("abort")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { config } from "../../package.json";
|
||||||
|
import { FluentMessageId } from "../../typings/i10n";
|
||||||
|
|
||||||
|
export { initLocale, getString, getLocaleID };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize locale data
|
||||||
|
*/
|
||||||
|
function initLocale() {
|
||||||
|
const l10n = new (
|
||||||
|
typeof Localization === "undefined"
|
||||||
|
? ztoolkit.getGlobal("Localization")
|
||||||
|
: Localization
|
||||||
|
)([`${config.addonRef}-addon.ftl`], true);
|
||||||
|
addon.data.locale = {
|
||||||
|
current: l10n,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get locale string, see https://firefox-source-docs.mozilla.org/l10n/fluent/tutorial.html#fluent-translation-list-ftl
|
||||||
|
* @param localString ftl key
|
||||||
|
* @param options.branch branch name
|
||||||
|
* @param options.args args
|
||||||
|
* @example
|
||||||
|
* ```ftl
|
||||||
|
* # addon.ftl
|
||||||
|
* addon-static-example = This is default branch!
|
||||||
|
* .branch-example = This is a branch under addon-static-example!
|
||||||
|
* addon-dynamic-example =
|
||||||
|
{ $count ->
|
||||||
|
[one] I have { $count } apple
|
||||||
|
*[other] I have { $count } apples
|
||||||
|
}
|
||||||
|
* ```
|
||||||
|
* ```js
|
||||||
|
* getString("addon-static-example"); // This is default branch!
|
||||||
|
* getString("addon-static-example", { branch: "branch-example" }); // This is a branch under addon-static-example!
|
||||||
|
* getString("addon-dynamic-example", { args: { count: 1 } }); // I have 1 apple
|
||||||
|
* getString("addon-dynamic-example", { args: { count: 2 } }); // I have 2 apples
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function getString(localString: FluentMessageId): string;
|
||||||
|
function getString(localString: FluentMessageId, branch: string): string;
|
||||||
|
function getString(
|
||||||
|
localeString: FluentMessageId,
|
||||||
|
options: { branch?: string | undefined; args?: Record<string, unknown> },
|
||||||
|
): string;
|
||||||
|
function getString(...inputs: any[]) {
|
||||||
|
if (inputs.length === 1) {
|
||||||
|
return _getString(inputs[0]);
|
||||||
|
} else if (inputs.length === 2) {
|
||||||
|
if (typeof inputs[1] === "string") {
|
||||||
|
return _getString(inputs[0], { branch: inputs[1] });
|
||||||
|
} else {
|
||||||
|
return _getString(inputs[0], inputs[1]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid arguments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pattern {
|
||||||
|
value: string | null;
|
||||||
|
attributes: Array<{
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getString(
|
||||||
|
localeString: FluentMessageId,
|
||||||
|
options: { branch?: string | undefined; args?: Record<string, unknown> } = {},
|
||||||
|
): string {
|
||||||
|
const localStringWithPrefix = `${config.addonRef}-${localeString}`;
|
||||||
|
const { branch, args } = options;
|
||||||
|
const pattern = addon.data.locale?.current.formatMessagesSync([
|
||||||
|
{ id: localStringWithPrefix, args },
|
||||||
|
])[0] as Pattern;
|
||||||
|
|
||||||
|
if (!pattern) {
|
||||||
|
return localStringWithPrefix;
|
||||||
|
}
|
||||||
|
if (branch && pattern.attributes) {
|
||||||
|
return (
|
||||||
|
pattern.attributes.find((attr) => attr.name === branch)?.value ||
|
||||||
|
localStringWithPrefix
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return pattern.value || localStringWithPrefix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocaleID(id: FluentMessageId) {
|
||||||
|
return `${config.addonRef}-${id}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { config } from "../../package.json";
|
||||||
|
|
||||||
|
type PluginPrefsMap = _ZoteroTypes.Prefs["PluginPrefsMap"];
|
||||||
|
|
||||||
|
const PREFS_PREFIX = config.prefsPrefix;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get preference value.
|
||||||
|
* Wrapper of `Zotero.Prefs.get`.
|
||||||
|
* @param key
|
||||||
|
*/
|
||||||
|
export function getPref<K extends keyof PluginPrefsMap>(key: K) {
|
||||||
|
return Zotero.Prefs.get(`${PREFS_PREFIX}.${key}`, true) as PluginPrefsMap[K];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set preference value.
|
||||||
|
* Wrapper of `Zotero.Prefs.set`.
|
||||||
|
* @param key
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
|
export function setPref<K extends keyof PluginPrefsMap>(
|
||||||
|
key: K,
|
||||||
|
value: PluginPrefsMap[K],
|
||||||
|
) {
|
||||||
|
return Zotero.Prefs.set(`${PREFS_PREFIX}.${key}`, value, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear preference value.
|
||||||
|
* Wrapper of `Zotero.Prefs.clear`.
|
||||||
|
* @param key
|
||||||
|
*/
|
||||||
|
export function clearPref(key: string) {
|
||||||
|
return Zotero.Prefs.clear(`${PREFS_PREFIX}.${key}`, true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export { isWindowAlive };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the window is alive.
|
||||||
|
* Useful to prevent opening duplicate windows.
|
||||||
|
* @param win
|
||||||
|
*/
|
||||||
|
function isWindowAlive(win?: Window) {
|
||||||
|
return win && !Components.utils.isDeadWrapper(win) && !win.closed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { ZoteroToolkit } from "zotero-plugin-toolkit";
|
||||||
|
import { config } from "../../package.json";
|
||||||
|
|
||||||
|
export { createZToolkit };
|
||||||
|
|
||||||
|
function createZToolkit() {
|
||||||
|
const _ztoolkit = new ZoteroToolkit();
|
||||||
|
/**
|
||||||
|
* Alternatively, import toolkit modules you use to minify the plugin size.
|
||||||
|
* You can add the modules under the `MyToolkit` class below and uncomment the following line.
|
||||||
|
*/
|
||||||
|
// const _ztoolkit = new MyToolkit();
|
||||||
|
initZToolkit(_ztoolkit);
|
||||||
|
return _ztoolkit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initZToolkit(_ztoolkit: ReturnType<typeof createZToolkit>) {
|
||||||
|
const env = __env__;
|
||||||
|
_ztoolkit.basicOptions.log.prefix = `[${config.addonName}]`;
|
||||||
|
_ztoolkit.basicOptions.log.disableConsole = env === "production";
|
||||||
|
_ztoolkit.UI.basicOptions.ui.enableElementJSONLog = __env__ === "development";
|
||||||
|
_ztoolkit.UI.basicOptions.ui.enableElementDOMLog = __env__ === "development";
|
||||||
|
// Getting basicOptions.debug will load global modules like the debug bridge.
|
||||||
|
// since we want to deprecate it, should avoid using it unless necessary.
|
||||||
|
// _ztoolkit.basicOptions.debug.disableDebugBridgePassword =
|
||||||
|
// __env__ === "development";
|
||||||
|
_ztoolkit.basicOptions.api.pluginID = config.addonID;
|
||||||
|
_ztoolkit.ProgressWindow.setIconURI(
|
||||||
|
"default",
|
||||||
|
`chrome://${config.addonRef}/content/icons/favicon.png`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { BasicTool, unregister } from "zotero-plugin-toolkit";
|
||||||
|
import { UITool } from "zotero-plugin-toolkit";
|
||||||
|
|
||||||
|
class MyToolkit extends BasicTool {
|
||||||
|
UI: UITool;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.UI = new UITool(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterAll() {
|
||||||
|
unregister(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "zotero-types/entries/sandbox/",
|
||||||
|
"include": ["src", "typings"],
|
||||||
|
"exclude": ["build", "addon"]
|
||||||
|
}
|
||||||
Vendored
+37
@@ -0,0 +1,37 @@
|
|||||||
|
declare const _globalThis: {
|
||||||
|
[key: string]: any;
|
||||||
|
Zotero: _ZoteroTypes.Zotero;
|
||||||
|
ztoolkit: ZToolkit;
|
||||||
|
addon: typeof addon;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare type ZToolkit = ReturnType<
|
||||||
|
typeof import("../src/utils/ztoolkit").createZToolkit
|
||||||
|
>;
|
||||||
|
|
||||||
|
declare const ztoolkit: ZToolkit;
|
||||||
|
|
||||||
|
declare const rootURI: string;
|
||||||
|
|
||||||
|
declare const addon: import("../src/addon").default;
|
||||||
|
|
||||||
|
declare const __env__: "production" | "development";
|
||||||
|
|
||||||
|
declare const Zotero_Tabs: {
|
||||||
|
selectedID: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare const PathUtils: {
|
||||||
|
join: (...parts: string[]) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare const IOUtils: {
|
||||||
|
exists: (path: string) => Promise<boolean>;
|
||||||
|
readUTF8: (path: string) => Promise<string>;
|
||||||
|
writeUTF8: (path: string, data: string) => Promise<void>;
|
||||||
|
remove: (path: string) => Promise<void>;
|
||||||
|
makeDirectory: (
|
||||||
|
path: string,
|
||||||
|
options?: { createAncestors?: boolean; ignoreExisting?: boolean },
|
||||||
|
) => Promise<void>;
|
||||||
|
};
|
||||||
Vendored
+55
@@ -0,0 +1,55 @@
|
|||||||
|
// Generated by zotero-plugin-scaffold
|
||||||
|
/* prettier-ignore */
|
||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
export type FluentMessageId =
|
||||||
|
| 'chat-add-selection'
|
||||||
|
| 'chat-clear'
|
||||||
|
| 'chat-cleared'
|
||||||
|
| 'chat-done'
|
||||||
|
| 'chat-loading'
|
||||||
|
| 'chat-need-config'
|
||||||
|
| 'chat-no-reply'
|
||||||
|
| 'chat-note-saved'
|
||||||
|
| 'chat-placeholder'
|
||||||
|
| 'chat-save-note'
|
||||||
|
| 'chat-selection-added'
|
||||||
|
| 'chat-selection-empty'
|
||||||
|
| 'chat-send'
|
||||||
|
| 'chat-stop'
|
||||||
|
| 'chat-stopped'
|
||||||
|
| 'chat-summarize'
|
||||||
|
| 'chat-thinking'
|
||||||
|
| 'item-section-chat-head'
|
||||||
|
| 'item-section-chat-sidenav'
|
||||||
|
| 'itemmenu-label'
|
||||||
|
| 'prefs-answer-language'
|
||||||
|
| 'prefs-api-key'
|
||||||
|
| 'prefs-base-url'
|
||||||
|
| 'prefs-hint-cloud'
|
||||||
|
| 'prefs-hint-empty'
|
||||||
|
| 'prefs-hint-local'
|
||||||
|
| 'prefs-hint-openrouter'
|
||||||
|
| 'prefs-max-context'
|
||||||
|
| 'prefs-max-tokens'
|
||||||
|
| 'prefs-model'
|
||||||
|
| 'prefs-models-empty'
|
||||||
|
| 'prefs-models-pick'
|
||||||
|
| 'prefs-openrouter-referer'
|
||||||
|
| 'prefs-openrouter-title'
|
||||||
|
| 'prefs-privacy'
|
||||||
|
| 'prefs-provider'
|
||||||
|
| 'prefs-refresh-models'
|
||||||
|
| 'prefs-reset'
|
||||||
|
| 'prefs-section-generation'
|
||||||
|
| 'prefs-section-openrouter'
|
||||||
|
| 'prefs-section-prompts'
|
||||||
|
| 'prefs-section-provider'
|
||||||
|
| 'prefs-send-metadata'
|
||||||
|
| 'prefs-summary-prompt'
|
||||||
|
| 'prefs-system-prompt'
|
||||||
|
| 'prefs-temperature'
|
||||||
|
| 'prefs-timeout'
|
||||||
|
| 'prefs-title'
|
||||||
|
| 'startup-begin'
|
||||||
|
| 'startup-finish';
|
||||||
Vendored
+27
@@ -0,0 +1,27 @@
|
|||||||
|
// Generated by zotero-plugin-scaffold
|
||||||
|
/* prettier-ignore */
|
||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
declare namespace _ZoteroTypes {
|
||||||
|
interface Prefs {
|
||||||
|
PluginPrefsMap: {
|
||||||
|
"provider": string;
|
||||||
|
"apiBaseUrl": string;
|
||||||
|
"apiKey": string;
|
||||||
|
"model": string;
|
||||||
|
"openrouterReferer": string;
|
||||||
|
"openrouterTitle": string;
|
||||||
|
"temperature": string;
|
||||||
|
"maxTokens": number;
|
||||||
|
"timeoutMs": number;
|
||||||
|
"maxContextChars": number;
|
||||||
|
"answerLanguage": string;
|
||||||
|
"systemPrompt": string;
|
||||||
|
"summaryPrompt": string;
|
||||||
|
"sendMetadata": boolean;
|
||||||
|
"verboseLog": boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { defineConfig } from "zotero-plugin-scaffold";
|
||||||
|
import pkg from "./package.json";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
source: ["src", "addon"],
|
||||||
|
dist: ".scaffold/build",
|
||||||
|
name: pkg.config.addonName,
|
||||||
|
id: pkg.config.addonID,
|
||||||
|
namespace: pkg.config.addonRef,
|
||||||
|
updateURL: `https://github.com/{{owner}}/{{repo}}/releases/download/release/${
|
||||||
|
pkg.version.includes("-") ? "update-beta.json" : "update.json"
|
||||||
|
}`,
|
||||||
|
xpiDownloadLink:
|
||||||
|
"https://github.com/{{owner}}/{{repo}}/releases/download/v{{version}}/{{xpiName}}.xpi",
|
||||||
|
|
||||||
|
build: {
|
||||||
|
assets: ["addon/**/*.*"],
|
||||||
|
define: {
|
||||||
|
...pkg.config,
|
||||||
|
author: pkg.author,
|
||||||
|
description: pkg.description,
|
||||||
|
homepage: pkg.homepage,
|
||||||
|
buildVersion: pkg.version,
|
||||||
|
buildTime: "{{buildTime}}",
|
||||||
|
},
|
||||||
|
prefs: {
|
||||||
|
prefix: pkg.config.prefsPrefix,
|
||||||
|
},
|
||||||
|
esbuildOptions: [
|
||||||
|
{
|
||||||
|
entryPoints: ["src/index.ts"],
|
||||||
|
define: {
|
||||||
|
__env__: `"${process.env.NODE_ENV}"`,
|
||||||
|
},
|
||||||
|
bundle: true,
|
||||||
|
target: "firefox115",
|
||||||
|
outfile: `.scaffold/build/addon/content/scripts/${pkg.config.addonRef}.js`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
test: {
|
||||||
|
waitForPlugin: `() => Zotero.${pkg.config.addonInstance}.data.initialized`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// If you need to see a more detailed log, uncomment the following line:
|
||||||
|
// logLevel: "trace",
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user