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