Spaces:
Running
Running
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +1 -0
- .gitignore +8 -0
- LICENSE +661 -0
- README.md +38 -1
- _mcp_filesystem/read_write/guess_number.py +35 -0
- _mcp_filesystem/read_write/poem.md +9 -0
- _mcp_servers/datetime-mcp/datetime-mcp.py +22 -0
- _mcp_servers/fetch-mcp/fetch-mcp.py +26 -0
- _mcp_servers/file_watcher-mcp/file_watcher-mcp.py +74 -0
- _mcp_servers/filesystem_read-mcp/filesystem_read-mcp.py +265 -0
- _mcp_servers/filesystem_write-mcp/filesystem_write-mcp.py +213 -0
- css/3rdparty/hljs_androidstudio.min.css +1 -0
- css/agents.css +382 -0
- css/main.css +564 -0
- example_chat_with_flow_and_agents.json +223 -0
- index.html +140 -19
- js/3rdparty/auto-render.min.js +1 -0
- js/3rdparty/highlight.min.js +0 -0
- js/3rdparty/katex.min.js +0 -0
- js/3rdparty/markdown-it.min.js +0 -0
- js/app.js +500 -0
- js/components/chatbox.js +185 -0
- js/components/chatlist-view.js +110 -0
- js/components/chatlog.js +530 -0
- js/components/settings-panel.js +128 -0
- js/config.js +57 -0
- js/hooks.js +81 -0
- js/main.js +12 -0
- js/plugins/agents.js +1110 -0
- js/plugins/avatars.js +120 -0
- js/plugins/error-bubble.js +88 -0
- js/plugins/formatting.js +521 -0
- js/plugins/maximize-view.js +28 -0
- js/plugins/mcp.js +453 -0
- js/plugins/model-params.js +229 -0
- js/plugins/ui-controls.js +154 -0
- js/services/api-service.js +101 -0
- js/services/chat-service.js +274 -0
- js/services/config-service.js +71 -0
- js/state/store.js +81 -0
- js/utils/chat.js +75 -0
- js/utils/logger.js +30 -0
- js/utils/parsers.js +69 -0
- js/utils/shared.js +132 -0
- js/utils/ui.js +41 -0
- main.py +132 -0
- mcp_config-examples.json +48 -0
- mcp_config.json +38 -0
- penguin.svg +27 -0
- pyproject.toml +10 -0
.gitattributes
CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
36 |
+
screenshot.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/_test/
|
2 |
+
|
3 |
+
/.venv/
|
4 |
+
/uv.lock
|
5 |
+
|
6 |
+
.DS_Store
|
7 |
+
|
8 |
+
__pycache__/
|
LICENSE
ADDED
@@ -0,0 +1,661 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
GNU AFFERO GENERAL PUBLIC LICENSE
|
2 |
+
Version 3, 19 November 2007
|
3 |
+
|
4 |
+
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
5 |
+
Everyone is permitted to copy and distribute verbatim copies
|
6 |
+
of this license document, but changing it is not allowed.
|
7 |
+
|
8 |
+
Preamble
|
9 |
+
|
10 |
+
The GNU Affero General Public License is a free, copyleft license for
|
11 |
+
software and other kinds of works, specifically designed to ensure
|
12 |
+
cooperation with the community in the case of network server software.
|
13 |
+
|
14 |
+
The licenses for most software and other practical works are designed
|
15 |
+
to take away your freedom to share and change the works. By contrast,
|
16 |
+
our General Public Licenses are intended to guarantee your freedom to
|
17 |
+
share and change all versions of a program--to make sure it remains free
|
18 |
+
software for all its users.
|
19 |
+
|
20 |
+
When we speak of free software, we are referring to freedom, not
|
21 |
+
price. Our General Public Licenses are designed to make sure that you
|
22 |
+
have the freedom to distribute copies of free software (and charge for
|
23 |
+
them if you wish), that you receive source code or can get it if you
|
24 |
+
want it, that you can change the software or use pieces of it in new
|
25 |
+
free programs, and that you know you can do these things.
|
26 |
+
|
27 |
+
Developers that use our General Public Licenses protect your rights
|
28 |
+
with two steps: (1) assert copyright on the software, and (2) offer
|
29 |
+
you this License which gives you legal permission to copy, distribute
|
30 |
+
and/or modify the software.
|
31 |
+
|
32 |
+
A secondary benefit of defending all users' freedom is that
|
33 |
+
improvements made in alternate versions of the program, if they
|
34 |
+
receive widespread use, become available for other developers to
|
35 |
+
incorporate. Many developers of free software are heartened and
|
36 |
+
encouraged by the resulting cooperation. However, in the case of
|
37 |
+
software used on network servers, this result may fail to come about.
|
38 |
+
The GNU General Public License permits making a modified version and
|
39 |
+
letting the public access it on a server without ever releasing its
|
40 |
+
source code to the public.
|
41 |
+
|
42 |
+
The GNU Affero General Public License is designed specifically to
|
43 |
+
ensure that, in such cases, the modified source code becomes available
|
44 |
+
to the community. It requires the operator of a network server to
|
45 |
+
provide the source code of the modified version running there to the
|
46 |
+
users of that server. Therefore, public use of a modified version, on
|
47 |
+
a publicly accessible server, gives the public access to the source
|
48 |
+
code of the modified version.
|
49 |
+
|
50 |
+
An older license, called the Affero General Public License and
|
51 |
+
published by Affero, was designed to accomplish similar goals. This is
|
52 |
+
a different license, not a version of the Affero GPL, but Affero has
|
53 |
+
released a new version of the Affero GPL which permits relicensing under
|
54 |
+
this license.
|
55 |
+
|
56 |
+
The precise terms and conditions for copying, distribution and
|
57 |
+
modification follow.
|
58 |
+
|
59 |
+
TERMS AND CONDITIONS
|
60 |
+
|
61 |
+
0. Definitions.
|
62 |
+
|
63 |
+
"This License" refers to version 3 of the GNU Affero General Public License.
|
64 |
+
|
65 |
+
"Copyright" also means copyright-like laws that apply to other kinds of
|
66 |
+
works, such as semiconductor masks.
|
67 |
+
|
68 |
+
"The Program" refers to any copyrightable work licensed under this
|
69 |
+
License. Each licensee is addressed as "you". "Licensees" and
|
70 |
+
"recipients" may be individuals or organizations.
|
71 |
+
|
72 |
+
To "modify" a work means to copy from or adapt all or part of the work
|
73 |
+
in a fashion requiring copyright permission, other than the making of an
|
74 |
+
exact copy. The resulting work is called a "modified version" of the
|
75 |
+
earlier work or a work "based on" the earlier work.
|
76 |
+
|
77 |
+
A "covered work" means either the unmodified Program or a work based
|
78 |
+
on the Program.
|
79 |
+
|
80 |
+
To "propagate" a work means to do anything with it that, without
|
81 |
+
permission, would make you directly or secondarily liable for
|
82 |
+
infringement under applicable copyright law, except executing it on a
|
83 |
+
computer or modifying a private copy. Propagation includes copying,
|
84 |
+
distribution (with or without modification), making available to the
|
85 |
+
public, and in some countries other activities as well.
|
86 |
+
|
87 |
+
To "convey" a work means any kind of propagation that enables other
|
88 |
+
parties to make or receive copies. Mere interaction with a user through
|
89 |
+
a computer network, with no transfer of a copy, is not conveying.
|
90 |
+
|
91 |
+
An interactive user interface displays "Appropriate Legal Notices"
|
92 |
+
to the extent that it includes a convenient and prominently visible
|
93 |
+
feature that (1) displays an appropriate copyright notice, and (2)
|
94 |
+
tells the user that there is no warranty for the work (except to the
|
95 |
+
extent that warranties are provided), that licensees may convey the
|
96 |
+
work under this License, and how to view a copy of this License. If
|
97 |
+
the interface presents a list of user commands or options, such as a
|
98 |
+
menu, a prominent item in the list meets this criterion.
|
99 |
+
|
100 |
+
1. Source Code.
|
101 |
+
|
102 |
+
The "source code" for a work means the preferred form of the work
|
103 |
+
for making modifications to it. "Object code" means any non-source
|
104 |
+
form of a work.
|
105 |
+
|
106 |
+
A "Standard Interface" means an interface that either is an official
|
107 |
+
standard defined by a recognized standards body, or, in the case of
|
108 |
+
interfaces specified for a particular programming language, one that
|
109 |
+
is widely used among developers working in that language.
|
110 |
+
|
111 |
+
The "System Libraries" of an executable work include anything, other
|
112 |
+
than the work as a whole, that (a) is included in the normal form of
|
113 |
+
packaging a Major Component, but which is not part of that Major
|
114 |
+
Component, and (b) serves only to enable use of the work with that
|
115 |
+
Major Component, or to implement a Standard Interface for which an
|
116 |
+
implementation is available to the public in source code form. A
|
117 |
+
"Major Component", in this context, means a major essential component
|
118 |
+
(kernel, window system, and so on) of the specific operating system
|
119 |
+
(if any) on which the executable work runs, or a compiler used to
|
120 |
+
produce the work, or an object code interpreter used to run it.
|
121 |
+
|
122 |
+
The "Corresponding Source" for a work in object code form means all
|
123 |
+
the source code needed to generate, install, and (for an executable
|
124 |
+
work) run the object code and to modify the work, including scripts to
|
125 |
+
control those activities. However, it does not include the work's
|
126 |
+
System Libraries, or general-purpose tools or generally available free
|
127 |
+
programs which are used unmodified in performing those activities but
|
128 |
+
which are not part of the work. For example, Corresponding Source
|
129 |
+
includes interface definition files associated with source files for
|
130 |
+
the work, and the source code for shared libraries and dynamically
|
131 |
+
linked subprograms that the work is specifically designed to require,
|
132 |
+
such as by intimate data communication or control flow between those
|
133 |
+
subprograms and other parts of the work.
|
134 |
+
|
135 |
+
The Corresponding Source need not include anything that users
|
136 |
+
can regenerate automatically from other parts of the Corresponding
|
137 |
+
Source.
|
138 |
+
|
139 |
+
The Corresponding Source for a work in source code form is that
|
140 |
+
same work.
|
141 |
+
|
142 |
+
2. Basic Permissions.
|
143 |
+
|
144 |
+
All rights granted under this License are granted for the term of
|
145 |
+
copyright on the Program, and are irrevocable provided the stated
|
146 |
+
conditions are met. This License explicitly affirms your unlimited
|
147 |
+
permission to run the unmodified Program. The output from running a
|
148 |
+
covered work is covered by this License only if the output, given its
|
149 |
+
content, constitutes a covered work. This License acknowledges your
|
150 |
+
rights of fair use or other equivalent, as provided by copyright law.
|
151 |
+
|
152 |
+
You may make, run and propagate covered works that you do not
|
153 |
+
convey, without conditions so long as your license otherwise remains
|
154 |
+
in force. You may convey covered works to others for the sole purpose
|
155 |
+
of having them make modifications exclusively for you, or provide you
|
156 |
+
with facilities for running those works, provided that you comply with
|
157 |
+
the terms of this License in conveying all material for which you do
|
158 |
+
not control copyright. Those thus making or running the covered works
|
159 |
+
for you must do so exclusively on your behalf, under your direction
|
160 |
+
and control, on terms that prohibit them from making any copies of
|
161 |
+
your copyrighted material outside their relationship with you.
|
162 |
+
|
163 |
+
Conveying under any other circumstances is permitted solely under
|
164 |
+
the conditions stated below. Sublicensing is not allowed; section 10
|
165 |
+
makes it unnecessary.
|
166 |
+
|
167 |
+
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
168 |
+
|
169 |
+
No covered work shall be deemed part of an effective technological
|
170 |
+
measure under any applicable law fulfilling obligations under article
|
171 |
+
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
172 |
+
similar laws prohibiting or restricting circumvention of such
|
173 |
+
measures.
|
174 |
+
|
175 |
+
When you convey a covered work, you waive any legal power to forbid
|
176 |
+
circumvention of technological measures to the extent such circumvention
|
177 |
+
is effected by exercising rights under this License with respect to
|
178 |
+
the covered work, and you disclaim any intention to limit operation or
|
179 |
+
modification of the work as a means of enforcing, against the work's
|
180 |
+
users, your or third parties' legal rights to forbid circumvention of
|
181 |
+
technological measures.
|
182 |
+
|
183 |
+
4. Conveying Verbatim Copies.
|
184 |
+
|
185 |
+
You may convey verbatim copies of the Program's source code as you
|
186 |
+
receive it, in any medium, provided that you conspicuously and
|
187 |
+
appropriately publish on each copy an appropriate copyright notice;
|
188 |
+
keep intact all notices stating that this License and any
|
189 |
+
non-permissive terms added in accord with section 7 apply to the code;
|
190 |
+
keep intact all notices of the absence of any warranty; and give all
|
191 |
+
recipients a copy of this License along with the Program.
|
192 |
+
|
193 |
+
You may charge any price or no price for each copy that you convey,
|
194 |
+
and you may offer support or warranty protection for a fee.
|
195 |
+
|
196 |
+
5. Conveying Modified Source Versions.
|
197 |
+
|
198 |
+
You may convey a work based on the Program, or the modifications to
|
199 |
+
produce it from the Program, in the form of source code under the
|
200 |
+
terms of section 4, provided that you also meet all of these conditions:
|
201 |
+
|
202 |
+
a) The work must carry prominent notices stating that you modified
|
203 |
+
it, and giving a relevant date.
|
204 |
+
|
205 |
+
b) The work must carry prominent notices stating that it is
|
206 |
+
released under this License and any conditions added under section
|
207 |
+
7. This requirement modifies the requirement in section 4 to
|
208 |
+
"keep intact all notices".
|
209 |
+
|
210 |
+
c) You must license the entire work, as a whole, under this
|
211 |
+
License to anyone who comes into possession of a copy. This
|
212 |
+
License will therefore apply, along with any applicable section 7
|
213 |
+
additional terms, to the whole of the work, and all its parts,
|
214 |
+
regardless of how they are packaged. This License gives no
|
215 |
+
permission to license the work in any other way, but it does not
|
216 |
+
invalidate such permission if you have separately received it.
|
217 |
+
|
218 |
+
d) If the work has interactive user interfaces, each must display
|
219 |
+
Appropriate Legal Notices; however, if the Program has interactive
|
220 |
+
interfaces that do not display Appropriate Legal Notices, your
|
221 |
+
work need not make them do so.
|
222 |
+
|
223 |
+
A compilation of a covered work with other separate and independent
|
224 |
+
works, which are not by their nature extensions of the covered work,
|
225 |
+
and which are not combined with it such as to form a larger program,
|
226 |
+
in or on a volume of a storage or distribution medium, is called an
|
227 |
+
"aggregate" if the compilation and its resulting copyright are not
|
228 |
+
used to limit the access or legal rights of the compilation's users
|
229 |
+
beyond what the individual works permit. Inclusion of a covered work
|
230 |
+
in an aggregate does not cause this License to apply to the other
|
231 |
+
parts of the aggregate.
|
232 |
+
|
233 |
+
6. Conveying Non-Source Forms.
|
234 |
+
|
235 |
+
You may convey a covered work in object code form under the terms
|
236 |
+
of sections 4 and 5, provided that you also convey the
|
237 |
+
machine-readable Corresponding Source under the terms of this License,
|
238 |
+
in one of these ways:
|
239 |
+
|
240 |
+
a) Convey the object code in, or embodied in, a physical product
|
241 |
+
(including a physical distribution medium), accompanied by the
|
242 |
+
Corresponding Source fixed on a durable physical medium
|
243 |
+
customarily used for software interchange.
|
244 |
+
|
245 |
+
b) Convey the object code in, or embodied in, a physical product
|
246 |
+
(including a physical distribution medium), accompanied by a
|
247 |
+
written offer, valid for at least three years and valid for as
|
248 |
+
long as you offer spare parts or customer support for that product
|
249 |
+
model, to give anyone who possesses the object code either (1) a
|
250 |
+
copy of the Corresponding Source for all the software in the
|
251 |
+
product that is covered by this License, on a durable physical
|
252 |
+
medium customarily used for software interchange, for a price no
|
253 |
+
more than your reasonable cost of physically performing this
|
254 |
+
conveying of source, or (2) access to copy the
|
255 |
+
Corresponding Source from a network server at no charge.
|
256 |
+
|
257 |
+
c) Convey individual copies of the object code with a copy of the
|
258 |
+
written offer to provide the Corresponding Source. This
|
259 |
+
alternative is allowed only occasionally and noncommercially, and
|
260 |
+
only if you received the object code with such an offer, in accord
|
261 |
+
with subsection 6b.
|
262 |
+
|
263 |
+
d) Convey the object code by offering access from a designated
|
264 |
+
place (gratis or for a charge), and offer equivalent access to the
|
265 |
+
Corresponding Source in the same way through the same place at no
|
266 |
+
further charge. You need not require recipients to copy the
|
267 |
+
Corresponding Source along with the object code. If the place to
|
268 |
+
copy the object code is a network server, the Corresponding Source
|
269 |
+
may be on a different server (operated by you or a third party)
|
270 |
+
that supports equivalent copying facilities, provided you maintain
|
271 |
+
clear directions next to the object code saying where to find the
|
272 |
+
Corresponding Source. Regardless of what server hosts the
|
273 |
+
Corresponding Source, you remain obligated to ensure that it is
|
274 |
+
available for as long as needed to satisfy these requirements.
|
275 |
+
|
276 |
+
e) Convey the object code using peer-to-peer transmission, provided
|
277 |
+
you inform other peers where the object code and Corresponding
|
278 |
+
Source of the work are being offered to the general public at no
|
279 |
+
charge under subsection 6d.
|
280 |
+
|
281 |
+
A separable portion of the object code, whose source code is excluded
|
282 |
+
from the Corresponding Source as a System Library, need not be
|
283 |
+
included in conveying the object code work.
|
284 |
+
|
285 |
+
A "User Product" is either (1) a "consumer product", which means any
|
286 |
+
tangible personal property which is normally used for personal, family,
|
287 |
+
or household purposes, or (2) anything designed or sold for incorporation
|
288 |
+
into a dwelling. In determining whether a product is a consumer product,
|
289 |
+
doubtful cases shall be resolved in favor of coverage. For a particular
|
290 |
+
product received by a particular user, "normally used" refers to a
|
291 |
+
typical or common use of that class of product, regardless of the status
|
292 |
+
of the particular user or of the way in which the particular user
|
293 |
+
actually uses, or expects or is expected to use, the product. A product
|
294 |
+
is a consumer product regardless of whether the product has substantial
|
295 |
+
commercial, industrial or non-consumer uses, unless such uses represent
|
296 |
+
the only significant mode of use of the product.
|
297 |
+
|
298 |
+
"Installation Information" for a User Product means any methods,
|
299 |
+
procedures, authorization keys, or other information required to install
|
300 |
+
and execute modified versions of a covered work in that User Product from
|
301 |
+
a modified version of its Corresponding Source. The information must
|
302 |
+
suffice to ensure that the continued functioning of the modified object
|
303 |
+
code is in no case prevented or interfered with solely because
|
304 |
+
modification has been made.
|
305 |
+
|
306 |
+
If you convey an object code work under this section in, or with, or
|
307 |
+
specifically for use in, a User Product, and the conveying occurs as
|
308 |
+
part of a transaction in which the right of possession and use of the
|
309 |
+
User Product is transferred to the recipient in perpetuity or for a
|
310 |
+
fixed term (regardless of how the transaction is characterized), the
|
311 |
+
Corresponding Source conveyed under this section must be accompanied
|
312 |
+
by the Installation Information. But this requirement does not apply
|
313 |
+
if neither you nor any third party retains the ability to install
|
314 |
+
modified object code on the User Product (for example, the work has
|
315 |
+
been installed in ROM).
|
316 |
+
|
317 |
+
The requirement to provide Installation Information does not include a
|
318 |
+
requirement to continue to provide support service, warranty, or updates
|
319 |
+
for a work that has been modified or installed by the recipient, or for
|
320 |
+
the User Product in which it has been modified or installed. Access to a
|
321 |
+
network may be denied when the modification itself materially and
|
322 |
+
adversely affects the operation of the network or violates the rules and
|
323 |
+
protocols for communication across the network.
|
324 |
+
|
325 |
+
Corresponding Source conveyed, and Installation Information provided,
|
326 |
+
in accord with this section must be in a format that is publicly
|
327 |
+
documented (and with an implementation available to the public in
|
328 |
+
source code form), and must require no special password or key for
|
329 |
+
unpacking, reading or copying.
|
330 |
+
|
331 |
+
7. Additional Terms.
|
332 |
+
|
333 |
+
"Additional permissions" are terms that supplement the terms of this
|
334 |
+
License by making exceptions from one or more of its conditions.
|
335 |
+
Additional permissions that are applicable to the entire Program shall
|
336 |
+
be treated as though they were included in this License, to the extent
|
337 |
+
that they are valid under applicable law. If additional permissions
|
338 |
+
apply only to part of the Program, that part may be used separately
|
339 |
+
under those permissions, but the entire Program remains governed by
|
340 |
+
this License without regard to the additional permissions.
|
341 |
+
|
342 |
+
When you convey a copy of a covered work, you may at your option
|
343 |
+
remove any additional permissions from that copy, or from any part of
|
344 |
+
it. (Additional permissions may be written to require their own
|
345 |
+
removal in certain cases when you modify the work.) You may place
|
346 |
+
additional permissions on material, added by you to a covered work,
|
347 |
+
for which you have or can give appropriate copyright permission.
|
348 |
+
|
349 |
+
Notwithstanding any other provision of this License, for material you
|
350 |
+
add to a covered work, you may (if authorized by the copyright holders of
|
351 |
+
that material) supplement the terms of this License with terms:
|
352 |
+
|
353 |
+
a) Disclaiming warranty or limiting liability differently from the
|
354 |
+
terms of sections 15 and 16 of this License; or
|
355 |
+
|
356 |
+
b) Requiring preservation of specified reasonable legal notices or
|
357 |
+
author attributions in that material or in the Appropriate Legal
|
358 |
+
Notices displayed by works containing it; or
|
359 |
+
|
360 |
+
c) Prohibiting misrepresentation of the origin of that material, or
|
361 |
+
requiring that modified versions of such material be marked in
|
362 |
+
reasonable ways as different from the original version; or
|
363 |
+
|
364 |
+
d) Limiting the use for publicity purposes of names of licensors or
|
365 |
+
authors of the material; or
|
366 |
+
|
367 |
+
e) Declining to grant rights under trademark law for use of some
|
368 |
+
trade names, trademarks, or service marks; or
|
369 |
+
|
370 |
+
f) Requiring indemnification of licensors and authors of that
|
371 |
+
material by anyone who conveys the material (or modified versions of
|
372 |
+
it) with contractual assumptions of liability to the recipient, for
|
373 |
+
any liability that these contractual assumptions directly impose on
|
374 |
+
those licensors and authors.
|
375 |
+
|
376 |
+
All other non-permissive additional terms are considered "further
|
377 |
+
restrictions" within the meaning of section 10. If the Program as you
|
378 |
+
received it, or any part of it, contains a notice stating that it is
|
379 |
+
governed by this License along with a term that is a further
|
380 |
+
restriction, you may remove that term. If a license document contains
|
381 |
+
a further restriction but permits relicensing or conveying under this
|
382 |
+
License, you may add to a covered work material governed by the terms
|
383 |
+
of that license document, provided that the further restriction does
|
384 |
+
not survive such relicensing or conveying.
|
385 |
+
|
386 |
+
If you add terms to a covered work in accord with this section, you
|
387 |
+
must place, in the relevant source files, a statement of the
|
388 |
+
additional terms that apply to those files, or a notice indicating
|
389 |
+
where to find the applicable terms.
|
390 |
+
|
391 |
+
Additional terms, permissive or non-permissive, may be stated in the
|
392 |
+
form of a separately written license, or stated as exceptions;
|
393 |
+
the above requirements apply either way.
|
394 |
+
|
395 |
+
8. Termination.
|
396 |
+
|
397 |
+
You may not propagate or modify a covered work except as expressly
|
398 |
+
provided under this License. Any attempt otherwise to propagate or
|
399 |
+
modify it is void, and will automatically terminate your rights under
|
400 |
+
this License (including any patent licenses granted under the third
|
401 |
+
paragraph of section 11).
|
402 |
+
|
403 |
+
However, if you cease all violation of this License, then your
|
404 |
+
license from a particular copyright holder is reinstated (a)
|
405 |
+
provisionally, unless and until the copyright holder explicitly and
|
406 |
+
finally terminates your license, and (b) permanently, if the copyright
|
407 |
+
holder fails to notify you of the violation by some reasonable means
|
408 |
+
prior to 60 days after the cessation.
|
409 |
+
|
410 |
+
Moreover, your license from a particular copyright holder is
|
411 |
+
reinstated permanently if the copyright holder notifies you of the
|
412 |
+
violation by some reasonable means, this is the first time you have
|
413 |
+
received notice of violation of this License (for any work) from that
|
414 |
+
copyright holder, and you cure the violation prior to 30 days after
|
415 |
+
your receipt of the notice.
|
416 |
+
|
417 |
+
Termination of your rights under this section does not terminate the
|
418 |
+
licenses of parties who have received copies or rights from you under
|
419 |
+
this License. If your rights have been terminated and not permanently
|
420 |
+
reinstated, you do not qualify to receive new licenses for the same
|
421 |
+
material under section 10.
|
422 |
+
|
423 |
+
9. Acceptance Not Required for Having Copies.
|
424 |
+
|
425 |
+
You are not required to accept this License in order to receive or
|
426 |
+
run a copy of the Program. Ancillary propagation of a covered work
|
427 |
+
occurring solely as a consequence of using peer-to-peer transmission
|
428 |
+
to receive a copy likewise does not require acceptance. However,
|
429 |
+
nothing other than this License grants you permission to propagate or
|
430 |
+
modify any covered work. These actions infringe copyright if you do
|
431 |
+
not accept this License. Therefore, by modifying or propagating a
|
432 |
+
covered work, you indicate your acceptance of this License to do so.
|
433 |
+
|
434 |
+
10. Automatic Licensing of Downstream Recipients.
|
435 |
+
|
436 |
+
Each time you convey a covered work, the recipient automatically
|
437 |
+
receives a license from the original licensors, to run, modify and
|
438 |
+
propagate that work, subject to this License. You are not responsible
|
439 |
+
for enforcing compliance by third parties with this License.
|
440 |
+
|
441 |
+
An "entity transaction" is a transaction transferring control of an
|
442 |
+
organization, or substantially all assets of one, or subdividing an
|
443 |
+
organization, or merging organizations. If propagation of a covered
|
444 |
+
work results from an entity transaction, each party to that
|
445 |
+
transaction who receives a copy of the work also receives whatever
|
446 |
+
licenses to the work the party's predecessor in interest had or could
|
447 |
+
give under the previous paragraph, plus a right to possession of the
|
448 |
+
Corresponding Source of the work from the predecessor in interest, if
|
449 |
+
the predecessor has it or can get it with reasonable efforts.
|
450 |
+
|
451 |
+
You may not impose any further restrictions on the exercise of the
|
452 |
+
rights granted or affirmed under this License. For example, you may
|
453 |
+
not impose a license fee, royalty, or other charge for exercise of
|
454 |
+
rights granted under this License, and you may not initiate litigation
|
455 |
+
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
456 |
+
any patent claim is infringed by making, using, selling, offering for
|
457 |
+
sale, or importing the Program or any portion of it.
|
458 |
+
|
459 |
+
11. Patents.
|
460 |
+
|
461 |
+
A "contributor" is a copyright holder who authorizes use under this
|
462 |
+
License of the Program or a work on which the Program is based. The
|
463 |
+
work thus licensed is called the contributor's "contributor version".
|
464 |
+
|
465 |
+
A contributor's "essential patent claims" are all patent claims
|
466 |
+
owned or controlled by the contributor, whether already acquired or
|
467 |
+
hereafter acquired, that would be infringed by some manner, permitted
|
468 |
+
by this License, of making, using, or selling its contributor version,
|
469 |
+
but do not include claims that would be infringed only as a
|
470 |
+
consequence of further modification of the contributor version. For
|
471 |
+
purposes of this definition, "control" includes the right to grant
|
472 |
+
patent sublicenses in a manner consistent with the requirements of
|
473 |
+
this License.
|
474 |
+
|
475 |
+
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
476 |
+
patent license under the contributor's essential patent claims, to
|
477 |
+
make, use, sell, offer for sale, import and otherwise run, modify and
|
478 |
+
propagate the contents of its contributor version.
|
479 |
+
|
480 |
+
In the following three paragraphs, a "patent license" is any express
|
481 |
+
agreement or commitment, however denominated, not to enforce a patent
|
482 |
+
(such as an express permission to practice a patent or covenant not to
|
483 |
+
sue for patent infringement). To "grant" such a patent license to a
|
484 |
+
party means to make such an agreement or commitment not to enforce a
|
485 |
+
patent against the party.
|
486 |
+
|
487 |
+
If you convey a covered work, knowingly relying on a patent license,
|
488 |
+
and the Corresponding Source of the work is not available for anyone
|
489 |
+
to copy, free of charge and under the terms of this License, through a
|
490 |
+
publicly available network server or other readily accessible means,
|
491 |
+
then you must either (1) cause the Corresponding Source to be so
|
492 |
+
available, or (2) arrange to deprive yourself of the benefit of the
|
493 |
+
patent license for this particular work, or (3) arrange, in a manner
|
494 |
+
consistent with the requirements of this License, to extend the patent
|
495 |
+
license to downstream recipients. "Knowingly relying" means you have
|
496 |
+
actual knowledge that, but for the patent license, your conveying the
|
497 |
+
covered work in a country, or your recipient's use of the covered work
|
498 |
+
in a country, would infringe one or more identifiable patents in that
|
499 |
+
country that you have reason to believe are valid.
|
500 |
+
|
501 |
+
If, pursuant to or in connection with a single transaction or
|
502 |
+
arrangement, you convey, or propagate by procuring conveyance of, a
|
503 |
+
covered work, and grant a patent license to some of the parties
|
504 |
+
receiving the covered work authorizing them to use, propagate, modify
|
505 |
+
or convey a specific copy of the covered work, then the patent license
|
506 |
+
you grant is automatically extended to all recipients of the covered
|
507 |
+
work and works based on it.
|
508 |
+
|
509 |
+
A patent license is "discriminatory" if it does not include within
|
510 |
+
the scope of its coverage, prohibits the exercise of, or is
|
511 |
+
conditioned on the non-exercise of one or more of the rights that are
|
512 |
+
specifically granted under this License. You may not convey a covered
|
513 |
+
work if you are a party to an arrangement with a third party that is
|
514 |
+
in the business of distributing software, under which you make payment
|
515 |
+
to the third party based on the extent of your activity of conveying
|
516 |
+
the work, and under which the third party grants, to any of the
|
517 |
+
parties who would receive the covered work from you, a discriminatory
|
518 |
+
patent license (a) in connection with copies of the covered work
|
519 |
+
conveyed by you (or copies made from those copies), or (b) primarily
|
520 |
+
for and in connection with specific products or compilations that
|
521 |
+
contain the covered work, unless you entered into that arrangement,
|
522 |
+
or that patent license was granted, prior to 28 March 2007.
|
523 |
+
|
524 |
+
Nothing in this License shall be construed as excluding or limiting
|
525 |
+
any implied license or other defenses to infringement that may
|
526 |
+
otherwise be available to you under applicable patent law.
|
527 |
+
|
528 |
+
12. No Surrender of Others' Freedom.
|
529 |
+
|
530 |
+
If conditions are imposed on you (whether by court order, agreement or
|
531 |
+
otherwise) that contradict the conditions of this License, they do not
|
532 |
+
excuse you from the conditions of this License. If you cannot convey a
|
533 |
+
covered work so as to satisfy simultaneously your obligations under this
|
534 |
+
License and any other pertinent obligations, then as a consequence you may
|
535 |
+
not convey it at all. For example, if you agree to terms that obligate you
|
536 |
+
to collect a royalty for further conveying from those to whom you convey
|
537 |
+
the Program, the only way you could satisfy both those terms and this
|
538 |
+
License would be to refrain entirely from conveying the Program.
|
539 |
+
|
540 |
+
13. Remote Network Interaction; Use with the GNU General Public License.
|
541 |
+
|
542 |
+
Notwithstanding any other provision of this License, if you modify the
|
543 |
+
Program, your modified version must prominently offer all users
|
544 |
+
interacting with it remotely through a computer network (if your version
|
545 |
+
supports such interaction) an opportunity to receive the Corresponding
|
546 |
+
Source of your version by providing access to the Corresponding Source
|
547 |
+
from a network server at no charge, through some standard or customary
|
548 |
+
means of facilitating copying of software. This Corresponding Source
|
549 |
+
shall include the Corresponding Source for any work covered by version 3
|
550 |
+
of the GNU General Public License that is incorporated pursuant to the
|
551 |
+
following paragraph.
|
552 |
+
|
553 |
+
Notwithstanding any other provision of this License, you have
|
554 |
+
permission to link or combine any covered work with a work licensed
|
555 |
+
under version 3 of the GNU General Public License into a single
|
556 |
+
combined work, and to convey the resulting work. The terms of this
|
557 |
+
License will continue to apply to the part which is the covered work,
|
558 |
+
but the work with which it is combined will remain governed by version
|
559 |
+
3 of the GNU General Public License.
|
560 |
+
|
561 |
+
14. Revised Versions of this License.
|
562 |
+
|
563 |
+
The Free Software Foundation may publish revised and/or new versions of
|
564 |
+
the GNU Affero General Public License from time to time. Such new versions
|
565 |
+
will be similar in spirit to the present version, but may differ in detail to
|
566 |
+
address new problems or concerns.
|
567 |
+
|
568 |
+
Each version is given a distinguishing version number. If the
|
569 |
+
Program specifies that a certain numbered version of the GNU Affero General
|
570 |
+
Public License "or any later version" applies to it, you have the
|
571 |
+
option of following the terms and conditions either of that numbered
|
572 |
+
version or of any later version published by the Free Software
|
573 |
+
Foundation. If the Program does not specify a version number of the
|
574 |
+
GNU Affero General Public License, you may choose any version ever published
|
575 |
+
by the Free Software Foundation.
|
576 |
+
|
577 |
+
If the Program specifies that a proxy can decide which future
|
578 |
+
versions of the GNU Affero General Public License can be used, that proxy's
|
579 |
+
public statement of acceptance of a version permanently authorizes you
|
580 |
+
to choose that version for the Program.
|
581 |
+
|
582 |
+
Later license versions may give you additional or different
|
583 |
+
permissions. However, no additional obligations are imposed on any
|
584 |
+
author or copyright holder as a result of your choosing to follow a
|
585 |
+
later version.
|
586 |
+
|
587 |
+
15. Disclaimer of Warranty.
|
588 |
+
|
589 |
+
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
590 |
+
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
591 |
+
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
592 |
+
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
593 |
+
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
594 |
+
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
595 |
+
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
596 |
+
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
597 |
+
|
598 |
+
16. Limitation of Liability.
|
599 |
+
|
600 |
+
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
601 |
+
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
602 |
+
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
603 |
+
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
604 |
+
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
605 |
+
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
606 |
+
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
607 |
+
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
608 |
+
SUCH DAMAGES.
|
609 |
+
|
610 |
+
17. Interpretation of Sections 15 and 16.
|
611 |
+
|
612 |
+
If the disclaimer of warranty and limitation of liability provided
|
613 |
+
above cannot be given local legal effect according to their terms,
|
614 |
+
reviewing courts shall apply local law that most closely approximates
|
615 |
+
an absolute waiver of all civil liability in connection with the
|
616 |
+
Program, unless a warranty or assumption of liability accompanies a
|
617 |
+
copy of the Program in return for a fee.
|
618 |
+
|
619 |
+
END OF TERMS AND CONDITIONS
|
620 |
+
|
621 |
+
How to Apply These Terms to Your New Programs
|
622 |
+
|
623 |
+
If you develop a new program, and you want it to be of the greatest
|
624 |
+
possible use to the public, the best way to achieve this is to make it
|
625 |
+
free software which everyone can redistribute and change under these terms.
|
626 |
+
|
627 |
+
To do so, attach the following notices to the program. It is safest
|
628 |
+
to attach them to the start of each source file to most effectively
|
629 |
+
state the exclusion of warranty; and each file should have at least
|
630 |
+
the "copyright" line and a pointer to where the full notice is found.
|
631 |
+
|
632 |
+
<one line to give the program's name and a brief idea of what it does.>
|
633 |
+
Copyright (C) <year> <name of author>
|
634 |
+
|
635 |
+
This program is free software: you can redistribute it and/or modify
|
636 |
+
it under the terms of the GNU Affero General Public License as published
|
637 |
+
by the Free Software Foundation, either version 3 of the License, or
|
638 |
+
(at your option) any later version.
|
639 |
+
|
640 |
+
This program is distributed in the hope that it will be useful,
|
641 |
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
642 |
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
643 |
+
GNU Affero General Public License for more details.
|
644 |
+
|
645 |
+
You should have received a copy of the GNU Affero General Public License
|
646 |
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
647 |
+
|
648 |
+
Also add information on how to contact you by electronic and paper mail.
|
649 |
+
|
650 |
+
If your software can interact with users remotely through a computer
|
651 |
+
network, you should also make sure that it provides a way for users to
|
652 |
+
get its source. For example, if your program is a web application, its
|
653 |
+
interface could display a "Source" link that leads users to an archive
|
654 |
+
of the code. There are many ways you could offer source, and different
|
655 |
+
solutions will be better for different programs; see section 13 for the
|
656 |
+
specific requirements.
|
657 |
+
|
658 |
+
You should also get your employer (if you work as a programmer) or school,
|
659 |
+
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
660 |
+
For more information on this, and how to apply and follow the GNU AGPL, see
|
661 |
+
<https://www.gnu.org/licenses/>.
|
README.md
CHANGED
@@ -9,4 +9,41 @@ license: agpl-3.0
|
|
9 |
short_description: A chat application with flows, agents and MCP support.
|
10 |
---
|
11 |
|
12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
9 |
short_description: A chat application with flows, agents and MCP support.
|
10 |
---
|
11 |
|
12 |
+
# AIFlow Chat
|
13 |
+
|
14 |
+
A chat application with flows, agents and MCP support, that interacts with AI models via the OpenAI-compatible API.
|
15 |
+
|
16 |
+
> **Warning**: This tool is in development, made as a spare time project, and no warranty is given for any functionality.
|
17 |
+
|
18 |
+
Key capabilities include:
|
19 |
+
- **AI Interactions**: Stream responses from AI models. Generate text, tables, code, formulas (via LaTeX), and simple SVG images/charts.
|
20 |
+
- **Tool Support**: Integrate external tools (e.g., web search) using MCP for real-time data fetching.
|
21 |
+
- **Formatting & Usability**: Render Markdown, highlight code, display math equations, and add citations for tool outputs.
|
22 |
+
- **Chat Management**: Multiple chats with save/load, editing/deleting messages, alternative responses, and branching conversations.
|
23 |
+
|
24 |
+
### Usage
|
25 |
+
|
26 |
+
#### Online Demo
|
27 |
+
Test a (sometimes outdated version) at: [https://huggingface.co/spaces/dma123/aiflow-chat](https://huggingface.co/spaces/dma123/aiflow-chat).
|
28 |
+
|
29 |
+
#### Local Setup
|
30 |
+
1. Clone the repo: `git clone https://github.com/dmatscheko/aiflow-chat.git`
|
31 |
+
2. Run a simple HTTP server: `python -m http.server 8000` (or use any static file server).
|
32 |
+
3. Open `http://localhost:8000` in your browser.
|
33 |
+
|
34 |
+
#### With MCP (for Tools)
|
35 |
+
To enable advanced tools like web/X search:
|
36 |
+
1. Clone the repo: `git clone https://github.com/dmatscheko/aiflow-chat.git`
|
37 |
+
2. Customize tools via `mcp_config.json`.
|
38 |
+
3. Run: `uv run main.py` (or `pip install fastmcp fastapi` and `python main.py`). This starts a web server at http://localhost:8000, a local MCP proxy at `http://localhost:3000/mcp`, and auto-configures the app.
|
39 |
+
4. Open `http://localhost:8000` in your browser, if it is not automatically openend.
|
40 |
+
|
41 |
+
#### Controls
|
42 |
+
- **Input**: Type messages; use Shift+Enter (or Ctrl/Alt+Enter) to submit. Press Esc to abort AI responses.
|
43 |
+
- **Chats**: Sidebar for multiple chats; edit titles, add/delete messages, navigate alternatives.
|
44 |
+
- **Settings**: Tune sampling (temperature/top-p), manage API endpoints, refresh models, select role (user/system/assistant/tool).
|
45 |
+
- **Avatars**: Click message avatars to upload custom images (stored locally).
|
46 |
+
|
47 |
+
### Screenshot
|
48 |
+
This screenshot was "randomly selected" because its output was ok-ish ;)
|
49 |
+

|
_mcp_filesystem/read_write/guess_number.py
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import random
|
2 |
+
|
3 |
+
|
4 |
+
def guess_the_number():
|
5 |
+
"""Simple text-based 'Guess the Number' game."""
|
6 |
+
target = random.randint(1, 100)
|
7 |
+
attempts = 7
|
8 |
+
guessed = False
|
9 |
+
|
10 |
+
print("I'm thinking of a number between 1 and 100. You have 7 attempts to guess it.")
|
11 |
+
|
12 |
+
while attempts > 0 and not guessed:
|
13 |
+
try:
|
14 |
+
guess = int(input(f"Enter your guess (attempts left: {attempts}): "))
|
15 |
+
attempts -= 1
|
16 |
+
|
17 |
+
if guess == target:
|
18 |
+
print(f"🎉 Congratulations! You guessed the number {target} correctly!")
|
19 |
+
guessed = True
|
20 |
+
elif guess < target:
|
21 |
+
print("Too low! Try again.")
|
22 |
+
else:
|
23 |
+
print("Too high! Try again.")
|
24 |
+
|
25 |
+
except ValueError:
|
26 |
+
print("Please enter a valid integer.")
|
27 |
+
attempts += 1 # No penalty for invalid input
|
28 |
+
|
29 |
+
if not guessed:
|
30 |
+
print(f"❌ Game over! The number was {target}.")
|
31 |
+
|
32 |
+
|
33 |
+
# Run the game
|
34 |
+
if __name__ == "__main__":
|
35 |
+
guess_the_number()
|
_mcp_filesystem/read_write/poem.md
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
In twilight's hush, where shadows play,
|
2 |
+
A single star begins to sway.
|
3 |
+
It whispers tales of distant skies,
|
4 |
+
Beneath the moon's soft, silver eyes.
|
5 |
+
|
6 |
+
The wind, a bard with gentle breath,
|
7 |
+
Sings secrets only night can tell.
|
8 |
+
And in the stillness, hearts take flight—
|
9 |
+
Bathed in the glow of endless night.
|
_mcp_servers/datetime-mcp/datetime-mcp.py
ADDED
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
This is an MCP server that provides a tool to get the current date and time.
|
3 |
+
"""
|
4 |
+
|
5 |
+
from fastmcp import FastMCP
|
6 |
+
import datetime
|
7 |
+
|
8 |
+
# Create an MCP server instance
|
9 |
+
mcp = FastMCP("Date and Time Server")
|
10 |
+
|
11 |
+
|
12 |
+
@mcp.tool()
|
13 |
+
def get_current_datetime() -> str:
|
14 |
+
"""
|
15 |
+
Get the current date and time in ISO format.
|
16 |
+
"""
|
17 |
+
return datetime.datetime.now().isoformat()
|
18 |
+
|
19 |
+
|
20 |
+
if __name__ == "__main__":
|
21 |
+
# Run the MCP server
|
22 |
+
mcp.run()
|
_mcp_servers/fetch-mcp/fetch-mcp.py
ADDED
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
This is an MCP server that provides a tool to get a web page.
|
3 |
+
"""
|
4 |
+
|
5 |
+
import urllib3
|
6 |
+
from fastmcp import FastMCP
|
7 |
+
import requests
|
8 |
+
|
9 |
+
# Suppress SSL warnings
|
10 |
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
11 |
+
|
12 |
+
mcp = FastMCP("Web Page Fetcher")
|
13 |
+
|
14 |
+
|
15 |
+
@mcp.tool
|
16 |
+
def fetch_web_page(url: str) -> str:
|
17 |
+
"""
|
18 |
+
Fetch the content of a web page given its URL. Supports HTTP and HTTPS, ignoring invalid SSL certificates.
|
19 |
+
"""
|
20 |
+
response = requests.get(url, verify=False)
|
21 |
+
response.raise_for_status() # Raise an error for bad status codes
|
22 |
+
return response.text
|
23 |
+
|
24 |
+
|
25 |
+
if __name__ == "__main__":
|
26 |
+
mcp.run()
|
_mcp_servers/file_watcher-mcp/file_watcher-mcp.py
ADDED
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastmcp import FastMCP
|
2 |
+
import os
|
3 |
+
import sys
|
4 |
+
import time
|
5 |
+
from pydantic import Field
|
6 |
+
from typing import Annotated, Optional
|
7 |
+
|
8 |
+
# Global variable for the watched directory
|
9 |
+
_watched_dir: Optional[str] = None
|
10 |
+
|
11 |
+
def set_watched_dir(real_dir: str) -> None:
|
12 |
+
"""Configure the watched directory."""
|
13 |
+
global _watched_dir
|
14 |
+
_watched_dir = os.path.abspath(os.path.expanduser(real_dir))
|
15 |
+
|
16 |
+
# Server setup
|
17 |
+
mcp = FastMCP(
|
18 |
+
name="File Watcher Server",
|
19 |
+
instructions="A server that provides a tool to wait for a file to appear in a specific directory, read its content, and then delete it.",
|
20 |
+
)
|
21 |
+
|
22 |
+
|
23 |
+
@mcp.tool
|
24 |
+
def wait_for_and_read_file(
|
25 |
+
filename: Annotated[str, Field(description="The name of the file to wait for in the watched directory.")],
|
26 |
+
timeout: Annotated[Optional[str], Field(description="The maximum number of seconds to wait for the file.")] = "60",
|
27 |
+
) -> str:
|
28 |
+
"""
|
29 |
+
Waits for a specific file to appear in the watched directory, reads its content,
|
30 |
+
deletes it, and returns the content.
|
31 |
+
"""
|
32 |
+
if _watched_dir is None:
|
33 |
+
return "Error: The watched directory is not configured."
|
34 |
+
|
35 |
+
file_path = os.path.join(_watched_dir, filename)
|
36 |
+
|
37 |
+
timeout_seconds = 60
|
38 |
+
if timeout is not None:
|
39 |
+
try:
|
40 |
+
timeout_seconds = int(timeout)
|
41 |
+
except (ValueError, TypeError):
|
42 |
+
pass
|
43 |
+
|
44 |
+
|
45 |
+
start_time = time.time()
|
46 |
+
while not os.path.exists(file_path):
|
47 |
+
if time.time() - start_time > timeout_seconds:
|
48 |
+
return f"Error: Timed out after {timeout_seconds} seconds waiting for {filename}"
|
49 |
+
time.sleep(1)
|
50 |
+
|
51 |
+
try:
|
52 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
53 |
+
content = f.read()
|
54 |
+
os.remove(file_path)
|
55 |
+
return content
|
56 |
+
except Exception as e:
|
57 |
+
return f"Error reading or deleting file {filename}: {e}"
|
58 |
+
|
59 |
+
|
60 |
+
# Run the server with the watched directory from command-line arguments.
|
61 |
+
if len(sys.argv) != 2:
|
62 |
+
print("Usage: file-watcher-mcp <watched-directory>")
|
63 |
+
sys.exit(1)
|
64 |
+
|
65 |
+
real_dir = sys.argv[1]
|
66 |
+
if not os.path.isdir(real_dir):
|
67 |
+
print(f"Error: {real_dir} is not a directory")
|
68 |
+
sys.exit(1)
|
69 |
+
|
70 |
+
set_watched_dir(real_dir)
|
71 |
+
print(f"MCP File Watcher Server running on stdio\nWatching directory: {_watched_dir}")
|
72 |
+
|
73 |
+
if __name__ == "__main__":
|
74 |
+
mcp.run()
|
_mcp_servers/filesystem_read-mcp/filesystem_read-mcp.py
ADDED
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from collections import deque
|
2 |
+
from datetime import datetime
|
3 |
+
import difflib
|
4 |
+
import fnmatch
|
5 |
+
from fastmcp import FastMCP
|
6 |
+
import os
|
7 |
+
from pydantic import BaseModel, Field, ValidationError
|
8 |
+
import re
|
9 |
+
import sys
|
10 |
+
from typing import Annotated, Dict, List, Optional
|
11 |
+
|
12 |
+
|
13 |
+
# Custom error class
|
14 |
+
class CustomFileSystemError(ValueError):
|
15 |
+
"""Custom error for filesystem operations."""
|
16 |
+
|
17 |
+
pass
|
18 |
+
|
19 |
+
|
20 |
+
# Global mappings for directory access
|
21 |
+
_allowed_real_dirs: List[str] = [] # Real file system paths
|
22 |
+
_virtual_to_real: Dict[str, str] = {} # Virtual path -> Real path
|
23 |
+
_real_to_virtual: Dict[str, str] = {} # Real path -> Virtual path
|
24 |
+
|
25 |
+
|
26 |
+
def set_allowed_dirs(real_dirs: List[str]) -> None:
|
27 |
+
"""Configure allowed real directories and map them to virtual paths (e.g., /data/a)."""
|
28 |
+
global _allowed_real_dirs, _virtual_to_real, _real_to_virtual
|
29 |
+
_allowed_real_dirs = [os.path.abspath(os.path.expanduser(d)) for d in real_dirs]
|
30 |
+
_virtual_to_real = {f"/data/{chr(97 + i)}": real_dir for i, real_dir in enumerate(_allowed_real_dirs)}
|
31 |
+
_real_to_virtual = {real_dir: virtual_dir for virtual_dir, real_dir in _virtual_to_real.items()}
|
32 |
+
|
33 |
+
|
34 |
+
def validate_virtual_path(virtual_path: str) -> str:
|
35 |
+
"""Convert a virtual path to a real path, ensuring it’s within allowed directories."""
|
36 |
+
for virtual_dir, real_dir in _virtual_to_real.items():
|
37 |
+
if virtual_path.startswith(virtual_dir + "/") or virtual_path == virtual_dir:
|
38 |
+
relative = virtual_path[len(virtual_dir) :].lstrip("/")
|
39 |
+
real_path = os.path.join(real_dir, relative) if relative else real_dir
|
40 |
+
break
|
41 |
+
else:
|
42 |
+
raise CustomFileSystemError(f"Not a valid path (List allowed directories for valid paths): {virtual_path}")
|
43 |
+
|
44 |
+
real_path = os.path.normpath(os.path.abspath(real_path))
|
45 |
+
try:
|
46 |
+
resolved_real_path = os.path.realpath(real_path)
|
47 |
+
if any(resolved_real_path.startswith(d + os.sep) or resolved_real_path == d for d in _allowed_real_dirs):
|
48 |
+
return resolved_real_path
|
49 |
+
raise PermissionError("Access denied")
|
50 |
+
except FileNotFoundError:
|
51 |
+
real_parent = os.path.realpath(os.path.dirname(real_path))
|
52 |
+
if not os.path.exists(real_parent):
|
53 |
+
raise FileNotFoundError("Parent directory not found")
|
54 |
+
if any(real_parent.startswith(d + os.sep) or real_parent == d for d in _allowed_real_dirs):
|
55 |
+
return real_path
|
56 |
+
raise PermissionError("Access denied")
|
57 |
+
|
58 |
+
|
59 |
+
def get_error_message(message, virtual_path: str, e: Exception) -> str:
|
60 |
+
"""Generate a user-friendly error message using the virtual path."""
|
61 |
+
virtual_path = virtual_path or "Unknown path"
|
62 |
+
if isinstance(e, FileNotFoundError):
|
63 |
+
return f"{message}: No such file or directory (List allowed directories for valid paths): {virtual_path}"
|
64 |
+
elif isinstance(e, PermissionError):
|
65 |
+
return f"{message}: Permission denied (List allowed directories for valid paths): {virtual_path}"
|
66 |
+
elif isinstance(e, IsADirectoryError):
|
67 |
+
return f"{message}: Is a directory: {virtual_path}"
|
68 |
+
elif isinstance(e, NotADirectoryError):
|
69 |
+
return f"{message}: Not a valid directory (List allowed directories for valid paths): {virtual_path}"
|
70 |
+
elif isinstance(e, FileExistsError):
|
71 |
+
return f"{message}: File already exists: {virtual_path}"
|
72 |
+
elif isinstance(e, CustomFileSystemError):
|
73 |
+
return f"{message}: {e}"
|
74 |
+
elif isinstance(e, ValidationError):
|
75 |
+
errors = e.errors()
|
76 |
+
error_details = "; ".join(f"{err['loc'][0]}: {err['msg']}" for err in errors)
|
77 |
+
return f"{message}: Input validation error: {error_details}"
|
78 |
+
elif isinstance(e, ValueError):
|
79 |
+
return f"{message}: Invalid value: {virtual_path}"
|
80 |
+
else:
|
81 |
+
return f"{message}: {virtual_path}"
|
82 |
+
|
83 |
+
|
84 |
+
# File operation helpers
|
85 |
+
def head_file(real_path: str, lines: int) -> str:
|
86 |
+
"""Read first N lines of a file."""
|
87 |
+
with open(real_path, "r", encoding="utf-8") as f:
|
88 |
+
return "".join(line for i, line in enumerate(f) if i < lines)
|
89 |
+
|
90 |
+
|
91 |
+
def tail_file(real_path: str, lines: int) -> str:
|
92 |
+
"""Read last N lines of a file."""
|
93 |
+
with open(real_path, "r", encoding="utf-8") as f:
|
94 |
+
return "".join(deque(f, maxlen=lines))
|
95 |
+
|
96 |
+
|
97 |
+
def apply_edits(virtual_path: str, edits: List[Dict[str, str]], dry_run: bool) -> str:
|
98 |
+
"""Apply text replacements and return a diff."""
|
99 |
+
real_path = validate_virtual_path(virtual_path)
|
100 |
+
with open(real_path, "r", encoding="utf-8") as f:
|
101 |
+
content = new_content = f.read()
|
102 |
+
for edit in edits:
|
103 |
+
pattern = rf"^{re.escape(edit['oldText'])}(\r?\n|\r|$)"
|
104 |
+
new_content = re.sub(pattern, lambda m: edit["newText"] + m.group(1), new_content, flags=re.MULTILINE)
|
105 |
+
diff = "".join(difflib.unified_diff(content.splitlines(keepends=True), new_content.splitlines(keepends=True), fromfile=virtual_path, tofile=virtual_path))
|
106 |
+
if not dry_run:
|
107 |
+
with open(real_path, "w", encoding="utf-8") as f:
|
108 |
+
f.write(new_content)
|
109 |
+
return diff
|
110 |
+
|
111 |
+
|
112 |
+
def list_files_recursive(virtual_path: str, pattern: Optional[str] = None, exclude_patterns: Optional[List[str]] = None) -> str:
|
113 |
+
"""List files and directories recursively, optionally filtering by pattern."""
|
114 |
+
real_path = validate_virtual_path(virtual_path)
|
115 |
+
matches = []
|
116 |
+
for root, dirs, files in os.walk(real_path):
|
117 |
+
if exclude_patterns:
|
118 |
+
dirs[:] = [d for d in dirs if not any(fnmatch.fnmatch(d, p) for p in exclude_patterns)]
|
119 |
+
files = [f for f in files if not any(fnmatch.fnmatch(f, p) for p in exclude_patterns)]
|
120 |
+
rel_root = os.path.relpath(root, real_path) if root != real_path else ""
|
121 |
+
for name in dirs + files:
|
122 |
+
if pattern is None or fnmatch.fnmatch(name.lower(), pattern.lower()):
|
123 |
+
rel_path = os.path.join(rel_root, name).replace(os.sep, "/")
|
124 |
+
if os.path.isdir(os.path.join(root, name)):
|
125 |
+
rel_path += "/"
|
126 |
+
matches.append(rel_path)
|
127 |
+
return "\n".join([f"### Contents of {virtual_path}:"] + sorted(matches))
|
128 |
+
|
129 |
+
|
130 |
+
# Tool argument models
|
131 |
+
class EditOp(BaseModel):
|
132 |
+
oldText: str = Field(..., description="Line to be replaced")
|
133 |
+
newText: str = Field(..., description="Replacement line")
|
134 |
+
|
135 |
+
|
136 |
+
# Server setup
|
137 |
+
mcp = FastMCP(
|
138 |
+
name="File System Server",
|
139 |
+
instructions="A server that provides tools for interacting with a file system. Only some paths are accessible, therefore the allowed directores must be listed initially.",
|
140 |
+
)
|
141 |
+
|
142 |
+
|
143 |
+
@mcp.tool
|
144 |
+
def read_file(
|
145 |
+
path: Annotated[str, Field(description="The virtual path of the file to read.")],
|
146 |
+
head: Annotated[Optional[int], Field(description="The number of lines to read from the beginning of the file.")] = None,
|
147 |
+
tail: Annotated[Optional[int], Field(description="The number of lines to read from the end of the file.")] = None,
|
148 |
+
) -> str:
|
149 |
+
"""Read file contents from the secure file system. Allows reading the whole file, or just the head or tail."""
|
150 |
+
try:
|
151 |
+
real_path = validate_virtual_path(path)
|
152 |
+
if head is not None and tail is not None:
|
153 |
+
raise CustomFileSystemError("Specify either head or tail, not both")
|
154 |
+
if head is not None:
|
155 |
+
return head_file(real_path, head)
|
156 |
+
elif tail is not None:
|
157 |
+
return tail_file(real_path, tail)
|
158 |
+
else:
|
159 |
+
with open(real_path, "r", encoding="utf-8") as f:
|
160 |
+
return f.read()
|
161 |
+
except Exception as e:
|
162 |
+
return get_error_message("Error reading", path, e)
|
163 |
+
|
164 |
+
|
165 |
+
@mcp.tool
|
166 |
+
def read_multiple_files(paths: Annotated[List[str], Field(description="A list of virtual paths of the files to read.")]) -> str:
|
167 |
+
"""Read the contents of multiple files efficiently."""
|
168 |
+
try:
|
169 |
+
results = []
|
170 |
+
seen = set()
|
171 |
+
for virtual_path in paths:
|
172 |
+
if virtual_path not in seen:
|
173 |
+
try:
|
174 |
+
seen.add(virtual_path)
|
175 |
+
real_path = validate_virtual_path(virtual_path)
|
176 |
+
content = open(real_path, "r", encoding="utf-8").read()
|
177 |
+
results.append(f"### {virtual_path}:\n```\n{content}\n```\n")
|
178 |
+
except Exception as e:
|
179 |
+
results.append(f"### {virtual_path}:\n{get_error_message('Error reading', virtual_path, e)}\n")
|
180 |
+
return "\n".join(results)
|
181 |
+
except Exception as e:
|
182 |
+
return get_error_message("Error reading multiple files", None, e)
|
183 |
+
|
184 |
+
|
185 |
+
@mcp.tool
|
186 |
+
def list_directory(path: Annotated[str, Field(description="The virtual path of the directory to list.")]) -> str:
|
187 |
+
"""List the files and directories within a given directory, indicating whether each entry is a file or a directory."""
|
188 |
+
try:
|
189 |
+
real_path = validate_virtual_path(path)
|
190 |
+
entries = os.listdir(real_path)
|
191 |
+
listing = [f"[{'DIR' if os.path.isdir(os.path.join(real_path, e)) else 'FILE'}] {e}" for e in entries]
|
192 |
+
return "\n".join(listing)
|
193 |
+
except Exception as e:
|
194 |
+
return get_error_message("Error listing", path, e)
|
195 |
+
|
196 |
+
|
197 |
+
@mcp.tool
|
198 |
+
def directory_tree(path: Annotated[str, Field(description="The virtual path of the root directory for the tree listing.")]) -> str:
|
199 |
+
"""Show a recursive directory listing starting from the given path."""
|
200 |
+
try:
|
201 |
+
return list_files_recursive(path)
|
202 |
+
except Exception as e:
|
203 |
+
return get_error_message("Error listing", path, e)
|
204 |
+
|
205 |
+
|
206 |
+
@mcp.tool
|
207 |
+
def search_files(
|
208 |
+
path: Annotated[str, Field(description="The virtual path of the directory to start the search from.")],
|
209 |
+
pattern: Annotated[Optional[str], Field(description="A glob pattern to filter file and directory names (e.g., '*.py').")] = None,
|
210 |
+
excludePatterns: Annotated[Optional[List[str]], Field(description="A list of glob patterns to exclude files or directories.")] = None,
|
211 |
+
) -> str:
|
212 |
+
"""Search for files and directories by name pattern, with optional exclusions."""
|
213 |
+
try:
|
214 |
+
return list_files_recursive(path, pattern, excludePatterns)
|
215 |
+
except Exception as e:
|
216 |
+
return get_error_message("Error searching", path, e)
|
217 |
+
|
218 |
+
|
219 |
+
@mcp.tool
|
220 |
+
def get_file_info(path: Annotated[str, Field(description="The virtual path of the file or directory to get information about.")]) -> str:
|
221 |
+
"""Get metadata for a file or directory, such as size, modification times, and permissions."""
|
222 |
+
try:
|
223 |
+
|
224 |
+
def format_time(timestamp):
|
225 |
+
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
226 |
+
|
227 |
+
real_path = validate_virtual_path(path)
|
228 |
+
stats = os.stat(real_path)
|
229 |
+
info = {
|
230 |
+
"path": path,
|
231 |
+
"size": stats.st_size,
|
232 |
+
"created": format_time(stats.st_ctime),
|
233 |
+
"modified": format_time(stats.st_mtime),
|
234 |
+
"accessed": format_time(stats.st_atime),
|
235 |
+
"isDirectory": os.path.isdir(real_path),
|
236 |
+
"isFile": os.path.isfile(real_path),
|
237 |
+
"permissions": oct(stats.st_mode)[-3:],
|
238 |
+
}
|
239 |
+
return "\n".join(f"{k}: {v}" for k, v in info.items())
|
240 |
+
except Exception as e:
|
241 |
+
return get_error_message("Error getting info", path, e)
|
242 |
+
|
243 |
+
|
244 |
+
@mcp.tool
|
245 |
+
def list_allowed_directories() -> str:
|
246 |
+
"""List the top-level virtual directories that are accessible."""
|
247 |
+
return "### Allowed directories:\n" + "\n".join(_virtual_to_real.keys())
|
248 |
+
|
249 |
+
|
250 |
+
# Run the server with allowed directories from command-line arguments.
|
251 |
+
if len(sys.argv) < 2:
|
252 |
+
print("Usage: filesystem <allowed-directory> [additional-directories...]")
|
253 |
+
sys.exit(1)
|
254 |
+
real_dirs = sys.argv[1:]
|
255 |
+
for real_dir in real_dirs:
|
256 |
+
if not os.path.isdir(real_dir):
|
257 |
+
print(f"Error: {real_dir} is not a directory")
|
258 |
+
sys.exit(1)
|
259 |
+
set_allowed_dirs(real_dirs)
|
260 |
+
virtual_dirs_mapping = "\n".join(f"{v} -> {r}" for v, r in _virtual_to_real.items())
|
261 |
+
print(f"MCP Filesystem Server running on stdio\nVirtual to real directory mappings:\n{virtual_dirs_mapping}")
|
262 |
+
|
263 |
+
|
264 |
+
if __name__ == "__main__":
|
265 |
+
mcp.run()
|
_mcp_servers/filesystem_write-mcp/filesystem_write-mcp.py
ADDED
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from collections import deque
|
2 |
+
from datetime import datetime
|
3 |
+
import difflib
|
4 |
+
import fnmatch
|
5 |
+
from fastmcp import FastMCP
|
6 |
+
import os
|
7 |
+
from pydantic import BaseModel, Field, ValidationError
|
8 |
+
import re
|
9 |
+
import sys
|
10 |
+
from typing import Annotated, Dict, List, Optional
|
11 |
+
|
12 |
+
|
13 |
+
# Custom error class
|
14 |
+
class CustomFileSystemError(ValueError):
|
15 |
+
"""Custom error for filesystem operations."""
|
16 |
+
|
17 |
+
pass
|
18 |
+
|
19 |
+
|
20 |
+
# Global mappings for directory access
|
21 |
+
_allowed_real_dirs: List[str] = [] # Real file system paths
|
22 |
+
_virtual_to_real: Dict[str, str] = {} # Virtual path -> Real path
|
23 |
+
_real_to_virtual: Dict[str, str] = {} # Real path -> Virtual path
|
24 |
+
|
25 |
+
|
26 |
+
def set_allowed_dirs(real_dirs: List[str]) -> None:
|
27 |
+
"""Configure allowed real directories and map them to virtual paths (e.g., /data/a)."""
|
28 |
+
global _allowed_real_dirs, _virtual_to_real, _real_to_virtual
|
29 |
+
_allowed_real_dirs = [os.path.abspath(os.path.expanduser(d)) for d in real_dirs]
|
30 |
+
_virtual_to_real = {f"/data/{chr(97 + i)}": real_dir for i, real_dir in enumerate(_allowed_real_dirs)}
|
31 |
+
_real_to_virtual = {real_dir: virtual_dir for virtual_dir, real_dir in _virtual_to_real.items()}
|
32 |
+
|
33 |
+
|
34 |
+
def validate_virtual_path(virtual_path: str) -> str:
|
35 |
+
"""Convert a virtual path to a real path, ensuring it’s within allowed directories."""
|
36 |
+
for virtual_dir, real_dir in _virtual_to_real.items():
|
37 |
+
if virtual_path.startswith(virtual_dir + "/") or virtual_path == virtual_dir:
|
38 |
+
relative = virtual_path[len(virtual_dir) :].lstrip("/")
|
39 |
+
real_path = os.path.join(real_dir, relative) if relative else real_dir
|
40 |
+
break
|
41 |
+
else:
|
42 |
+
raise CustomFileSystemError(f"Not a valid path (List allowed directories for valid paths): {virtual_path}")
|
43 |
+
|
44 |
+
real_path = os.path.normpath(os.path.abspath(real_path))
|
45 |
+
try:
|
46 |
+
resolved_real_path = os.path.realpath(real_path)
|
47 |
+
if any(resolved_real_path.startswith(d + os.sep) or resolved_real_path == d for d in _allowed_real_dirs):
|
48 |
+
return resolved_real_path
|
49 |
+
raise PermissionError("Access denied")
|
50 |
+
except FileNotFoundError:
|
51 |
+
real_parent = os.path.realpath(os.path.dirname(real_path))
|
52 |
+
if not os.path.exists(real_parent):
|
53 |
+
raise FileNotFoundError("Parent directory not found")
|
54 |
+
if any(real_parent.startswith(d + os.sep) or real_parent == d for d in _allowed_real_dirs):
|
55 |
+
return real_path
|
56 |
+
raise PermissionError("Access denied")
|
57 |
+
|
58 |
+
|
59 |
+
def get_error_message(message, virtual_path: str, e: Exception) -> str:
|
60 |
+
"""Generate a user-friendly error message using the virtual path."""
|
61 |
+
virtual_path = virtual_path or "Unknown path"
|
62 |
+
if isinstance(e, FileNotFoundError):
|
63 |
+
return f"{message}: No such file or directory: {virtual_path}"
|
64 |
+
elif isinstance(e, PermissionError):
|
65 |
+
return f"{message}: Permission denied: {virtual_path}"
|
66 |
+
elif isinstance(e, IsADirectoryError):
|
67 |
+
return f"{message}: Is a directory: {virtual_path}"
|
68 |
+
elif isinstance(e, NotADirectoryError):
|
69 |
+
return f"{message}: Not a directory: {virtual_path}"
|
70 |
+
elif isinstance(e, FileExistsError):
|
71 |
+
return f"{message}: File already exists: {virtual_path}"
|
72 |
+
elif isinstance(e, CustomFileSystemError):
|
73 |
+
return f"{message}: {e}"
|
74 |
+
elif isinstance(e, ValidationError):
|
75 |
+
errors = e.errors()
|
76 |
+
error_details = "; ".join(f"{err['loc'][0]}: {err['msg']}" for err in errors)
|
77 |
+
return f"{message}: Input validation error: {error_details}"
|
78 |
+
elif isinstance(e, ValueError):
|
79 |
+
return f"{message}: Invalid value: {virtual_path}"
|
80 |
+
else:
|
81 |
+
return f"{message}: {virtual_path}"
|
82 |
+
|
83 |
+
|
84 |
+
# File operation helpers
|
85 |
+
def head_file(real_path: str, lines: int) -> str:
|
86 |
+
"""Read first N lines of a file."""
|
87 |
+
with open(real_path, "r", encoding="utf-8") as f:
|
88 |
+
return "".join(line for i, line in enumerate(f) if i < lines)
|
89 |
+
|
90 |
+
|
91 |
+
def tail_file(real_path: str, lines: int) -> str:
|
92 |
+
"""Read last N lines of a file."""
|
93 |
+
with open(real_path, "r", encoding="utf-8") as f:
|
94 |
+
return "".join(deque(f, maxlen=lines))
|
95 |
+
|
96 |
+
|
97 |
+
def apply_edits(virtual_path: str, edits: List[Dict[str, str]], dry_run: bool) -> str:
|
98 |
+
"""Apply text replacements and return a diff."""
|
99 |
+
real_path = validate_virtual_path(virtual_path)
|
100 |
+
with open(real_path, "r", encoding="utf-8") as f:
|
101 |
+
content = new_content = f.read()
|
102 |
+
for edit in edits:
|
103 |
+
pattern = rf"^{re.escape(edit['oldText'])}(\r?\n|\r|$)"
|
104 |
+
new_content = re.sub(pattern, lambda m: edit["newText"] + m.group(1), new_content, flags=re.MULTILINE)
|
105 |
+
diff = "".join(difflib.unified_diff(content.splitlines(keepends=True), new_content.splitlines(keepends=True), fromfile=virtual_path, tofile=virtual_path))
|
106 |
+
if not dry_run:
|
107 |
+
with open(real_path, "w", encoding="utf-8") as f:
|
108 |
+
f.write(new_content)
|
109 |
+
return diff
|
110 |
+
|
111 |
+
|
112 |
+
def list_files_recursive(virtual_path: str, pattern: Optional[str] = None, exclude_patterns: Optional[List[str]] = None) -> str:
|
113 |
+
"""List files and directories recursively, optionally filtering by pattern."""
|
114 |
+
real_path = validate_virtual_path(virtual_path)
|
115 |
+
matches = []
|
116 |
+
for root, dirs, files in os.walk(real_path):
|
117 |
+
if exclude_patterns:
|
118 |
+
dirs[:] = [d for d in dirs if not any(fnmatch.fnmatch(d, p) for p in exclude_patterns)]
|
119 |
+
files = [f for f in files if not any(fnmatch.fnmatch(f, p) for p in exclude_patterns)]
|
120 |
+
rel_root = os.path.relpath(root, real_path) if root != real_path else ""
|
121 |
+
for name in dirs + files:
|
122 |
+
if pattern is None or fnmatch.fnmatch(name.lower(), pattern.lower()):
|
123 |
+
rel_path = os.path.join(rel_root, name).replace(os.sep, "/")
|
124 |
+
if os.path.isdir(os.path.join(root, name)):
|
125 |
+
rel_path += "/"
|
126 |
+
matches.append(rel_path)
|
127 |
+
return "\n".join([f"### Contents of {virtual_path}:"] + sorted(matches))
|
128 |
+
|
129 |
+
|
130 |
+
# Tool argument models
|
131 |
+
class EditOp(BaseModel):
|
132 |
+
oldText: str = Field(..., description="Line to be replaced")
|
133 |
+
newText: str = Field(..., description="Replacement line")
|
134 |
+
|
135 |
+
|
136 |
+
# Server setup
|
137 |
+
mcp = FastMCP(
|
138 |
+
name="File System Server",
|
139 |
+
instructions="A server that provides tools for interacting with a file system. Only some paths are accessible, therefore the allowed directores must be listed initially.",
|
140 |
+
)
|
141 |
+
|
142 |
+
|
143 |
+
@mcp.tool
|
144 |
+
def write_file(
|
145 |
+
path: Annotated[str, Field(description="The virtual path of the file to write to. If the file exists, it will be overwritten.")],
|
146 |
+
content: Annotated[str, Field(description="The content to write to the file.")],
|
147 |
+
) -> str:
|
148 |
+
"""Write or overwrite a file with the given text content."""
|
149 |
+
try:
|
150 |
+
real_path = validate_virtual_path(path)
|
151 |
+
with open(real_path, "w", encoding="utf-8") as f:
|
152 |
+
f.write(content)
|
153 |
+
return f"Wrote to {path}"
|
154 |
+
except Exception as e:
|
155 |
+
return get_error_message("Error writing", path, e)
|
156 |
+
|
157 |
+
|
158 |
+
@mcp.tool
|
159 |
+
def edit_file(
|
160 |
+
path: Annotated[str, Field(description="The virtual path of the file to edit.")],
|
161 |
+
edits: Annotated[List[EditOp], Field(description="A list of replacement operations to apply to the file.")],
|
162 |
+
dryRun: Annotated[bool, Field(description="If true, returns a diff of the changes without applying them.")] = False,
|
163 |
+
) -> str:
|
164 |
+
"""Edit a file with line-based replacements and returns a diff of the changes."""
|
165 |
+
try:
|
166 |
+
diff = apply_edits(path, [{"oldText": e.oldText, "newText": e.newText} for e in edits], dryRun)
|
167 |
+
return diff
|
168 |
+
except Exception as e:
|
169 |
+
return get_error_message("Error editing", path, e)
|
170 |
+
|
171 |
+
|
172 |
+
@mcp.tool
|
173 |
+
def create_directory(path: Annotated[str, Field(description="The virtual path of the directory to create. It can be nested (e.g., /data/a/new/dir).")]) -> str:
|
174 |
+
"""Create a directory, including any necessary parent directories."""
|
175 |
+
try:
|
176 |
+
real_path = validate_virtual_path(path)
|
177 |
+
os.makedirs(real_path, exist_ok=True)
|
178 |
+
return f"Created {path}"
|
179 |
+
except Exception as e:
|
180 |
+
return get_error_message("Error creating", path, e)
|
181 |
+
|
182 |
+
|
183 |
+
@mcp.tool
|
184 |
+
def move_file(
|
185 |
+
source: Annotated[str, Field(description="The virtual path of the file or directory to move.")],
|
186 |
+
destination: Annotated[str, Field(description="The new virtual path for the file or directory.")],
|
187 |
+
) -> str:
|
188 |
+
"""Move or rename a file or directory. This operation will fail if the destination already exists."""
|
189 |
+
try:
|
190 |
+
real_source = validate_virtual_path(source)
|
191 |
+
real_destination = validate_virtual_path(destination)
|
192 |
+
os.rename(real_source, real_destination)
|
193 |
+
return f"Moved {source} to {destination}"
|
194 |
+
except Exception as e:
|
195 |
+
return get_error_message("Error moving", source, e)
|
196 |
+
|
197 |
+
|
198 |
+
# Run the server with allowed directories from command-line arguments.
|
199 |
+
if len(sys.argv) < 2:
|
200 |
+
print("Usage: filesystem <allowed-directory> [additional-directories...]")
|
201 |
+
sys.exit(1)
|
202 |
+
real_dirs = sys.argv[1:]
|
203 |
+
for real_dir in real_dirs:
|
204 |
+
if not os.path.isdir(real_dir):
|
205 |
+
print(f"Error: {real_dir} is not a directory")
|
206 |
+
sys.exit(1)
|
207 |
+
set_allowed_dirs(real_dirs)
|
208 |
+
virtual_dirs_mapping = "\n".join(f"{v} -> {r}" for v, r in _virtual_to_real.items())
|
209 |
+
print(f"MCP Filesystem Server running on stdio\nVirtual to real directory mappings:\n{virtual_dirs_mapping}")
|
210 |
+
|
211 |
+
|
212 |
+
if __name__ == "__main__":
|
213 |
+
mcp.run()
|
css/3rdparty/hljs_androidstudio.min.css
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#a9b7c6;background:#282b2e}.hljs-bullet,.hljs-literal,.hljs-number,.hljs-symbol{color:#6897bb}.hljs-deletion,.hljs-keyword,.hljs-selector-tag{color:#cc7832}.hljs-link,.hljs-template-variable,.hljs-variable{color:#629755}.hljs-comment,.hljs-quote{color:grey}.hljs-meta{color:#bbb529}.hljs-addition,.hljs-attribute,.hljs-string{color:#6a8759}.hljs-section,.hljs-title,.hljs-type{color:#ffc66d}.hljs-name,.hljs-selector-class,.hljs-selector-id{color:#e8bf6a}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
|
css/agents.css
ADDED
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/* Styles for the agents and flow tabs */
|
2 |
+
|
3 |
+
#tabs {
|
4 |
+
display: flex;
|
5 |
+
border-bottom: 1px solid var(--border-color);
|
6 |
+
align-items: center;
|
7 |
+
}
|
8 |
+
|
9 |
+
#maximize-button {
|
10 |
+
margin-left: auto;
|
11 |
+
margin-right: 10px;
|
12 |
+
}
|
13 |
+
|
14 |
+
.tab-button {
|
15 |
+
padding: 10px 20px;
|
16 |
+
cursor: pointer;
|
17 |
+
border: none;
|
18 |
+
background-color: transparent;
|
19 |
+
border-bottom: 2px solid transparent;
|
20 |
+
font-size: 1em;
|
21 |
+
color: var(--text-color);
|
22 |
+
}
|
23 |
+
|
24 |
+
.tab-button.active {
|
25 |
+
border-bottom: 2px solid var(--text-color-hover);
|
26 |
+
font-weight: bold;
|
27 |
+
}
|
28 |
+
|
29 |
+
.tab-pane {
|
30 |
+
display: none;
|
31 |
+
padding: 10px;
|
32 |
+
flex-grow: 1;
|
33 |
+
overflow-y: auto;
|
34 |
+
}
|
35 |
+
|
36 |
+
.tab-pane.active {
|
37 |
+
display: flex;
|
38 |
+
flex-direction: column;
|
39 |
+
height: 100%;
|
40 |
+
}
|
41 |
+
|
42 |
+
#chat-tab-pane {
|
43 |
+
padding: 0;
|
44 |
+
}
|
45 |
+
|
46 |
+
#tab-content {
|
47 |
+
flex-grow: 1;
|
48 |
+
display: flex;
|
49 |
+
flex-direction: column;
|
50 |
+
overflow: hidden;
|
51 |
+
}
|
52 |
+
|
53 |
+
/* --- Generic Styles for Agents/Flow Tabs --- */
|
54 |
+
.agents-flow-toolbar {
|
55 |
+
margin-bottom: 10px;
|
56 |
+
padding-bottom: 10px;
|
57 |
+
border-bottom: 1px solid var(--border-color);
|
58 |
+
display: flex;
|
59 |
+
gap: 10px;
|
60 |
+
}
|
61 |
+
|
62 |
+
.agents-flow-btn {
|
63 |
+
background-color: var(--tool-button-bg);
|
64 |
+
border: 1px solid var(--border-color);
|
65 |
+
color: var(--text-color);
|
66 |
+
padding: 5px 10px;
|
67 |
+
border-radius: 4px;
|
68 |
+
cursor: pointer;
|
69 |
+
}
|
70 |
+
.agents-flow-btn:hover {
|
71 |
+
background-color: var(--tool-button-bg-hover);
|
72 |
+
color: var(--text-color-hover);
|
73 |
+
}
|
74 |
+
|
75 |
+
|
76 |
+
/* --- Agents Tab Styles --- */
|
77 |
+
#agent-list {
|
78 |
+
display: flex;
|
79 |
+
flex-direction: column;
|
80 |
+
gap: 10px;
|
81 |
+
}
|
82 |
+
|
83 |
+
.agent-card {
|
84 |
+
border: 1px solid var(--border-color);
|
85 |
+
border-radius: 5px;
|
86 |
+
padding: 15px;
|
87 |
+
background-color: var(--accent-bg);
|
88 |
+
}
|
89 |
+
|
90 |
+
.agent-card.active {
|
91 |
+
border-color: var(--text-color-hover);
|
92 |
+
box-shadow: 0 0 5px var(--text-color-hover);
|
93 |
+
}
|
94 |
+
|
95 |
+
.agent-card h3 {
|
96 |
+
margin-top: 0;
|
97 |
+
}
|
98 |
+
|
99 |
+
.agent-card-buttons {
|
100 |
+
margin-top: 10px;
|
101 |
+
display: flex;
|
102 |
+
gap: 5px;
|
103 |
+
}
|
104 |
+
|
105 |
+
#agent-form-container {
|
106 |
+
margin-top: 20px;
|
107 |
+
border-top: 1px solid var(--border-color);
|
108 |
+
padding-top: 20px;
|
109 |
+
background-color: var(--settings-bg);
|
110 |
+
padding: 15px;
|
111 |
+
border-radius: 5px;
|
112 |
+
}
|
113 |
+
|
114 |
+
#agent-form label {
|
115 |
+
display: block;
|
116 |
+
margin-bottom: 5px;
|
117 |
+
}
|
118 |
+
|
119 |
+
#agent-form input[type="text"],
|
120 |
+
#agent-form textarea {
|
121 |
+
width: 100%;
|
122 |
+
padding: 8px;
|
123 |
+
margin-bottom: 10px;
|
124 |
+
border-radius: 3px;
|
125 |
+
background-color: var(--input-bg);
|
126 |
+
border: 1px solid var(--input-border);
|
127 |
+
color: var(--text-color);
|
128 |
+
box-sizing: border-box;
|
129 |
+
}
|
130 |
+
|
131 |
+
#agent-form-buttons {
|
132 |
+
margin-top: 10px;
|
133 |
+
}
|
134 |
+
|
135 |
+
/* --- Flow Tab Styles --- */
|
136 |
+
#flow-canvas-wrapper {
|
137 |
+
flex-grow: 1;
|
138 |
+
overflow: auto;
|
139 |
+
position: relative;
|
140 |
+
background-color: var(--accent-bg);
|
141 |
+
border-radius: 5px;
|
142 |
+
}
|
143 |
+
|
144 |
+
#flow-canvas {
|
145 |
+
position: relative;
|
146 |
+
width: 2000px;
|
147 |
+
height: 2000px;
|
148 |
+
cursor: grab;
|
149 |
+
}
|
150 |
+
|
151 |
+
#flow-canvas.panning {
|
152 |
+
cursor: grabbing;
|
153 |
+
}
|
154 |
+
|
155 |
+
#flow-svg-layer {
|
156 |
+
position: absolute;
|
157 |
+
top: 0;
|
158 |
+
left: 0;
|
159 |
+
width: 100%;
|
160 |
+
height: 100%;
|
161 |
+
pointer-events: none;
|
162 |
+
z-index: 1;
|
163 |
+
}
|
164 |
+
|
165 |
+
#flow-node-container {
|
166 |
+
position: absolute;
|
167 |
+
top: 0;
|
168 |
+
left: 0;
|
169 |
+
width: 100%;
|
170 |
+
height: 100%;
|
171 |
+
z-index: 2;
|
172 |
+
}
|
173 |
+
|
174 |
+
.flow-step-card {
|
175 |
+
position: absolute;
|
176 |
+
width: 250px;
|
177 |
+
border: 1px solid var(--border-color);
|
178 |
+
border-radius: 5px;
|
179 |
+
padding: 10px;
|
180 |
+
background-color: var(--bg-color);
|
181 |
+
cursor: move;
|
182 |
+
z-index: 10;
|
183 |
+
transition: width 0.2s, height 0.2s;
|
184 |
+
}
|
185 |
+
|
186 |
+
.minimize-flow-step-btn {
|
187 |
+
position: absolute;
|
188 |
+
top: 5px;
|
189 |
+
left: 5px;
|
190 |
+
width: 16px;
|
191 |
+
height: 16px;
|
192 |
+
background-color: var(--tool-button-bg);
|
193 |
+
border: 1px solid var(--border-color);
|
194 |
+
color: var(--text-color);
|
195 |
+
border-radius: 4px;
|
196 |
+
cursor: pointer;
|
197 |
+
z-index: 12;
|
198 |
+
padding: 0;
|
199 |
+
line-height: 14px;
|
200 |
+
text-align: center;
|
201 |
+
font-size: 14px;
|
202 |
+
}
|
203 |
+
|
204 |
+
.flow-step-card.minimized {
|
205 |
+
width: 125px;
|
206 |
+
height: 40px;
|
207 |
+
padding-top: 25px;
|
208 |
+
}
|
209 |
+
|
210 |
+
.flow-step-card.minimized .flow-step-content {
|
211 |
+
display: none;
|
212 |
+
}
|
213 |
+
|
214 |
+
.flow-step-card.minimized h4 {
|
215 |
+
font-size: 0.8em;
|
216 |
+
white-space: nowrap;
|
217 |
+
overflow: hidden;
|
218 |
+
text-overflow: ellipsis;
|
219 |
+
}
|
220 |
+
|
221 |
+
.flow-step-input {
|
222 |
+
width: 100%;
|
223 |
+
padding: 8px;
|
224 |
+
margin-top: 5px;
|
225 |
+
margin-bottom: 10px;
|
226 |
+
border-radius: 3px;
|
227 |
+
background-color: var(--input-bg);
|
228 |
+
border: 1px solid var(--input-border);
|
229 |
+
color: var(--text-color);
|
230 |
+
box-sizing: border-box;
|
231 |
+
}
|
232 |
+
|
233 |
+
.flow-step-card-buttons {
|
234 |
+
margin-top: 10px;
|
235 |
+
}
|
236 |
+
|
237 |
+
.connector {
|
238 |
+
position: absolute;
|
239 |
+
width: 12px;
|
240 |
+
height: 12px;
|
241 |
+
background-color: var(--input-bg);
|
242 |
+
border: 1px solid var(--text-color);
|
243 |
+
border-radius: 50%;
|
244 |
+
cursor: pointer;
|
245 |
+
z-index: 11;
|
246 |
+
}
|
247 |
+
.connector:hover {
|
248 |
+
background-color: var(--text-color-hover);
|
249 |
+
}
|
250 |
+
|
251 |
+
.connector.top {
|
252 |
+
top: -6px;
|
253 |
+
left: calc(50% - 6px);
|
254 |
+
}
|
255 |
+
|
256 |
+
.connector.bottom {
|
257 |
+
bottom: -6px;
|
258 |
+
left: calc(50% - 6px);
|
259 |
+
}
|
260 |
+
|
261 |
+
.connector-group {
|
262 |
+
display: flex;
|
263 |
+
justify-content: space-around;
|
264 |
+
}
|
265 |
+
|
266 |
+
.connector-group .connector {
|
267 |
+
position: relative; /* Override absolute positioning */
|
268 |
+
top: auto;
|
269 |
+
left: auto;
|
270 |
+
bottom: -16px;
|
271 |
+
}
|
272 |
+
|
273 |
+
.connector-label {
|
274 |
+
position: absolute;
|
275 |
+
top: -2px;
|
276 |
+
left: 15px;
|
277 |
+
}
|
278 |
+
|
279 |
+
.flow-step-card.minimized .connector-group .connector {
|
280 |
+
bottom: 8px;
|
281 |
+
}
|
282 |
+
|
283 |
+
/* --- Dropdown for Add Step --- */
|
284 |
+
.dropdown {
|
285 |
+
position: relative;
|
286 |
+
display: inline-block;
|
287 |
+
}
|
288 |
+
|
289 |
+
.dropdown-content {
|
290 |
+
display: none;
|
291 |
+
position: absolute;
|
292 |
+
background-color: var(--accent-bg);
|
293 |
+
min-width: 160px;
|
294 |
+
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
|
295 |
+
z-index: 12; /* Higher than flow-step-card */
|
296 |
+
border-radius: 4px;
|
297 |
+
border: 1px solid var(--border-color);
|
298 |
+
}
|
299 |
+
|
300 |
+
.dropdown-content a {
|
301 |
+
color: var(--text-color);
|
302 |
+
padding: 8px 12px;
|
303 |
+
text-decoration: none;
|
304 |
+
display: block;
|
305 |
+
font-size: 0.9em;
|
306 |
+
}
|
307 |
+
|
308 |
+
.dropdown-content a:hover {
|
309 |
+
background-color: var(--tool-button-bg-hover);
|
310 |
+
color: var(--text-color-hover);
|
311 |
+
}
|
312 |
+
|
313 |
+
.dropdown-content.show {
|
314 |
+
display: block;
|
315 |
+
}
|
316 |
+
|
317 |
+
/* --- Special Flow Step Styles --- */
|
318 |
+
.flow-step-simple-prompt {
|
319 |
+
background-color: hsla(0, 0%, 35%, 0.8);
|
320 |
+
}
|
321 |
+
|
322 |
+
.flow-step-conditional-stop {
|
323 |
+
background-color: hsla(200, 20%, 35%, 0.8);
|
324 |
+
}
|
325 |
+
|
326 |
+
.flow-step-multi-prompt {
|
327 |
+
background-color: hsla(145, 20%, 35%, 0.8);
|
328 |
+
}
|
329 |
+
|
330 |
+
.flow-step-branching-prompt {
|
331 |
+
background-color: hsla(30, 20%, 35%, 0.8);
|
332 |
+
}
|
333 |
+
|
334 |
+
.flow-step-consolidator {
|
335 |
+
background-color: hsla(280, 20%, 35%, 0.8);
|
336 |
+
}
|
337 |
+
|
338 |
+
.flow-step-prompt-and-clear {
|
339 |
+
background-color: hsla(0, 0%, 15%, 0.8);
|
340 |
+
}
|
341 |
+
|
342 |
+
.agent-model-settings-group {
|
343 |
+
border: 1px solid var(--border-color);
|
344 |
+
padding: 10px;
|
345 |
+
margin-top: 10px;
|
346 |
+
border-radius: 5px;
|
347 |
+
}
|
348 |
+
|
349 |
+
/*
|
350 |
+
.model-list-fieldset .model-list-row {
|
351 |
+
display: flex;
|
352 |
+
align-items: center;
|
353 |
+
margin-bottom: 5px;
|
354 |
+
}
|
355 |
+
|
356 |
+
.model-list-fieldset .model-list-row input[type="radio"] {
|
357 |
+
margin-right: 10px;
|
358 |
+
margin-bottom: 0;
|
359 |
+
}
|
360 |
+
*/
|
361 |
+
|
362 |
+
|
363 |
+
.agent-model-settings-group .model-list-fieldset .model-list-row {
|
364 |
+
display: flex;
|
365 |
+
}
|
366 |
+
|
367 |
+
.model-list-fieldset .model-list-row input[type="radio"] {
|
368 |
+
margin-right: 6px;
|
369 |
+
}
|
370 |
+
|
371 |
+
.agent-model-settings-group .model-list-fieldset .model-list-row input[type="radio"] {
|
372 |
+
margin-top: -8px;
|
373 |
+
}
|
374 |
+
|
375 |
+
|
376 |
+
#run-flow-btn {
|
377 |
+
background-color: hsl(120, 100%, 20%);
|
378 |
+
}
|
379 |
+
|
380 |
+
#run-flow-btn:hover {
|
381 |
+
background-color: hsl(120, 100%, 35%);
|
382 |
+
}
|
css/main.css
ADDED
@@ -0,0 +1,564 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/* Global styles */
|
2 |
+
:root {
|
3 |
+
--bg-color: #202122;
|
4 |
+
--text-color: #efeff0;
|
5 |
+
--text-color-hover: #fafafb;
|
6 |
+
--accent-bg: #343542;
|
7 |
+
--accent-bg-hover: #40424f;
|
8 |
+
--border-color: #20212280;
|
9 |
+
--link-color: #aaa;
|
10 |
+
--link-hover: underline;
|
11 |
+
--error-bg: #ff3333a0;
|
12 |
+
--error-text: #fff;
|
13 |
+
--think-bg: #282b2e60;
|
14 |
+
--think-border: #ffffff15;
|
15 |
+
--scrollbar-bg: #00000000;
|
16 |
+
--scrollbar-thumb: #ffffff15;
|
17 |
+
--scrollbar-thumb-hover: #909090;
|
18 |
+
--message-ping-bg: #383333;
|
19 |
+
--message-pong-bg: #333338;
|
20 |
+
--message-system-bg: #583333;
|
21 |
+
--table-border: #d9d9e2;
|
22 |
+
--table-th-bg: #ececf033;
|
23 |
+
--table-text: #d1d5db;
|
24 |
+
--hljs-bg: #282b2e60;
|
25 |
+
--tool-button-bg: #ffffff10;
|
26 |
+
--tool-button-bg-hover: #ffffff30;
|
27 |
+
--tool-button-small-bg: #ffffff10;
|
28 |
+
--tool-button-small-bg-hover: #ffffff30;
|
29 |
+
--settings-bg: #343542dd;
|
30 |
+
--input-bg: #40424f;
|
31 |
+
--input-border: #20212280;
|
32 |
+
}
|
33 |
+
|
34 |
+
html, body {
|
35 |
+
height: 100%;
|
36 |
+
margin: 0;
|
37 |
+
padding: 0;
|
38 |
+
font-family: Helvetica, sans-serif;
|
39 |
+
color: var(--text-color);
|
40 |
+
background-color: var(--bg-color);
|
41 |
+
}
|
42 |
+
|
43 |
+
/* Main column container */
|
44 |
+
#colContainer {
|
45 |
+
display: flex;
|
46 |
+
flex-direction: column;
|
47 |
+
height: 100%;
|
48 |
+
max-width: 75%;
|
49 |
+
margin-left: auto;
|
50 |
+
margin-right: auto;
|
51 |
+
transition: max-width 0.3s ease-in-out;
|
52 |
+
}
|
53 |
+
|
54 |
+
body.maximized-view #colContainer {
|
55 |
+
max-width: 100%;
|
56 |
+
}
|
57 |
+
|
58 |
+
/* Header section */
|
59 |
+
#header {
|
60 |
+
margin: 0;
|
61 |
+
padding: 12px;
|
62 |
+
text-align: center;
|
63 |
+
}
|
64 |
+
#header:has(+#mainContent #chatContainer>#chat>.message) {
|
65 |
+
display: none;
|
66 |
+
}
|
67 |
+
|
68 |
+
/* Main content flex row */
|
69 |
+
#mainContent {
|
70 |
+
display: flex;
|
71 |
+
flex: 1;
|
72 |
+
overflow: hidden;
|
73 |
+
}
|
74 |
+
|
75 |
+
/* Chat list container */
|
76 |
+
#chatListContainer {
|
77 |
+
width: 250px;
|
78 |
+
background-color: var(--accent-bg);
|
79 |
+
padding: 10px;
|
80 |
+
border-right: 1px solid var(--border-color);
|
81 |
+
display: flex;
|
82 |
+
flex-direction: column;
|
83 |
+
height: 100%;
|
84 |
+
overflow: hidden;
|
85 |
+
box-sizing: border-box;
|
86 |
+
}
|
87 |
+
#chatListWrapper {
|
88 |
+
flex: 1;
|
89 |
+
overflow-y: auto;
|
90 |
+
}
|
91 |
+
#chatList {
|
92 |
+
margin: 0;
|
93 |
+
padding: 0;
|
94 |
+
list-style: none;
|
95 |
+
}
|
96 |
+
#chatControls {
|
97 |
+
display: flex;
|
98 |
+
justify-content: space-between;
|
99 |
+
align-items: center;
|
100 |
+
margin-top: 10px;
|
101 |
+
padding-top: 10px;
|
102 |
+
border-top: 1px solid var(--border-color);
|
103 |
+
}
|
104 |
+
#chatControls .left-buttons {
|
105 |
+
display: flex;
|
106 |
+
gap: 5px;
|
107 |
+
}
|
108 |
+
.chat-item {
|
109 |
+
padding: 10px;
|
110 |
+
cursor: pointer;
|
111 |
+
list-style: none;
|
112 |
+
border-bottom: 1px solid var(--border-color);
|
113 |
+
display: flex;
|
114 |
+
align-items: center;
|
115 |
+
}
|
116 |
+
.chat-item.active {
|
117 |
+
background-color: var(--accent-bg-hover);
|
118 |
+
border-radius: 5px;
|
119 |
+
}
|
120 |
+
.chat-item span {
|
121 |
+
flex: 1;
|
122 |
+
white-space: nowrap;
|
123 |
+
overflow: hidden;
|
124 |
+
text-overflow: ellipsis;
|
125 |
+
cursor: pointer; /* Indicates it's clickable for selection */
|
126 |
+
}
|
127 |
+
.chat-item input {
|
128 |
+
flex: 1;
|
129 |
+
background: transparent;
|
130 |
+
border: none;
|
131 |
+
color: var(--text-color);
|
132 |
+
outline: none;
|
133 |
+
}
|
134 |
+
.chat-item button {
|
135 |
+
margin-left: 5px;
|
136 |
+
opacity: 0;
|
137 |
+
transition: opacity 0.2s;
|
138 |
+
}
|
139 |
+
.chat-item:hover button {
|
140 |
+
opacity: 1;
|
141 |
+
}
|
142 |
+
|
143 |
+
/* Chat area */
|
144 |
+
#chatArea {
|
145 |
+
flex: 1;
|
146 |
+
display: flex;
|
147 |
+
flex-direction: column;
|
148 |
+
overflow: hidden;
|
149 |
+
}
|
150 |
+
|
151 |
+
/* Tab Bar */
|
152 |
+
#tabs {
|
153 |
+
display: flex;
|
154 |
+
justify-content: space-between;
|
155 |
+
align-items: center;
|
156 |
+
}
|
157 |
+
|
158 |
+
#main-tabs {
|
159 |
+
display: flex;
|
160 |
+
align-items: center;
|
161 |
+
}
|
162 |
+
|
163 |
+
#tab-buttons {
|
164 |
+
display: flex;
|
165 |
+
align-items: center;
|
166 |
+
gap: 5px;
|
167 |
+
}
|
168 |
+
|
169 |
+
#tab-buttons #source {
|
170 |
+
padding: 0 5px;
|
171 |
+
font-size: small;
|
172 |
+
}
|
173 |
+
|
174 |
+
/* Chat display container */
|
175 |
+
#chatContainer {
|
176 |
+
flex: 1;
|
177 |
+
padding: 10px 0;
|
178 |
+
overflow-y: overlay;
|
179 |
+
}
|
180 |
+
|
181 |
+
/* Input area container */
|
182 |
+
#inputContainer {
|
183 |
+
padding: 8px;
|
184 |
+
background-color: var(--accent-bg);
|
185 |
+
display: flex;
|
186 |
+
flex-direction: row;
|
187 |
+
align-items: center;
|
188 |
+
border-top-left-radius: 8px;
|
189 |
+
border-top-right-radius: 8px;
|
190 |
+
}
|
191 |
+
#inputContainer textarea {
|
192 |
+
width: 100%;
|
193 |
+
resize: none;
|
194 |
+
border: 1px solid var(--input-border);
|
195 |
+
outline: none;
|
196 |
+
border-radius: 5px;
|
197 |
+
background-color: var(--input-bg);
|
198 |
+
color: var(--text-color);
|
199 |
+
padding: 8px;
|
200 |
+
margin-right: 8px;
|
201 |
+
}
|
202 |
+
|
203 |
+
/* Generic tool buttons */
|
204 |
+
.toolButton {
|
205 |
+
background-color: var(--tool-button-bg);
|
206 |
+
border: 1px solid var(--border-color);
|
207 |
+
color: var(--text-color);
|
208 |
+
border: none;
|
209 |
+
padding: 2px;
|
210 |
+
margin: 1px 0;
|
211 |
+
border-radius: 4px;
|
212 |
+
width: 35px;
|
213 |
+
height: 29px;
|
214 |
+
cursor: pointer;
|
215 |
+
}
|
216 |
+
.toolButton:hover {
|
217 |
+
background-color: var(--tool-button-bg-hover);
|
218 |
+
color: var(--text-color-hover);
|
219 |
+
}
|
220 |
+
.toolButton.small {
|
221 |
+
background-color: var(--tool-button-small-bg);
|
222 |
+
padding: 0;
|
223 |
+
margin: 0;
|
224 |
+
width: 26px;
|
225 |
+
height: 18px;
|
226 |
+
}
|
227 |
+
.toolButton.small:hover {
|
228 |
+
background-color: var(--tool-button-small-bg-hover);
|
229 |
+
color: var(--text-color-hover);
|
230 |
+
}
|
231 |
+
|
232 |
+
a {
|
233 |
+
color: var(--link-color);
|
234 |
+
text-decoration: none;
|
235 |
+
}
|
236 |
+
a:hover {
|
237 |
+
text-decoration: var(--link-hover);
|
238 |
+
}
|
239 |
+
|
240 |
+
/* Settings dialog styles */
|
241 |
+
#settings {
|
242 |
+
display: none;
|
243 |
+
white-space: nowrap;
|
244 |
+
width: fit-content;
|
245 |
+
font-size: 14px;
|
246 |
+
padding: 8px;
|
247 |
+
background-color: var(--settings-bg);
|
248 |
+
border-radius: 8px;
|
249 |
+
position: absolute;
|
250 |
+
bottom: 100px;
|
251 |
+
right: 10px;
|
252 |
+
}
|
253 |
+
#settings.open {
|
254 |
+
display: block;
|
255 |
+
}
|
256 |
+
#settings p {
|
257 |
+
margin: 8px 0;
|
258 |
+
}
|
259 |
+
|
260 |
+
/* Chat message flow */
|
261 |
+
#chat {
|
262 |
+
display: flex;
|
263 |
+
flex-direction: column;
|
264 |
+
min-height: 200px;
|
265 |
+
white-space: pre-wrap;
|
266 |
+
overflow-wrap: break-word;
|
267 |
+
word-break: break-word;
|
268 |
+
}
|
269 |
+
#chat > .message {
|
270 |
+
margin: 8px;
|
271 |
+
margin-bottom: 0;
|
272 |
+
padding: 8px;
|
273 |
+
border-radius: 16px;
|
274 |
+
}
|
275 |
+
#chat > .ping {
|
276 |
+
background-color: var(--message-ping-bg);
|
277 |
+
margin-left: 40px;
|
278 |
+
margin-right: 0;
|
279 |
+
border-bottom-right-radius: 0;
|
280 |
+
}
|
281 |
+
#chat > .pong {
|
282 |
+
background-color: var(--message-pong-bg);
|
283 |
+
margin-left: 0;
|
284 |
+
margin-right: 40px;
|
285 |
+
border-bottom-left-radius: 0;
|
286 |
+
margin-bottom: 8px;
|
287 |
+
}
|
288 |
+
#chat > .system {
|
289 |
+
background-color: var(--message-system-bg);
|
290 |
+
}
|
291 |
+
#chat .avatar {
|
292 |
+
width: 24px;
|
293 |
+
height: 24px;
|
294 |
+
object-fit: cover;
|
295 |
+
float: left;
|
296 |
+
margin-right: 8px;
|
297 |
+
border-radius: 3px;
|
298 |
+
}
|
299 |
+
#chat p {
|
300 |
+
margin-block-start: .3em;
|
301 |
+
margin-block-end: .3em;
|
302 |
+
}
|
303 |
+
#chat pre {
|
304 |
+
margin: .3em 0;
|
305 |
+
white-space: pre-wrap;
|
306 |
+
}
|
307 |
+
#chat table {
|
308 |
+
width: 100%;
|
309 |
+
border-collapse: separate;
|
310 |
+
border-spacing: 0;
|
311 |
+
border-right: 1px solid var(--table-border);
|
312 |
+
border-bottom: 1px solid var(--table-border);
|
313 |
+
border-radius: 5px;
|
314 |
+
font-size: 14px;
|
315 |
+
}
|
316 |
+
#chat td,
|
317 |
+
#chat th {
|
318 |
+
padding: 5px 8px;
|
319 |
+
border-left: 1px solid var(--table-border);
|
320 |
+
border-top: 1px solid var(--table-border);
|
321 |
+
color: var(--table-text);
|
322 |
+
}
|
323 |
+
#chat th {
|
324 |
+
text-align: left;
|
325 |
+
background-color: var(--table-th-bg);
|
326 |
+
color: var(--text-color);
|
327 |
+
}
|
328 |
+
#chat th:first-of-type {
|
329 |
+
border-top-left-radius: 5px;
|
330 |
+
}
|
331 |
+
#chat th:last-of-type {
|
332 |
+
border-top-right-radius: 5px;
|
333 |
+
}
|
334 |
+
#chat tr:last-of-type td:first-of-type {
|
335 |
+
border-bottom-left-radius: 5px;
|
336 |
+
}
|
337 |
+
#chat tr:last-of-type td:last-of-type {
|
338 |
+
border-bottom-right-radius: 5px;
|
339 |
+
}
|
340 |
+
#chat .katex-html {
|
341 |
+
display: none;
|
342 |
+
font-size: 0;
|
343 |
+
}
|
344 |
+
#chat .katex-error {
|
345 |
+
display: block;
|
346 |
+
}
|
347 |
+
#chat .hljs > svg,
|
348 |
+
#chat .content img {
|
349 |
+
max-width: 50%;
|
350 |
+
display: block;
|
351 |
+
margin-left: auto;
|
352 |
+
margin-right: auto;
|
353 |
+
}
|
354 |
+
|
355 |
+
/* Thinking area styles */
|
356 |
+
#chat details.think {
|
357 |
+
background-color: var(--think-bg);
|
358 |
+
padding: 8px;
|
359 |
+
border-radius: 5px;
|
360 |
+
margin: 8px 0;
|
361 |
+
border: 1px solid var(--think-border);
|
362 |
+
}
|
363 |
+
#chat details.think > summary {
|
364 |
+
cursor: pointer;
|
365 |
+
font-weight: bold;
|
366 |
+
color: var(--text-color);
|
367 |
+
}
|
368 |
+
#chat details.think > .think-content {
|
369 |
+
padding: 8px 0;
|
370 |
+
}
|
371 |
+
|
372 |
+
/* Scrollbar customization */
|
373 |
+
::-webkit-scrollbar {
|
374 |
+
width: 10px;
|
375 |
+
background-color: var(--scrollbar-bg);
|
376 |
+
}
|
377 |
+
::-webkit-scrollbar-thumb {
|
378 |
+
background-color: var(--scrollbar-thumb);
|
379 |
+
border-radius: 4px;
|
380 |
+
}
|
381 |
+
::-webkit-scrollbar-thumb:hover {
|
382 |
+
background-color: var(--scrollbar-thumb-hover);
|
383 |
+
}
|
384 |
+
|
385 |
+
/* Session styles */
|
386 |
+
#session {
|
387 |
+
text-align: right;
|
388 |
+
}
|
389 |
+
#session-login, #session-logout {
|
390 |
+
display: none;
|
391 |
+
margin-top: 8px;
|
392 |
+
text-align: center;
|
393 |
+
}
|
394 |
+
|
395 |
+
/* Highlight.js styles */
|
396 |
+
.hljs {
|
397 |
+
padding: 8px;
|
398 |
+
border-radius: 5px;
|
399 |
+
background-color: var(--hljs-bg);
|
400 |
+
}
|
401 |
+
|
402 |
+
/* Generic utility classes */
|
403 |
+
.nobreak {
|
404 |
+
white-space: nowrap;
|
405 |
+
}
|
406 |
+
.right {
|
407 |
+
float: right;
|
408 |
+
}
|
409 |
+
.clickable {
|
410 |
+
cursor: pointer;
|
411 |
+
}
|
412 |
+
|
413 |
+
#chatListContainer.hidden {
|
414 |
+
display: none;
|
415 |
+
}
|
416 |
+
|
417 |
+
/* Mobile-specific styles */
|
418 |
+
@media only screen and (max-width: 1037px) {
|
419 |
+
.only-desktop {
|
420 |
+
display: none;
|
421 |
+
}
|
422 |
+
#colContainer {
|
423 |
+
max-width: 100%;
|
424 |
+
}
|
425 |
+
#chatListContainer {
|
426 |
+
position: absolute;
|
427 |
+
left: 0;
|
428 |
+
top: 0;
|
429 |
+
bottom: 0;
|
430 |
+
z-index: 10;
|
431 |
+
width: 250px;
|
432 |
+
}
|
433 |
+
}
|
434 |
+
|
435 |
+
/* Desktop-specific styles */
|
436 |
+
@media only screen and (min-width: 1038px) {
|
437 |
+
.only-mobile {
|
438 |
+
display: none;
|
439 |
+
}
|
440 |
+
body:not(.maximized-view) #chatListContainer {
|
441 |
+
display: flex !important;
|
442 |
+
}
|
443 |
+
|
444 |
+
/* --- NORMAL DESKTOP VIEW --- */
|
445 |
+
body:not(.maximized-view) #source {
|
446 |
+
position: fixed;
|
447 |
+
bottom: 10px;
|
448 |
+
left: 10px;
|
449 |
+
}
|
450 |
+
|
451 |
+
body:not(.maximized-view) #settingsButton {
|
452 |
+
position: fixed;
|
453 |
+
bottom: 10px;
|
454 |
+
right: 10px;
|
455 |
+
}
|
456 |
+
|
457 |
+
/* --- MAXIMIZED DESKTOP VIEW --- */
|
458 |
+
body.maximized-view #toggleChatList {
|
459 |
+
display: inline-block;
|
460 |
+
}
|
461 |
+
}
|
462 |
+
|
463 |
+
/* Print styles */
|
464 |
+
@media print {
|
465 |
+
html, body {
|
466 |
+
background-color: #fff;
|
467 |
+
}
|
468 |
+
#chatContainer {
|
469 |
+
overflow: visible;
|
470 |
+
}
|
471 |
+
#inputContainer, #header, #buttons, #settings, #spacerBottom, #source, #chatListContainer {
|
472 |
+
display: none;
|
473 |
+
}
|
474 |
+
#colContainer {
|
475 |
+
max-width: 100%;
|
476 |
+
width: 100%;
|
477 |
+
}
|
478 |
+
.clip-badge-copy-icon, button {
|
479 |
+
display: none;
|
480 |
+
}
|
481 |
+
#chat > .ping {
|
482 |
+
border: 1px solid var(--message-ping-bg);
|
483 |
+
}
|
484 |
+
#chat > .pong {
|
485 |
+
border: 1px solid var(--message-pong-bg);
|
486 |
+
}
|
487 |
+
#chat > .system {
|
488 |
+
border: 1px solid var(--message-system-bg);
|
489 |
+
}
|
490 |
+
#chat .hljs > svg,
|
491 |
+
#chat .content img {
|
492 |
+
max-width: 100%;
|
493 |
+
}
|
494 |
+
}
|
495 |
+
|
496 |
+
/* Message button hover visibility */
|
497 |
+
/*
|
498 |
+
.msg_mod-add-btn, .msg_mod-del-btn, .msg_mod-prev-btn, .msg_mod-next-btn {
|
499 |
+
opacity: 0.2;
|
500 |
+
transition: opacity 0.2s;
|
501 |
+
}
|
502 |
+
.message:hover .msg_mod-add-btn, .message:hover .msg_mod-del-btn, .message:hover .msg_mod-prev-btn, .message:hover .msg_mod-next-btn {
|
503 |
+
opacity: 1;
|
504 |
+
}
|
505 |
+
*/
|
506 |
+
|
507 |
+
/* Error bubble styles */
|
508 |
+
#error-bubble {
|
509 |
+
display: none;
|
510 |
+
position: fixed;
|
511 |
+
top: 20px;
|
512 |
+
right: 20px;
|
513 |
+
background-color: var(--error-bg);
|
514 |
+
color: var(--error-text);
|
515 |
+
padding: 24px;
|
516 |
+
border-radius: 12px;
|
517 |
+
max-width: 400px;
|
518 |
+
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.7);
|
519 |
+
z-index: 1000;
|
520 |
+
animation: bubbleAppear 0.5s ease-out;
|
521 |
+
}
|
522 |
+
#error-bubble.hiding {
|
523 |
+
animation: bubbleDisappear 0.5s ease-out forwards;
|
524 |
+
}
|
525 |
+
@keyframes bubbleAppear {
|
526 |
+
from {
|
527 |
+
opacity: 0;
|
528 |
+
transform: translateX(20px) scale(0.9);
|
529 |
+
}
|
530 |
+
to {
|
531 |
+
opacity: 1;
|
532 |
+
transform: translateX(0) scale(1);
|
533 |
+
}
|
534 |
+
}
|
535 |
+
@keyframes bubbleDisappear {
|
536 |
+
from {
|
537 |
+
opacity: 1;
|
538 |
+
transform: translateX(0) scale(1);
|
539 |
+
}
|
540 |
+
to {
|
541 |
+
opacity: 0;
|
542 |
+
transform: translateX(20px) scale(0.9);
|
543 |
+
}
|
544 |
+
}
|
545 |
+
#error-bubble-content {
|
546 |
+
margin: 0;
|
547 |
+
font-size: 16px;
|
548 |
+
}
|
549 |
+
#error-bubble-content p {
|
550 |
+
margin: 12px 0;
|
551 |
+
}
|
552 |
+
#error-bubble-close {
|
553 |
+
position: absolute;
|
554 |
+
top: 12px;
|
555 |
+
right: 12px;
|
556 |
+
background: none;
|
557 |
+
border: none;
|
558 |
+
color: var(--error-text);
|
559 |
+
cursor: pointer;
|
560 |
+
font-size: 24px;
|
561 |
+
}
|
562 |
+
#error-bubble-close:hover {
|
563 |
+
color: #eee;
|
564 |
+
}
|
example_chat_with_flow_and_agents.json
ADDED
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"title": "Example Flow",
|
3 |
+
"data": {
|
4 |
+
"messages": [
|
5 |
+
{
|
6 |
+
"value": {
|
7 |
+
"role": "system",
|
8 |
+
"content": "You have the ability to present perspectives and provide real-time date and time information.\nYou can create and understand visuals, such as images, graphs, and charts, using SVG technology.\nUnless otherwise specified by the user, always use SVG for drawings.\nYou can express mathematical equations using latex notation, symbolized by $ and $$.\n\nStrictly adhere to user instructions, and in the event of conflicting directives, seek clarification or prioritize based on the user's needs.\nBe aware of the user's level of knowledge in the fields of programming and science to tailor your responses accordingly.\nIf you don't know the user's level of knowledge, assume a very high level of knowledge.\n\nBefore responding, thoroughly analyze the user's problem, considering the most efficient strategy to tackle and solve it step by step.\nIf you encounter an issue you can't solve or an error in your processes, communicate this clearly to the user and seek further guidance.\n\nAlways apply thoughtful consideration in all tasks.\nYour responses are backed by your extensive knowledge in programming and science.\nBe clear in articulating any ambiguities to ensure effective communication.\n\nKnowledge cutoff: none\nCurrent date: 2025-08-16\nCurrent time: 01:01\n\n--- AGENT DEFINITION ---\nYou are a Software Tester.\n--- END AGENT DEFINITION ---\n\n\n--- MCP TOOLS ---\n### MCP Tools:\n\nYou use tools via tool calls to help you solve questions. Make sure to use the following format for them, including the <dma:tool_call> and </dma:tool_call> tags. Tool calls should follow the following XML-inspired format:\n<dma:tool_call name=\"example_tool_name\">\n<parameter name=\"example_arg_name1\">\nexample_arg_value1\n</parameter>\n<parameter name=\"example_arg_name2\">\nexample_arg_value2\n</parameter>\n</dma:tool_call>\nDo not escape any of the tool call arguments. The arguments will be parsed as normal text. There is one exception: If you need to write </dma:tool_call> or </parameter> as value inside a <parameter>, write it like </dma:tool_call> or </parameter>.\n\nYou can use multiple tools in parallel by calling them together.\n\n#### Available Tools:\n\n1. **Datetime Get Current Datetime**\n - **Description**: Get the current date and time in ISO format.\n - **Action** (dma:tool_call name): `datetime_get_current_datetime`\n - **Arguments** (parameter name): \n\n2. **Fetch Fetch Web Page**\n - **Description**: Fetch the content of a web page given its URL. Supports HTTP and HTTPS, ignoring invalid SSL certificates.\n - **Action** (dma:tool_call name): `fetch_fetch_web_page`\n - **Arguments** (parameter name): \n - `url`: Url (type: string)(required)\n\n--- END MCP TOOLS ---"
|
9 |
+
},
|
10 |
+
"metadata": null,
|
11 |
+
"answerAlternatives": {
|
12 |
+
"messages": [
|
13 |
+
{
|
14 |
+
"value": {
|
15 |
+
"role": "user",
|
16 |
+
"content": "Summarize the idea behind the chosen version and what the architecure is and then fully repeat the code of the chosen version."
|
17 |
+
},
|
18 |
+
"metadata": null,
|
19 |
+
"answerAlternatives": {
|
20 |
+
"messages": [
|
21 |
+
{
|
22 |
+
"value": {
|
23 |
+
"role": "assistant",
|
24 |
+
"content": "**Summary of Idea and Architecture:**\n\nThe game is a simple number guessing challenge where the computer selects a random integer between 1 and 100, and the player attempts to guess it. After each guess, the player receives feedback indicating whether their guess is too high, too low, or correct. The game continues until the correct number is guessed, at which point it displays the number of attempts made.\n\nThe **software architecture** is a **single-tier, procedural design** with no external dependencies. It uses a minimal structure: a main function that encapsulates the game loop, input handling, and logic. The state (target number, attempt count) is maintained in local variables. Input/output is handled via the console using `input()` and `print()`. Error handling for invalid input (non-integer entries) is included using a `try-except` block. The entire program is self-contained and executes sequentially in a single script.\n\n---\n\n**Chosen Version (with improved readability):**\n\n```python\nimport random\n\n\ndef guess_the_number():\n \"\"\"Simple text-based 'Guess the Number' game.\"\"\"\n target = random.randint(1, 100)\n attempts = 7\n guessed = False\n\n print(\"I'm thinking of a number between 1 and 100. You have 7 attempts to guess it.\")\n\n while attempts > 0 and not guessed:\n try:\n guess = int(input(f\"Enter your guess (attempts left: {attempts}): \"))\n attempts -= 1\n\n if guess == target:\n print(f\"🎉 Congratulations! You guessed the number {target} correctly!\")\n guessed = True\n elif guess < target:\n print(\"Too low! Try again.\")\n else:\n print(\"Too high! Try again.\")\n\n except ValueError:\n print(\"Please enter a valid integer.\")\n attempts += 1 # No penalty for invalid input\n\n if not guessed:\n print(f\"❌ Game over! The number was {target}.\")\n\n\n# Run the game\nif __name__ == \"__main__\":\n guess_the_number()\n```"
|
25 |
+
},
|
26 |
+
"metadata": {
|
27 |
+
"model": "qwen/qwen3-30b-a3b-2507",
|
28 |
+
"temperature": 0.5,
|
29 |
+
"top_p": 1
|
30 |
+
},
|
31 |
+
"answerAlternatives": {
|
32 |
+
"messages": [
|
33 |
+
{
|
34 |
+
"value": {
|
35 |
+
"role": "user",
|
36 |
+
"content": "Verify if the last version of the software is correct and report if something is not correct. Be concise.\n\nIf everything is correct, write only precisely that message: \"The code is correct\"."
|
37 |
+
},
|
38 |
+
"metadata": null,
|
39 |
+
"answerAlternatives": {
|
40 |
+
"messages": [
|
41 |
+
{
|
42 |
+
"value": {
|
43 |
+
"role": "assistant",
|
44 |
+
"content": "The code is correct"
|
45 |
+
},
|
46 |
+
"metadata": {
|
47 |
+
"model": "qwen/qwen3-30b-a3b-2507",
|
48 |
+
"temperature": 0.5,
|
49 |
+
"top_p": 1
|
50 |
+
},
|
51 |
+
"answerAlternatives": null
|
52 |
+
}
|
53 |
+
],
|
54 |
+
"activeMessageIndex": 0
|
55 |
+
}
|
56 |
+
}
|
57 |
+
],
|
58 |
+
"activeMessageIndex": 0
|
59 |
+
}
|
60 |
+
}
|
61 |
+
],
|
62 |
+
"activeMessageIndex": 0
|
63 |
+
}
|
64 |
+
}
|
65 |
+
],
|
66 |
+
"activeMessageIndex": 0
|
67 |
+
}
|
68 |
+
}
|
69 |
+
],
|
70 |
+
"activeMessageIndex": 0
|
71 |
+
},
|
72 |
+
"agents": [
|
73 |
+
{
|
74 |
+
"id": "agent-1755305965343",
|
75 |
+
"name": "developer",
|
76 |
+
"description": "Software Developer",
|
77 |
+
"systemPrompt": "You are a Software Developer.",
|
78 |
+
"availableAsTool": false,
|
79 |
+
"modelSettings": {},
|
80 |
+
"useCustomModelSettings": false
|
81 |
+
},
|
82 |
+
{
|
83 |
+
"id": "agent-1755306008385",
|
84 |
+
"name": "architect",
|
85 |
+
"description": "Software Architect",
|
86 |
+
"systemPrompt": "You are a Software Architect.",
|
87 |
+
"availableAsTool": false,
|
88 |
+
"modelSettings": {},
|
89 |
+
"useCustomModelSettings": false
|
90 |
+
},
|
91 |
+
{
|
92 |
+
"id": "agent-1755306043662",
|
93 |
+
"name": "tester",
|
94 |
+
"description": "Software Tester",
|
95 |
+
"systemPrompt": "You are a Software Tester.",
|
96 |
+
"availableAsTool": false,
|
97 |
+
"modelSettings": {},
|
98 |
+
"useCustomModelSettings": false
|
99 |
+
},
|
100 |
+
{
|
101 |
+
"id": "agent-1755306237948",
|
102 |
+
"name": "idea finder",
|
103 |
+
"description": "Idea Finder",
|
104 |
+
"systemPrompt": "You find new ideas.",
|
105 |
+
"availableAsTool": false,
|
106 |
+
"modelSettings": {},
|
107 |
+
"useCustomModelSettings": false
|
108 |
+
}
|
109 |
+
],
|
110 |
+
"flow": {
|
111 |
+
"steps": [
|
112 |
+
{
|
113 |
+
"id": "step-1755394400913",
|
114 |
+
"type": "simple-prompt",
|
115 |
+
"x": 33,
|
116 |
+
"y": 18,
|
117 |
+
"agentId": "agent-1755306237948",
|
118 |
+
"prompt": "Describe a small game that can be implemented in python in one sentence."
|
119 |
+
},
|
120 |
+
{
|
121 |
+
"id": "step-1755394607361",
|
122 |
+
"type": "simple-prompt",
|
123 |
+
"x": 390,
|
124 |
+
"y": 24,
|
125 |
+
"agentId": "agent-1755306008385",
|
126 |
+
"prompt": "Write the software architecture description for it. Be concise."
|
127 |
+
},
|
128 |
+
{
|
129 |
+
"id": "step-1755394655147",
|
130 |
+
"type": "multi-prompt",
|
131 |
+
"x": 831,
|
132 |
+
"y": 26,
|
133 |
+
"agentId": "agent-1755305965343",
|
134 |
+
"prompt": "Write the code for the software.",
|
135 |
+
"count": 2
|
136 |
+
},
|
137 |
+
{
|
138 |
+
"id": "step-1755394914198",
|
139 |
+
"type": "simple-prompt",
|
140 |
+
"x": 35,
|
141 |
+
"y": 702,
|
142 |
+
"agentId": "agent-1755306043662",
|
143 |
+
"prompt": "Verify if the last version of the software is correct and report if something is not correct. Be concise.\n\nIf everything is correct, write only precisely that message: \"The code is correct\"."
|
144 |
+
},
|
145 |
+
{
|
146 |
+
"id": "step-1755394958642",
|
147 |
+
"type": "simple-prompt",
|
148 |
+
"x": 467,
|
149 |
+
"y": 849,
|
150 |
+
"agentId": "agent-1755305965343",
|
151 |
+
"prompt": "Improve the code. Keep textual remarks concise but concentrate on the code."
|
152 |
+
},
|
153 |
+
{
|
154 |
+
"id": "step-1755395049626",
|
155 |
+
"type": "conditional-stop",
|
156 |
+
"x": 65,
|
157 |
+
"y": 1110,
|
158 |
+
"conditionType": "contains",
|
159 |
+
"condition": "The code is correct",
|
160 |
+
"onMatch": "stop"
|
161 |
+
},
|
162 |
+
{
|
163 |
+
"id": "step-1755396588353",
|
164 |
+
"type": "prompt-and-clear",
|
165 |
+
"x": 248,
|
166 |
+
"y": 356,
|
167 |
+
"agentId": "agent-1755305965343",
|
168 |
+
"prompt": "Summarize the idea behind the chosen version and what the architecure is and then fully repeat the code of the chosen version."
|
169 |
+
},
|
170 |
+
{
|
171 |
+
"id": "step-1755396802906",
|
172 |
+
"type": "consolidator",
|
173 |
+
"x": 801,
|
174 |
+
"y": 445,
|
175 |
+
"agentId": "agent-1755305965343",
|
176 |
+
"prePrompt": "Please choose the best of the following answers:",
|
177 |
+
"postPrompt": "Explain your choice."
|
178 |
+
}
|
179 |
+
],
|
180 |
+
"connections": [
|
181 |
+
{
|
182 |
+
"from": "step-1755394400913",
|
183 |
+
"to": "step-1755394607361",
|
184 |
+
"outputName": "default"
|
185 |
+
},
|
186 |
+
{
|
187 |
+
"from": "step-1755394607361",
|
188 |
+
"to": "step-1755394655147",
|
189 |
+
"outputName": "default"
|
190 |
+
},
|
191 |
+
{
|
192 |
+
"from": "step-1755394958642",
|
193 |
+
"to": "step-1755394914198",
|
194 |
+
"outputName": "default"
|
195 |
+
},
|
196 |
+
{
|
197 |
+
"from": "step-1755394914198",
|
198 |
+
"to": "step-1755395049626",
|
199 |
+
"outputName": "default"
|
200 |
+
},
|
201 |
+
{
|
202 |
+
"from": "step-1755395049626",
|
203 |
+
"to": "step-1755394958642",
|
204 |
+
"outputName": "default"
|
205 |
+
},
|
206 |
+
{
|
207 |
+
"from": "step-1755396588353",
|
208 |
+
"to": "step-1755394914198",
|
209 |
+
"outputName": "default"
|
210 |
+
},
|
211 |
+
{
|
212 |
+
"from": "step-1755396802906",
|
213 |
+
"to": "step-1755396588353",
|
214 |
+
"outputName": "default"
|
215 |
+
},
|
216 |
+
{
|
217 |
+
"from": "step-1755394655147",
|
218 |
+
"to": "step-1755396802906",
|
219 |
+
"outputName": "default"
|
220 |
+
}
|
221 |
+
]
|
222 |
+
}
|
223 |
+
}
|
index.html
CHANGED
@@ -1,19 +1,140 @@
|
|
1 |
-
<!
|
2 |
-
<html>
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
|
4 |
+
<head>
|
5 |
+
<meta charset="UTF-8">
|
6 |
+
<link rel="icon" href="penguin.svg" type="image/svg+xml" sizes="any">
|
7 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
8 |
+
<title>AIFlow Chat</title>
|
9 |
+
<link rel="stylesheet" href="css/3rdparty/hljs_androidstudio.min.css">
|
10 |
+
<link rel="stylesheet" href="css/main.css">
|
11 |
+
<link rel="stylesheet" href="css/agents.css">
|
12 |
+
</head>
|
13 |
+
|
14 |
+
<body>
|
15 |
+
<div id="colContainer">
|
16 |
+
<h2 id="header">AIFlow Chat</h2>
|
17 |
+
<div id="mainContent" style="display: flex; flex: 1; overflow: hidden;">
|
18 |
+
<div id="chatListContainer">
|
19 |
+
<div id="chatListWrapper">
|
20 |
+
<ul id="chatList"></ul>
|
21 |
+
</div>
|
22 |
+
<div id="chatControls">
|
23 |
+
<div class="left-buttons">
|
24 |
+
<button id="saveChatButton" title="Save current chat" class="toolButton">
|
25 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
26 |
+
<path
|
27 |
+
d="M3 5a2 2 0 0 1 2-2h11.586A2 2 0 0 1 18 3.586l2.707 2.707A1 1 0 0 1 21 7v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5zm6 14h6v-6H9v6zm8 0h2V7.414l-2-2V7a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V5H5v14h2v-6a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v6zM9 5v2h6V5H9z"
|
28 |
+
fill="currentColor" />
|
29 |
+
</svg>
|
30 |
+
</button>
|
31 |
+
<button id="loadChatButton" title="Load chat" class="toolButton">
|
32 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
33 |
+
<path
|
34 |
+
d="m 1.9819243,7.2224558 a 2,2 0 0 1 2.0000001,-2 h 5 a 1,1 0 0 1 0.707,0.293 l 1.7069996,1.707 h 8.586 a 2,2 0 0 1 2,2.0000004 v 9.9999998 a 2,2 0 0 1 -2,2 H 3.9819244 a 2,2 0 0 1 -2.0000001,-2 z m 6.5860001,0 h -4.586 V 19.222456 H 19.981924 V 9.2224562 h -9 a 1,1 0 0 1 -0.707,-0.293 z"
|
35 |
+
fill="currentColor" />
|
36 |
+
<path
|
37 |
+
d="M 2.3055156,7.2224558 3.0246072,4.7775442 c 0.3116724,-1.0596858 0.8954305,-2 2,-2 h 4.9999998 c 0.265195,5.67e-5 0.519507,0.1054506 0.707,0.293 l 1.707,1.707 7.579272,-0.1078637 c 1.104457,-0.015718 2.015556,0.89554 2,2 l -0.03596,2.5527757 h -2 l 0.03596,-2.5527757 -7.993272,0.1078637 c -0.265195,-5.66e-5 -0.519507,-0.1054506 -0.707,-0.293 l -1.7070048,-1.707 h -4.586 L 4.3055155,7.2224558 Z"
|
38 |
+
fill="currentColor" />
|
39 |
+
</svg>
|
40 |
+
</button>
|
41 |
+
</div>
|
42 |
+
<button id="newChatButton" title="New chat" class="toolButton">
|
43 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
44 |
+
<path
|
45 |
+
d="M12 4a1 1 0 0 1 1 1v6h6a1 1 0 1 1 0 2h-6v6a1 1 0 1 1-2 0v-6H5a1 1 0 1 1 0-2h6V5a1 1 0 0 1 1-1z" fill="currentColor"/>
|
46 |
+
</svg>
|
47 |
+
</button>
|
48 |
+
</div>
|
49 |
+
</div>
|
50 |
+
<div id="chatArea" style="flex: 1; display: flex; flex-direction: column; overflow: hidden;">
|
51 |
+
<div id="tabs">
|
52 |
+
<div id="main-tabs">
|
53 |
+
<button class="tab-button active" data-tab="chat">Chat</button>
|
54 |
+
</div>
|
55 |
+
<div id="tab-buttons">
|
56 |
+
<a id="source" href="https://github.com/dmatscheko/aiflow-chat" target="_blank">github src</a>
|
57 |
+
<button id="toggleChatList" title="Chats List" class="toolButton only-mobile">
|
58 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3 5h18a1 1 0 1 0 0-2H3a1 1 0 0 0 0 2zm18 4H3a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2zm0 6H3a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2z" fill="currentColor"/></svg>
|
59 |
+
</button>
|
60 |
+
<button id="settingsButton" title="Settings" class="toolButton">
|
61 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
62 |
+
<path
|
63 |
+
d="M12 4a1 1 0 0 0-1 1c0 1.692-2.046 2.54-3.243 1.343a1 1 0 1 0-1.414 1.414C7.54 8.954 6.693 11 5 11a1 1 0 1 0 0 2c1.692 0 2.54 2.046 1.343 3.243a1 1 0 0 0 1.414 1.414C8.954 16.46 11 17.307 11 19a1 1 0 1 0 2 0c0-1.692 2.046-2.54 3.243-1.343a1 1 0 0 0 1.414-1.414C16.46 15.046 17.307 13 19 13a1 1 0 1 0 0-2c-1.692 0-2.54-2.046-1.343-3.243a1 1 0 0 0-1.414-1.414C15.046 7.54 13 6.693 13 5a1 1 0 0 0-1-1zm-2.992.777a3 3 0 0 1 5.984 0 3 3 0 0 1 4.23 4.231 3 3 0 0 1 .001 5.984 3 3 0 0 1-4.231 4.23 3 3 0 0 1-5.984 0 3 3 0 0 1-4.231-4.23 3 3 0 0 1 0-5.984 3 3 0 0 1 4.231-4.231z"
|
64 |
+
fill="currentColor" />
|
65 |
+
<path
|
66 |
+
d="M12 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm-2.828-.828a4 4 0 1 1 5.656 5.656 4 4 0 0 1-5.656-5.656z"
|
67 |
+
fill="currentColor" />
|
68 |
+
</svg>
|
69 |
+
</button>
|
70 |
+
</div>
|
71 |
+
</div>
|
72 |
+
<div id="tab-content">
|
73 |
+
<div class="tab-pane active" id="chat-tab-pane">
|
74 |
+
<div id="chatContainer">
|
75 |
+
<div id="chat"></div>
|
76 |
+
</div>
|
77 |
+
<div id="inputContainer">
|
78 |
+
<textarea id="messageInput" autofocus
|
79 |
+
placeholder="Enter multiline message and press <shift>+<enter>, or <ctrl>+<enter>, or click the triangle button."
|
80 |
+
rows="3"></textarea>
|
81 |
+
<button id="submitButton" title="Submit message / Stop assistant" class="toolButton">
|
82 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
|
83 |
+
xmlns="http://www.w3.org/2000/svg">
|
84 |
+
<path
|
85 |
+
d="M6 6.741c0-1.544 1.674-2.505 3.008-1.728l9.015 5.26c1.323.771 1.323 2.683 0 3.455l-9.015 5.258C7.674 19.764 6 18.803 6 17.26V6.741zM17.015 12L8 6.741V17.26L17.015 12z"
|
86 |
+
fill="currentColor" />
|
87 |
+
</svg>
|
88 |
+
</button>
|
89 |
+
</div>
|
90 |
+
</div>
|
91 |
+
</div>
|
92 |
+
</div>
|
93 |
+
</div>
|
94 |
+
</div>
|
95 |
+
<div id="settings">
|
96 |
+
<div id="global-model-settings-container"></div>
|
97 |
+
<fieldset>
|
98 |
+
<legend>User role</legend>
|
99 |
+
<input type="radio" id="system" name="user_role" value="system">
|
100 |
+
<label for="system">System</label><br>
|
101 |
+
<input type="radio" id="user" name="user_role" value="user" checked>
|
102 |
+
<label for="user">User</label><br>
|
103 |
+
<input type="radio" id="assistant" name="user_role" value="assistant">
|
104 |
+
<label for="assistant">Assistant</label><br>
|
105 |
+
<input type="radio" id="tool" name="user_role" value="tool">
|
106 |
+
<label for="tool">Tool</label>
|
107 |
+
</fieldset>
|
108 |
+
<br>
|
109 |
+
<fieldset>
|
110 |
+
<legend>Connection</legend>
|
111 |
+
<label for="endpoint" style="margin-bottom: 4px; display: block;">API Endpoint</label>
|
112 |
+
<input type="text" id="endpoint" value="https://api.openai.com/v1/chat/completions">
|
113 |
+
<label for="apiKey" style="margin-bottom: 4px; display: block; margin-top: 12px;">API Key</label>
|
114 |
+
<input type="password" id="apiKey">
|
115 |
+
<div id="session-login">
|
116 |
+
<label for="login-btn">Login</label>
|
117 |
+
<button id="login-btn" title="Login" class="toolButton">
|
118 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14 19a1 1 0 1 0 0 2h5a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2h-5a1 1 0 1 0 0 2h5v14h-5z" fill="currentColor"/><path d="M15.714 12.7a.996.996 0 0 0 .286-.697v-.006a.997.997 0 0 0-.293-.704l-4-4a1 1 0 1 0-1.414 1.414L12.586 11H3a1 1 0 1 0 0 2h9.586l-2.293 2.293a1 1 0 1 0 1.414 1.414l4-4 .007-.007z" fill="currentColor"/></svg>
|
119 |
+
</button>
|
120 |
+
</div>
|
121 |
+
<div id="session-logout">
|
122 |
+
<label for="logout-btn">Logout</label>
|
123 |
+
<button id="logout-btn" title="Logout" class="toolButton">
|
124 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M11 20a1 1 0 0 0-1-1H5V5h5a1 1 0 1 0 0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h5a1 1 0 0 0 1-1z" fill="currentColor"/><path d="M21.714 12.7a.996.996 0 0 0 .286-.697v-.006a.997.997 0 0 0-.293-.704l-4-4a1 1 0 1 0-1.414 1.414L18.586 11H9a1 1 0 1 0 0 2h9.586l-2.293 2.293a1 1 0 1 0 1.414 1.414l4-4 .007-.007z" fill="currentColor"/></svg>
|
125 |
+
</button>
|
126 |
+
</div>
|
127 |
+
</fieldset>
|
128 |
+
</div>
|
129 |
+
<div id="error-bubble">
|
130 |
+
<button id="error-bubble-close">×</button>
|
131 |
+
<div id="error-bubble-content"></div>
|
132 |
+
</div>
|
133 |
+
<script src="js/3rdparty/katex.min.js"></script>
|
134 |
+
<script src="js/3rdparty/auto-render.min.js"></script>
|
135 |
+
<script src="js/3rdparty/markdown-it.min.js"></script>
|
136 |
+
<script src="js/3rdparty/highlight.min.js"></script>
|
137 |
+
<script type="module" src="js/main.js"></script>
|
138 |
+
</body>
|
139 |
+
|
140 |
+
</html>
|
js/3rdparty/auto-render.min.js
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t(require("katex")):"function"==typeof define&&define.amd?define(["katex"],t):"object"==typeof exports?exports.renderMathInElement=t(require("katex")):e.renderMathInElement=t(e.katex)}("undefined"!=typeof self?self:this,(function(e){return function(){"use strict";var t={771:function(t){t.exports=e}},r={};function n(e){var i=r[e];if(void 0!==i)return i.exports;var a=r[e]={exports:{}};return t[e](a,a.exports,n),a.exports}n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,{a:t}),t},n.d=function(e,t){for(var r in t)n.o(t,r)&&!n.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)};var i={};return function(){n.d(i,{default:function(){return s}});var e=n(771),t=n.n(e),r=function(e,t,r){for(var n=r,i=0,a=e.length;n<t.length;){var o=t[n];if(i<=0&&t.slice(n,n+a)===e)return n;"\\"===o?n++:"{"===o?i++:"}"===o&&i--,n++}return-1},a=/^\\begin{/,o=function(e,t){for(var n,i=[],o=new RegExp("("+t.map((function(e){return e.left.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&")})).join("|")+")");-1!==(n=e.search(o));){n>0&&(i.push({type:"text",data:e.slice(0,n)}),e=e.slice(n));var l=t.findIndex((function(t){return e.startsWith(t.left)}));if(-1===(n=r(t[l].right,e,t[l].left.length)))break;var d=e.slice(0,n+t[l].right.length),s=a.test(d)?d:e.slice(t[l].left.length,n);i.push({type:"math",data:s,rawData:d,display:t[l].display}),e=e.slice(n+t[l].right.length)}return""!==e&&i.push({type:"text",data:e}),i},l=function(e,r){var n=o(e,r.delimiters);if(1===n.length&&"text"===n[0].type)return null;for(var i=document.createDocumentFragment(),a=0;a<n.length;a++)if("text"===n[a].type)i.appendChild(document.createTextNode(n[a].data));else{var l=document.createElement("span"),d=n[a].data;r.displayMode=n[a].display;try{r.preProcess&&(d=r.preProcess(d)),t().render(d,l,r)}catch(e){if(!(e instanceof t().ParseError))throw e;r.errorCallback("KaTeX auto-render: Failed to parse `"+n[a].data+"` with ",e),i.appendChild(document.createTextNode(n[a].rawData));continue}i.appendChild(l)}return i},d=function e(t,r){for(var n=0;n<t.childNodes.length;n++){var i=t.childNodes[n];if(3===i.nodeType){for(var a=i.textContent,o=i.nextSibling,d=0;o&&o.nodeType===Node.TEXT_NODE;)a+=o.textContent,o=o.nextSibling,d++;var s=l(a,r);if(s){for(var f=0;f<d;f++)i.nextSibling.remove();n+=s.childNodes.length-1,t.replaceChild(s,i)}else n+=d}else 1===i.nodeType&&function(){var t=" "+i.className+" ";-1===r.ignoredTags.indexOf(i.nodeName.toLowerCase())&&r.ignoredClasses.every((function(e){return-1===t.indexOf(" "+e+" ")}))&&e(i,r)}()}},s=function(e,t){if(!e)throw new Error("No element provided to render");var r={};for(var n in t)t.hasOwnProperty(n)&&(r[n]=t[n]);r.delimiters=r.delimiters||[{left:"$$",right:"$$",display:!0},{left:"\\(",right:"\\)",display:!1},{left:"\\begin{equation}",right:"\\end{equation}",display:!0},{left:"\\begin{align}",right:"\\end{align}",display:!0},{left:"\\begin{alignat}",right:"\\end{alignat}",display:!0},{left:"\\begin{gather}",right:"\\end{gather}",display:!0},{left:"\\begin{CD}",right:"\\end{CD}",display:!0},{left:"\\[",right:"\\]",display:!0}],r.ignoredTags=r.ignoredTags||["script","noscript","style","textarea","pre","code","option"],r.ignoredClasses=r.ignoredClasses||[],r.errorCallback=r.errorCallback||console.error,r.macros=r.macros||{},d(e,r)}}(),i=i.default}()}));
|
js/3rdparty/highlight.min.js
ADDED
The diff for this file is too large to render.
See raw diff
|
|
js/3rdparty/katex.min.js
ADDED
The diff for this file is too large to render.
See raw diff
|
|
js/3rdparty/markdown-it.min.js
ADDED
The diff for this file is too large to render.
See raw diff
|
|
js/app.js
ADDED
@@ -0,0 +1,500 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview Main application class.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import Store from './state/store.js';
|
8 |
+
import ApiService from './services/api-service.js';
|
9 |
+
import ChatService from './services/chat-service.js';
|
10 |
+
import ConfigService from './services/config-service.js';
|
11 |
+
import SettingsPanel from './components/settings-panel.js';
|
12 |
+
import { ChatBox } from './components/chatbox.js';
|
13 |
+
import ChatListView from './components/chatlist-view.js';
|
14 |
+
import { log, triggerError } from './utils/logger.js';
|
15 |
+
import { resetEditing } from './utils/chat.js';
|
16 |
+
import { showLogin, showLogout } from './utils/ui.js';
|
17 |
+
import { exportJson, importJson } from './utils/shared.js';
|
18 |
+
import { hooks, registerPlugin } from './hooks.js';
|
19 |
+
import { formattingPlugins } from './plugins/formatting.js';
|
20 |
+
import { alternativeNavigationPlugin, messageModificationPlugin } from './plugins/ui-controls.js';
|
21 |
+
import { avatarsPlugin } from './plugins/avatars.js';
|
22 |
+
import { mcpPlugin } from './plugins/mcp.js';
|
23 |
+
import { errorBubblePlugin } from './plugins/error-bubble.js';
|
24 |
+
import { agentsPlugin } from './plugins/agents.js';
|
25 |
+
import { modelParamsPlugin } from './plugins/model-params.js';
|
26 |
+
import { maximizePlugin } from './plugins/maximize-view.js';
|
27 |
+
import { startMessage, messageSubmit, messageStop, defaultEndpoint } from './config.js';
|
28 |
+
|
29 |
+
/**
|
30 |
+
* @class App
|
31 |
+
* Main application orchestrator.
|
32 |
+
*/
|
33 |
+
class App {
|
34 |
+
constructor() {
|
35 |
+
log(5, 'App: Constructor called');
|
36 |
+
this.ui = {
|
37 |
+
chatBox: null,
|
38 |
+
messageEl: document.getElementById('messageInput'),
|
39 |
+
submitButton: document.getElementById('submitButton'),
|
40 |
+
newChatButton: document.getElementById('newChatButton'),
|
41 |
+
saveChatButton: document.getElementById('saveChatButton'),
|
42 |
+
loadChatButton: document.getElementById('loadChatButton'),
|
43 |
+
};
|
44 |
+
this.store = new Store({
|
45 |
+
receiving: false,
|
46 |
+
regenerateLastAnswer: false,
|
47 |
+
controller: new AbortController(),
|
48 |
+
editingPos: null,
|
49 |
+
chats: [],
|
50 |
+
currentChat: null,
|
51 |
+
ui: this.ui,
|
52 |
+
});
|
53 |
+
|
54 |
+
this.configService = new ConfigService(this.store);
|
55 |
+
this.apiService = new ApiService(this.store);
|
56 |
+
this.chatService = new ChatService(this.store, this.configService);
|
57 |
+
|
58 |
+
this.settingsPanel = new SettingsPanel({
|
59 |
+
configService: this.configService,
|
60 |
+
app: this,
|
61 |
+
});
|
62 |
+
|
63 |
+
this.chatListView = new ChatListView({
|
64 |
+
onChatSelected: (chatId) => this.chatService.switchChat(chatId),
|
65 |
+
onChatDeleted: (chatId) => this.chatService.deleteChat(chatId),
|
66 |
+
onTitleEdited: (chatId, newTitle) => this.chatService.updateChatTitle(chatId, newTitle),
|
67 |
+
});
|
68 |
+
|
69 |
+
this.ui.chatBox = new ChatBox(this.store);
|
70 |
+
|
71 |
+
this.store.subscribe('receiving', (val) => {
|
72 |
+
this.ui.submitButton.innerHTML = val ? messageStop : messageSubmit;
|
73 |
+
});
|
74 |
+
this.store.subscribe('chats', (chats) => this.chatListView.render(chats, this.store.get('currentChat')));
|
75 |
+
this.store.subscribe('currentChat', (chat) => {
|
76 |
+
this.onChatSwitched(chat);
|
77 |
+
this.chatListView.render(this.store.get('chats'), chat);
|
78 |
+
});
|
79 |
+
|
80 |
+
if (window.innerWidth <= 1037) {
|
81 |
+
document.getElementById('chatListContainer').classList.add('hidden');
|
82 |
+
}
|
83 |
+
hooks.onGenerateAIResponse.push((options, chatlog) => this.generateAIResponse(options, chatlog));
|
84 |
+
}
|
85 |
+
|
86 |
+
/**
|
87 |
+
* Initializes the application.
|
88 |
+
*/
|
89 |
+
async init() {
|
90 |
+
log(3, 'App: init called');
|
91 |
+
this.registerPlugins();
|
92 |
+
this.setupGlobalErrorHandlers();
|
93 |
+
|
94 |
+
this.ui.chatBox.onUpdate = () => this.chatService.persistChats();
|
95 |
+
|
96 |
+
this.chatService.init();
|
97 |
+
|
98 |
+
this.settingsPanel.setApiKey(this.configService.getItem('apiKey', ''));
|
99 |
+
this.settingsPanel.setEndpoint(this.configService.getItem('endpoint', defaultEndpoint));
|
100 |
+
|
101 |
+
await this.handleLogin();
|
102 |
+
|
103 |
+
hooks.onSettingsRender.forEach(fn => fn(this.settingsPanel.ui.settingsEl));
|
104 |
+
|
105 |
+
this.setUpEventListeners();
|
106 |
+
}
|
107 |
+
|
108 |
+
/**
|
109 |
+
* Registers all the plugins.
|
110 |
+
*/
|
111 |
+
registerPlugins() {
|
112 |
+
registerPlugin(agentsPlugin, this);
|
113 |
+
registerPlugin(mcpPlugin, this);
|
114 |
+
formattingPlugins.forEach(plugin => registerPlugin(plugin, this));
|
115 |
+
registerPlugin(alternativeNavigationPlugin, this);
|
116 |
+
registerPlugin(messageModificationPlugin, this);
|
117 |
+
registerPlugin(avatarsPlugin, this);
|
118 |
+
registerPlugin(errorBubblePlugin, this);
|
119 |
+
registerPlugin(modelParamsPlugin, this);
|
120 |
+
registerPlugin(maximizePlugin, this);
|
121 |
+
}
|
122 |
+
|
123 |
+
/**
|
124 |
+
* Sets up global error handlers.
|
125 |
+
*/
|
126 |
+
setupGlobalErrorHandlers() {
|
127 |
+
window.addEventListener('error', (event) => {
|
128 |
+
log(1, 'Global error', event.error || event.message);
|
129 |
+
triggerError(event.error || new Error(event.message));
|
130 |
+
event.preventDefault();
|
131 |
+
});
|
132 |
+
window.addEventListener('unhandledrejection', (event) => {
|
133 |
+
log(1, 'Global unhandled rejection', event.reason);
|
134 |
+
triggerError(event.reason);
|
135 |
+
event.preventDefault();
|
136 |
+
});
|
137 |
+
}
|
138 |
+
|
139 |
+
/**
|
140 |
+
* Handles the login process.
|
141 |
+
*/
|
142 |
+
async handleLogin() {
|
143 |
+
let success = this.configService.getItem('endpoint', '') !== '';
|
144 |
+
if (success) {
|
145 |
+
success = await this.loadModels();
|
146 |
+
if (success) {
|
147 |
+
success = this.configService.getItem('model', '') !== '';
|
148 |
+
if (success) {
|
149 |
+
showLogout();
|
150 |
+
this.settingsPanel.toggle('close');
|
151 |
+
} else {
|
152 |
+
triggerError('Please select a model.');
|
153 |
+
showLogout();
|
154 |
+
this.settingsPanel.toggle('open');
|
155 |
+
}
|
156 |
+
} else {
|
157 |
+
triggerError('Models could not be loaded. Is the API endpoint correct?');
|
158 |
+
showLogin();
|
159 |
+
this.settingsPanel.toggle('open');
|
160 |
+
}
|
161 |
+
} else {
|
162 |
+
triggerError('Please log in.');
|
163 |
+
showLogin();
|
164 |
+
// Pass empty array to clear models if login fails
|
165 |
+
this.configService.setItem('models', '[]');
|
166 |
+
this.settingsPanel.toggle('open');
|
167 |
+
}
|
168 |
+
}
|
169 |
+
|
170 |
+
/**
|
171 |
+
* Handles chat switching.
|
172 |
+
* @param {Object} chat - The chat to switch to.
|
173 |
+
*/
|
174 |
+
onChatSwitched(chat) {
|
175 |
+
log(3, 'App: onChatSwitched called for chat', chat?.id);
|
176 |
+
this.ui.chatBox.setChatlog(chat?.chatlog || null);
|
177 |
+
if (window.innerWidth <= 1037) {
|
178 |
+
document.getElementById('chatListContainer').classList.add('hidden');
|
179 |
+
}
|
180 |
+
}
|
181 |
+
|
182 |
+
/**
|
183 |
+
* Fetches models from the API.
|
184 |
+
* @returns {Promise<boolean>} True if models were loaded successfully.
|
185 |
+
*/
|
186 |
+
async loadModels() {
|
187 |
+
log(3, 'App: loadModels called');
|
188 |
+
const endpoint = this.configService.getItem('endpoint', defaultEndpoint);
|
189 |
+
const apiKey = this.configService.getItem('apiKey', '');
|
190 |
+
if (!endpoint) {
|
191 |
+
return false;
|
192 |
+
}
|
193 |
+
try {
|
194 |
+
const models = await this.apiService.getModels(endpoint, apiKey);
|
195 |
+
this.configService.setItem('models', JSON.stringify(models));
|
196 |
+
|
197 |
+
if (models && models.length > 0) {
|
198 |
+
const currentModel = this.configService.getItem('model');
|
199 |
+
if (currentModel) {
|
200 |
+
if (!models.some(model => model.id === currentModel)) {
|
201 |
+
this.configService.removeItem('model');
|
202 |
+
}
|
203 |
+
}
|
204 |
+
} else {
|
205 |
+
this.configService.removeItem('model');
|
206 |
+
triggerError('API endpoint does not provide any AI models. Please configure AI models there.');
|
207 |
+
}
|
208 |
+
|
209 |
+
// The settings panel will now re-render with the new models
|
210 |
+
// when it is opened. We could trigger a re-render here if needed.
|
211 |
+
return true;
|
212 |
+
} catch (err) {
|
213 |
+
log(1, 'App: Failed to load models', err);
|
214 |
+
triggerError('Failed to load models:', err);
|
215 |
+
if (this.configService.getItem('apiKey') !== null) {
|
216 |
+
this.configService.removeItem('apiKey');
|
217 |
+
this.configService.removeItem('models');
|
218 |
+
showLogin();
|
219 |
+
triggerError('Session invalid, logged out.');
|
220 |
+
}
|
221 |
+
return false;
|
222 |
+
}
|
223 |
+
}
|
224 |
+
|
225 |
+
/**
|
226 |
+
* Sets up the main event listeners.
|
227 |
+
*/
|
228 |
+
setUpEventListeners() {
|
229 |
+
log(3, 'App: setUpEventListeners called');
|
230 |
+
this.ui.submitButton.addEventListener('click', () => {
|
231 |
+
log(4, 'App: Submit button clicked, receiving:', this.store.get('receiving'));
|
232 |
+
if (this.store.get('receiving')) {
|
233 |
+
this.store.get('controller').abort();
|
234 |
+
return;
|
235 |
+
}
|
236 |
+
this.submitUserMessage(this.ui.messageEl.value, document.querySelector('input[name="user_role"]:checked').value);
|
237 |
+
document.getElementById('user').checked = true;
|
238 |
+
this.ui.messageEl.value = '';
|
239 |
+
this.ui.messageEl.style.height = 'auto';
|
240 |
+
});
|
241 |
+
this.ui.messageEl.addEventListener('keydown', event => {
|
242 |
+
if (event.key === 'Enter' && (event.shiftKey || event.ctrlKey || event.altKey)) {
|
243 |
+
event.preventDefault();
|
244 |
+
this.ui.submitButton.click();
|
245 |
+
}
|
246 |
+
});
|
247 |
+
this.ui.messageEl.addEventListener('input', function () {
|
248 |
+
this.style.height = 'auto';
|
249 |
+
let height = this.scrollHeight - parseInt(getComputedStyle(this).paddingTop) - parseInt(getComputedStyle(this).paddingBottom);
|
250 |
+
if (height > window.innerHeight / 2) {
|
251 |
+
height = window.innerHeight / 2;
|
252 |
+
this.style.overflowY = 'scroll';
|
253 |
+
} else {
|
254 |
+
this.style.overflowY = 'hidden';
|
255 |
+
}
|
256 |
+
if (height > this.clientHeight) this.style.height = `${height}px`;
|
257 |
+
});
|
258 |
+
document.addEventListener('keydown', event => {
|
259 |
+
if (event.key === 'Escape') {
|
260 |
+
this.store.get('controller').abort();
|
261 |
+
resetEditing(this.store, this.ui.chatBox.chatlog, this.ui.chatBox);
|
262 |
+
}
|
263 |
+
});
|
264 |
+
this.ui.newChatButton.addEventListener('click', () => {
|
265 |
+
log(4, 'App: New chat button clicked');
|
266 |
+
if (this.store.get('receiving')) this.store.get('controller').abort();
|
267 |
+
this.ui.messageEl.value = startMessage;
|
268 |
+
this.ui.messageEl.style.height = 'auto';
|
269 |
+
this.chatService.createNewChat();
|
270 |
+
});
|
271 |
+
this.ui.saveChatButton.addEventListener('click', () => {
|
272 |
+
log(4, 'App: Save chat button clicked');
|
273 |
+
const current = this.store.get('currentChat');
|
274 |
+
if (!current) return;
|
275 |
+
|
276 |
+
const chatData = {
|
277 |
+
title: current.title,
|
278 |
+
data: current.chatlog.toJSON(),
|
279 |
+
agents: current.agents || [],
|
280 |
+
flow: current.flow || { steps: [], connections: [] }
|
281 |
+
};
|
282 |
+
const filenameBase = current.title.replace(/\s/g, '_');
|
283 |
+
exportJson(chatData, filenameBase);
|
284 |
+
});
|
285 |
+
this.ui.loadChatButton.addEventListener('click', () => {
|
286 |
+
log(4, 'App: Load chat button clicked');
|
287 |
+
importJson('application/json', (jsonContent) => {
|
288 |
+
this.chatService.importChat(JSON.stringify(jsonContent));
|
289 |
+
});
|
290 |
+
});
|
291 |
+
|
292 |
+
document.getElementById('toggleChatList').addEventListener('click', () => {
|
293 |
+
log(5, 'App: Toggle chat list clicked');
|
294 |
+
document.getElementById('chatListContainer').classList.toggle('hidden');
|
295 |
+
});
|
296 |
+
}
|
297 |
+
|
298 |
+
/**
|
299 |
+
* Generates an AI response.
|
300 |
+
* @param {Object} [options={}] - Options for the generation.
|
301 |
+
* @param {Chatlog} [targetChatlog=this.chatlog] - The chatlog to generate a response for.
|
302 |
+
*/
|
303 |
+
async generateAIResponse(options = {}, targetChatlog = this.ui.chatBox.chatlog) {
|
304 |
+
log(3, 'App: generateAIResponse called');
|
305 |
+
if (this.store.get('receiving')) return;
|
306 |
+
|
307 |
+
// 1. Get settings from all scopes
|
308 |
+
const globalSettings = this.configService.getModelSettings();
|
309 |
+
const currentChat = this.store.get('currentChat');
|
310 |
+
const chatSettings = currentChat?.modelSettings || {};
|
311 |
+
|
312 |
+
let agentSettings = {};
|
313 |
+
const activeAgentId = currentChat?.activeAgentId;
|
314 |
+
if (activeAgentId) {
|
315 |
+
const agent = currentChat.agents.find(a => a.id === activeAgentId);
|
316 |
+
if (agent && agent.useCustomModelSettings) {
|
317 |
+
agentSettings = agent.modelSettings || {};
|
318 |
+
}
|
319 |
+
}
|
320 |
+
|
321 |
+
// 2. Merge settings (agent > chat > global > options)
|
322 |
+
const mergedSettings = { ...globalSettings, ...chatSettings, ...agentSettings, ...options };
|
323 |
+
|
324 |
+
if (!mergedSettings.model) {
|
325 |
+
log(2, 'App: No model selected');
|
326 |
+
triggerError('Please select a model.');
|
327 |
+
return;
|
328 |
+
}
|
329 |
+
|
330 |
+
this.store.set('receiving', true);
|
331 |
+
const targetMessage = targetChatlog.getLastMessage();
|
332 |
+
try {
|
333 |
+
let payload = {
|
334 |
+
messages: targetChatlog.getActiveMessageValues().filter(m => m.content !== null),
|
335 |
+
stream: true
|
336 |
+
};
|
337 |
+
|
338 |
+
// 3. Apply settings to payload via hook
|
339 |
+
hooks.onModelSettings.forEach(fn => fn(payload, mergedSettings));
|
340 |
+
|
341 |
+
// Don't send a request if there are no messages or only a system prompt.
|
342 |
+
if (payload.messages.length === 0) return;
|
343 |
+
if (payload.messages.length === 1 && payload.messages[0]?.role === 'system') return;
|
344 |
+
|
345 |
+
if (payload.messages[0]?.role === 'system') {
|
346 |
+
let systemContent = payload.messages[0].content;
|
347 |
+
for (let fn of hooks.onModifySystemPrompt) {
|
348 |
+
systemContent = fn(systemContent) || systemContent;
|
349 |
+
}
|
350 |
+
payload.messages[0].content = systemContent;
|
351 |
+
}
|
352 |
+
payload = hooks.beforeApiCall.reduce((p, fn) => fn(p, this.ui.chatBox) || p, payload);
|
353 |
+
|
354 |
+
const endpoint = this.configService.getItem('endpoint', defaultEndpoint)
|
355 |
+
const apiKey = this.configService.getItem('apiKey', '');
|
356 |
+
const abortSignal = this.store.get('controller').signal;
|
357 |
+
const reader = await this.apiService.streamAPIResponse(payload, endpoint, apiKey, abortSignal);
|
358 |
+
|
359 |
+
while (true) {
|
360 |
+
const { done, value } = await reader.read();
|
361 |
+
if (done) break;
|
362 |
+
const valueStr = new TextDecoder().decode(value);
|
363 |
+
if (valueStr.startsWith('{')) {
|
364 |
+
const data = JSON.parse(valueStr);
|
365 |
+
if (data.error) throw new Error(data.error.message);
|
366 |
+
}
|
367 |
+
const chunks = valueStr.split('\n');
|
368 |
+
let delta = '';
|
369 |
+
chunks.forEach(chunk => {
|
370 |
+
if (!chunk.startsWith('data: ')) return;
|
371 |
+
chunk = chunk.substring(6);
|
372 |
+
if (chunk === '' || chunk === '[DONE]') return;
|
373 |
+
const data = JSON.parse(chunk);
|
374 |
+
if (data.error) throw new Error(data.error.message);
|
375 |
+
delta += data.choices[0].delta.content || '';
|
376 |
+
});
|
377 |
+
if (delta === '') continue;
|
378 |
+
log(5, 'App: Received chunk', delta);
|
379 |
+
hooks.onChunkReceived.forEach(fn => fn(delta));
|
380 |
+
targetMessage.appendContent(delta);
|
381 |
+
targetChatlog.notify();
|
382 |
+
}
|
383 |
+
} catch (error) {
|
384 |
+
this.store.set('receiving', false); // Ensure receiving is false on error
|
385 |
+
if (error.name === 'AbortError') {
|
386 |
+
log(3, 'App: Response aborted');
|
387 |
+
hooks.onCancel.forEach(fn => fn());
|
388 |
+
this.store.set('controller', new AbortController());
|
389 |
+
const lastMessage = targetChatlog.getLastMessage();
|
390 |
+
if (lastMessage && lastMessage.value === null) {
|
391 |
+
const lastAlternatives = targetChatlog.getLastAlternatives();
|
392 |
+
lastAlternatives.messages.pop();
|
393 |
+
lastAlternatives.activeMessageIndex = lastAlternatives.messages.length - 1;
|
394 |
+
targetChatlog.notify();
|
395 |
+
} else if (lastMessage) {
|
396 |
+
lastMessage.appendContent('\n\n[Response aborted by user]');
|
397 |
+
lastMessage.cache = null;
|
398 |
+
}
|
399 |
+
return;
|
400 |
+
}
|
401 |
+
log(1, 'App: generateAIResponse error', error);
|
402 |
+
triggerError(error.message);
|
403 |
+
const lastMessage = targetChatlog.getLastMessage();
|
404 |
+
if (lastMessage.value === null) {
|
405 |
+
lastMessage.value = { role: 'assistant', content: `[Error: ${error.message}. Retry or check connection.]` };
|
406 |
+
hooks.afterMessageAdd.forEach(fn => fn(lastMessage));
|
407 |
+
} else {
|
408 |
+
lastMessage.appendContent(`\n\n[Error: ${error.message}. Retry or check connection.]`);
|
409 |
+
}
|
410 |
+
lastMessage.cache = null;
|
411 |
+
} finally {
|
412 |
+
// Set receiving to false before calling hooks, in case a hook triggers another generation
|
413 |
+
this.store.set('receiving', false);
|
414 |
+
const lastMessage = targetChatlog.getLastMessage();
|
415 |
+
|
416 |
+
// Set metadata here so hooks can use it
|
417 |
+
if (lastMessage && lastMessage.value !== null) {
|
418 |
+
lastMessage.cache = null;
|
419 |
+
lastMessage.metadata = { model: mergedSettings.model, temperature: mergedSettings.temperature, top_p: mergedSettings.top_p };
|
420 |
+
hooks.onMessageComplete.forEach(fn => fn(lastMessage, targetChatlog, this.ui.chatBox));
|
421 |
+
}
|
422 |
+
targetChatlog.notify();
|
423 |
+
this.chatService.persistChats();
|
424 |
+
}
|
425 |
+
}
|
426 |
+
|
427 |
+
/**
|
428 |
+
* Submits a user message.
|
429 |
+
* @param {string} message - The message to submit.
|
430 |
+
* @param {string} userRole - The role of the user.
|
431 |
+
*/
|
432 |
+
async submitUserMessage(message, userRole) {
|
433 |
+
log(3, 'App: submitUserMessage called with role', userRole);
|
434 |
+
// Ensure we are acting on the chatlog instance currently displayed in the UI
|
435 |
+
const currentChatlog = this.ui.chatBox.chatlog;
|
436 |
+
if (!currentChatlog) return;
|
437 |
+
|
438 |
+
const editedPos = this.store.get('editingPos');
|
439 |
+
log(4, 'App: editingPos is', editedPos);
|
440 |
+
if (editedPos !== null) {
|
441 |
+
log(4, 'App: Editing message at pos', editedPos);
|
442 |
+
const msg = currentChatlog.getNthMessage(editedPos);
|
443 |
+
if (msg) {
|
444 |
+
msg.value.role = userRole;
|
445 |
+
msg.setContent(message.trim());
|
446 |
+
msg.cache = null;
|
447 |
+
this.ui.chatBox.update();
|
448 |
+
}
|
449 |
+
this.store.set('editingPos', null);
|
450 |
+
document.getElementById('user').checked = true;
|
451 |
+
const editedMsg = currentChatlog.getNthMessage(editedPos);
|
452 |
+
if (editedMsg.value.role !== 'assistant' && editedMsg.answerAlternatives === null && currentChatlog.getFirstMessage() !== editedMsg) {
|
453 |
+
currentChatlog.addMessage({ role: 'assistant', content: null });
|
454 |
+
this.ui.chatBox.update();
|
455 |
+
await this.generateAIResponse({}, currentChatlog);
|
456 |
+
}
|
457 |
+
return;
|
458 |
+
}
|
459 |
+
|
460 |
+
if (!this.store.get('regenerateLastAnswer') && !message) return;
|
461 |
+
// Allow flow to submit messages even if another response is being generated
|
462 |
+
if (this.store.get('receiving') && !agentsPlugin.flowRunning) return;
|
463 |
+
|
464 |
+
if (userRole === 'assistant') {
|
465 |
+
let modifiedContent = message;
|
466 |
+
for (let fn of hooks.beforeUserMessageAdd) {
|
467 |
+
const result = fn(modifiedContent, userRole);
|
468 |
+
if (result === false) return;
|
469 |
+
if (typeof result === 'string') modifiedContent = result;
|
470 |
+
}
|
471 |
+
const newMessage = currentChatlog.addMessage({ role: userRole, content: modifiedContent });
|
472 |
+
hooks.afterMessageAdd.forEach(fn => fn(newMessage));
|
473 |
+
this.ui.chatBox.update();
|
474 |
+
return;
|
475 |
+
}
|
476 |
+
|
477 |
+
if (!this.store.get('regenerateLastAnswer')) {
|
478 |
+
message = message.trim();
|
479 |
+
let modifiedContent = message;
|
480 |
+
for (let fn of hooks.beforeUserMessageAdd) {
|
481 |
+
const result = fn(modifiedContent, userRole);
|
482 |
+
if (result === false) return;
|
483 |
+
if (typeof result === 'string') modifiedContent = result;
|
484 |
+
}
|
485 |
+
const newMessage = currentChatlog.addMessage({ role: userRole, content: modifiedContent });
|
486 |
+
hooks.afterMessageAdd.forEach(fn => fn(newMessage));
|
487 |
+
currentChatlog.addMessage(null);
|
488 |
+
}
|
489 |
+
|
490 |
+
this.store.set('regenerateLastAnswer', false);
|
491 |
+
this.ui.chatBox.update(); // Initial update to show user message
|
492 |
+
|
493 |
+
await this.generateAIResponse({}, currentChatlog);
|
494 |
+
|
495 |
+
// Final update to ensure UI is consistent after response generation, especially for flows.
|
496 |
+
this.ui.chatBox.update();
|
497 |
+
}
|
498 |
+
}
|
499 |
+
|
500 |
+
export default App;
|
js/components/chatbox.js
ADDED
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview The ChatBox component is responsible for displaying chat messages.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { log } from '../utils/logger.js';
|
8 |
+
import { hooks } from '../hooks.js';
|
9 |
+
import { triggerError } from '../utils/logger.js';
|
10 |
+
|
11 |
+
/**
|
12 |
+
* @class ChatBox
|
13 |
+
* Responsible for displaying the chat messages in the UI.
|
14 |
+
*/
|
15 |
+
class ChatBox {
|
16 |
+
/**
|
17 |
+
* @param {import('../state/store.js').default} store - The application's state store.
|
18 |
+
*/
|
19 |
+
constructor(store) {
|
20 |
+
log(5, 'ChatBox: Constructor called');
|
21 |
+
this.store = store;
|
22 |
+
this.container = document.getElementById('chat');
|
23 |
+
this.chatlog = null;
|
24 |
+
this.onUpdate = null; // Callback for after updates.
|
25 |
+
this.boundUpdate = this.update.bind(this);
|
26 |
+
}
|
27 |
+
|
28 |
+
/**
|
29 |
+
* Sets the chatlog for the chatbox to display.
|
30 |
+
* @param {import('./chatlog.js').Chatlog} chatlog - The chatlog to display.
|
31 |
+
*/
|
32 |
+
setChatlog(chatlog) {
|
33 |
+
if (this.chatlog) {
|
34 |
+
this.chatlog.unsubscribe(this.boundUpdate);
|
35 |
+
}
|
36 |
+
this.chatlog = chatlog;
|
37 |
+
if (this.chatlog) {
|
38 |
+
this.chatlog.subscribe(this.boundUpdate);
|
39 |
+
}
|
40 |
+
this.update();
|
41 |
+
}
|
42 |
+
|
43 |
+
/**
|
44 |
+
* Updates the HTML content of the chat window.
|
45 |
+
* @param {boolean} [scroll=true] - Whether to scroll to the bottom.
|
46 |
+
*/
|
47 |
+
update(scroll = true) {
|
48 |
+
log(5, 'ChatBox: update called, scroll:', scroll);
|
49 |
+
if (!this.chatlog) {
|
50 |
+
this.container.innerHTML = '';
|
51 |
+
return;
|
52 |
+
}
|
53 |
+
|
54 |
+
const shouldScrollDown = scroll && this.#isScrolledToBottom();
|
55 |
+
const fragment = document.createDocumentFragment();
|
56 |
+
let alternative = this.chatlog.rootAlternatives;
|
57 |
+
let lastRole = 'assistant';
|
58 |
+
let pos = 0;
|
59 |
+
// Traverse the active path through the chatlog.
|
60 |
+
while (alternative) {
|
61 |
+
const message = alternative.getActiveMessage();
|
62 |
+
if (!message) break;
|
63 |
+
if (message.cache) {
|
64 |
+
fragment.appendChild(message.cache); // Use cached element if available.
|
65 |
+
lastRole = message.value.role;
|
66 |
+
alternative = message.answerAlternatives;
|
67 |
+
pos++;
|
68 |
+
continue;
|
69 |
+
}
|
70 |
+
const msgIdx = alternative.activeMessageIndex;
|
71 |
+
const msgCnt = alternative.messages.length;
|
72 |
+
if (!message.value) {
|
73 |
+
const role = lastRole === 'assistant' ? 'user' : 'assistant';
|
74 |
+
const messageEl = this.formatMessage({ value: { role, content: '🤔...' } }, pos, msgIdx, msgCnt);
|
75 |
+
fragment.appendChild(messageEl);
|
76 |
+
break;
|
77 |
+
}
|
78 |
+
if (message.value.content === null) {
|
79 |
+
const messageEl = this.formatMessage({ value: { role: message.value.role, content: '🤔...' } }, pos, msgIdx, msgCnt);
|
80 |
+
fragment.appendChild(messageEl);
|
81 |
+
break;
|
82 |
+
}
|
83 |
+
const messageEl = this.formatMessage(message, pos, msgIdx, msgCnt);
|
84 |
+
fragment.appendChild(messageEl);
|
85 |
+
message.cache = messageEl; // Cache the element.
|
86 |
+
lastRole = message.value.role;
|
87 |
+
alternative = message.answerAlternatives;
|
88 |
+
pos++;
|
89 |
+
}
|
90 |
+
this.container.replaceChildren(fragment);
|
91 |
+
if (shouldScrollDown) {
|
92 |
+
this.container.parentElement.scrollTop = this.container.parentElement.scrollHeight;
|
93 |
+
}
|
94 |
+
if (this.onUpdate) this.onUpdate();
|
95 |
+
}
|
96 |
+
|
97 |
+
/**
|
98 |
+
* Checks if the chat container is scrolled to the bottom.
|
99 |
+
* @returns {boolean} True if scrolled to the bottom.
|
100 |
+
* @private
|
101 |
+
*/
|
102 |
+
#isScrolledToBottom() {
|
103 |
+
log(5, 'ChatBox: #isScrolledToBottom called');
|
104 |
+
const { scrollHeight, clientHeight, scrollTop } = this.container.parentElement;
|
105 |
+
return scrollHeight - clientHeight <= scrollTop + 5;
|
106 |
+
}
|
107 |
+
|
108 |
+
/**
|
109 |
+
* Formats a single message as an HTML element.
|
110 |
+
* @param {import('./chatlog.js').Message} message - The message to format.
|
111 |
+
* @param {number} pos - The position of the message in the chat.
|
112 |
+
* @param {number} msgIdx - The index of the message in its alternatives.
|
113 |
+
* @param {number} msgCnt - The total number of alternatives.
|
114 |
+
* @returns {HTMLElement} The formatted message element.
|
115 |
+
*/
|
116 |
+
formatMessage(message, pos, msgIdx, msgCnt) {
|
117 |
+
log(5, 'ChatBox: formatMessage called for pos', pos);
|
118 |
+
const el = document.createElement('div');
|
119 |
+
el.classList.add('message', message.value.role === 'assistant' ? 'pong' : 'ping');
|
120 |
+
if (message.value.role === 'system') el.classList.add('system');
|
121 |
+
el.dataset.pos = pos;
|
122 |
+
|
123 |
+
// Create header
|
124 |
+
const msgTitleStrip = document.createElement('small');
|
125 |
+
const roleEl = document.createElement('b');
|
126 |
+
roleEl.textContent = message.value.role;
|
127 |
+
msgTitleStrip.appendChild(roleEl);
|
128 |
+
|
129 |
+
if (message.metadata?.model) {
|
130 |
+
const modelEl = document.createElement('span');
|
131 |
+
modelEl.classList.add('right');
|
132 |
+
modelEl.textContent = ` ${message.metadata.model} `;
|
133 |
+
msgTitleStrip.appendChild(modelEl);
|
134 |
+
}
|
135 |
+
|
136 |
+
// Create a container for controls and call the hook
|
137 |
+
const controlsContainer = document.createElement('span');
|
138 |
+
controlsContainer.classList.add('message-controls', 'nobreak');
|
139 |
+
hooks.onRenderMessageControls.forEach(fn => fn(controlsContainer, message, this.chatlog, this));
|
140 |
+
msgTitleStrip.appendChild(controlsContainer);
|
141 |
+
|
142 |
+
el.appendChild(msgTitleStrip);
|
143 |
+
el.appendChild(document.createElement('br'));
|
144 |
+
el.appendChild(document.createElement('br'));
|
145 |
+
|
146 |
+
const formattedContent = this.#formatContent(message.value.content, message, pos);
|
147 |
+
if (formattedContent) {
|
148 |
+
el.appendChild(formattedContent);
|
149 |
+
}
|
150 |
+
|
151 |
+
hooks.onRenderMessage.forEach(fn => fn(el, message, this));
|
152 |
+
return el;
|
153 |
+
}
|
154 |
+
|
155 |
+
/**
|
156 |
+
* Formats the content of a message.
|
157 |
+
* @param {string} text - The text content to format.
|
158 |
+
* @param {import('./chatlog.js').Message} message - The message being formatted.
|
159 |
+
* @returns {HTMLElement | null} The formatted content element or null.
|
160 |
+
* @private
|
161 |
+
*/
|
162 |
+
#formatContent(text, message, pos) {
|
163 |
+
log(5, 'ChatBox: #formatContent called');
|
164 |
+
if (!text) return null;
|
165 |
+
try {
|
166 |
+
text = text.trim();
|
167 |
+
let html = text;
|
168 |
+
hooks.onFormatContent.forEach(fn => { html = fn(html, pos); });
|
169 |
+
const wrapper = document.createElement('div');
|
170 |
+
wrapper.classList.add('content');
|
171 |
+
wrapper.innerHTML = html;
|
172 |
+
hooks.onPostFormatContent.forEach(fn => { fn(wrapper, message, pos); });
|
173 |
+
return wrapper;
|
174 |
+
} catch (error) {
|
175 |
+
log(1, 'ChatBox: Formatting error', error);
|
176 |
+
triggerError('Formatting error:', error);
|
177 |
+
const wrapper = document.createElement('div');
|
178 |
+
wrapper.classList.add('content');
|
179 |
+
wrapper.innerHTML = `<p>Error formatting content: ${error.message}</p><pre>${text}</pre>`;
|
180 |
+
return wrapper;
|
181 |
+
}
|
182 |
+
}
|
183 |
+
}
|
184 |
+
|
185 |
+
export { ChatBox };
|
js/components/chatlist-view.js
ADDED
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview The ChatListView component displays the list of chats.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { log } from '../utils/logger.js';
|
8 |
+
import { createControlButton } from '../utils/ui.js';
|
9 |
+
|
10 |
+
/**
|
11 |
+
* @class ChatListView
|
12 |
+
* Manages the chat list UI.
|
13 |
+
*/
|
14 |
+
class ChatListView {
|
15 |
+
/**
|
16 |
+
* @param {Object} options - The options for the chat list view.
|
17 |
+
* @param {Function} options.onChatSelected - Callback for when a chat is selected.
|
18 |
+
* @param {Function} options.onChatDeleted - Callback for when a chat is deleted.
|
19 |
+
* @param {Function} options.onTitleEdited - Callback for when a chat title is edited.
|
20 |
+
*/
|
21 |
+
constructor({ onChatSelected, onChatDeleted, onTitleEdited }) {
|
22 |
+
this.onChatSelected = onChatSelected;
|
23 |
+
this.onChatDeleted = onChatDeleted;
|
24 |
+
this.onTitleEdited = onTitleEdited;
|
25 |
+
|
26 |
+
this.ui = {
|
27 |
+
chatList: document.getElementById('chatList'),
|
28 |
+
};
|
29 |
+
}
|
30 |
+
|
31 |
+
/**
|
32 |
+
* Renders the chat list.
|
33 |
+
* @param {Array<Object>} chats - The list of chats to render.
|
34 |
+
* @param {Object} currentChat - The currently active chat.
|
35 |
+
*/
|
36 |
+
render(chats, currentChat) {
|
37 |
+
log(3, 'ChatListView: render called');
|
38 |
+
this.ui.chatList.innerHTML = '';
|
39 |
+
chats.forEach(chat => {
|
40 |
+
const li = document.createElement('li');
|
41 |
+
li.dataset.id = chat.id;
|
42 |
+
li.classList.add('chat-item');
|
43 |
+
li.classList.toggle('active', chat.id === currentChat?.id);
|
44 |
+
|
45 |
+
const titleEl = document.createElement('span');
|
46 |
+
titleEl.textContent = chat.title;
|
47 |
+
titleEl.addEventListener('click', () => this.onChatSelected(chat.id));
|
48 |
+
|
49 |
+
const editButton = createControlButton(
|
50 |
+
'Edit Title',
|
51 |
+
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" fill="currentColor"/></svg>',
|
52 |
+
() => this.editTitle(li, titleEl, chat)
|
53 |
+
);
|
54 |
+
|
55 |
+
const deleteButton = createControlButton(
|
56 |
+
'Delete Chat',
|
57 |
+
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 4a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v2h4a1 1 0 1 1 0 2h-1.069l-.867 12.142A2 2 0 0 1 17.069 22H6.93a2 2 0 0 1-1.995-1.858L4.07 8H3a1 1 0 0 1 0-2h4V4zm2 2h6V4H9v2zM6.074 8l.857 12H17.07l.857-12H6.074zM10 10a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-6a1 1 0 0 1 1-1zm4 0a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-6a1 1 0 0 1 1-1z" fill="currentColor"/></svg>',
|
58 |
+
() => this.onChatDeleted(chat.id)
|
59 |
+
);
|
60 |
+
|
61 |
+
li.appendChild(titleEl);
|
62 |
+
li.appendChild(editButton);
|
63 |
+
li.appendChild(deleteButton);
|
64 |
+
this.ui.chatList.appendChild(li);
|
65 |
+
});
|
66 |
+
}
|
67 |
+
|
68 |
+
/**
|
69 |
+
* Handles the editing of a chat title.
|
70 |
+
* @param {HTMLElement} li - The list item element.
|
71 |
+
* @param {HTMLElement} titleEl - The title element.
|
72 |
+
* @param {Object} chat - The chat object.
|
73 |
+
*/
|
74 |
+
editTitle(li, titleEl, chat) {
|
75 |
+
const input = document.createElement('input');
|
76 |
+
input.type = 'text';
|
77 |
+
input.value = chat.title;
|
78 |
+
|
79 |
+
let handled = false;
|
80 |
+
const saveHandler = () => {
|
81 |
+
if (handled) return;
|
82 |
+
handled = true;
|
83 |
+
const newTitle = input.value.trim();
|
84 |
+
if (newTitle && newTitle !== chat.title) {
|
85 |
+
this.onTitleEdited(chat.id, newTitle);
|
86 |
+
}
|
87 |
+
if (li.contains(input)) {
|
88 |
+
li.replaceChild(titleEl, input);
|
89 |
+
}
|
90 |
+
};
|
91 |
+
|
92 |
+
input.addEventListener('blur', saveHandler);
|
93 |
+
input.addEventListener('keydown', (e) => {
|
94 |
+
if (e.key === 'Enter') {
|
95 |
+
saveHandler();
|
96 |
+
} else if (e.key === 'Escape') {
|
97 |
+
if (handled) return;
|
98 |
+
handled = true;
|
99 |
+
if (li.contains(input)) {
|
100 |
+
li.replaceChild(titleEl, input);
|
101 |
+
}
|
102 |
+
}
|
103 |
+
});
|
104 |
+
|
105 |
+
li.replaceChild(input, titleEl);
|
106 |
+
input.focus();
|
107 |
+
}
|
108 |
+
}
|
109 |
+
|
110 |
+
export default ChatListView;
|
js/components/chatlog.js
ADDED
@@ -0,0 +1,530 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview Defines the data structures for the chat history.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { log } from '../utils/logger.js';
|
8 |
+
import { hooks } from '../hooks.js';
|
9 |
+
|
10 |
+
/**
|
11 |
+
* @class Message
|
12 |
+
* Represents a single message in the chatlog.
|
13 |
+
*/
|
14 |
+
class Message {
|
15 |
+
/**
|
16 |
+
* @param {Object} value - The message value, e.g., { role: 'user', content: 'Hello' }.
|
17 |
+
*/
|
18 |
+
constructor(value) {
|
19 |
+
log(5, 'Message: Constructor called with value', value);
|
20 |
+
this.value = value;
|
21 |
+
this.metadata = null;
|
22 |
+
this.cache = null;
|
23 |
+
this.answerAlternatives = null;
|
24 |
+
}
|
25 |
+
|
26 |
+
/**
|
27 |
+
* Retrieves the active answer message if alternatives exist.
|
28 |
+
* @returns {Message | null} The active answer message or null.
|
29 |
+
*/
|
30 |
+
getAnswerMessage() {
|
31 |
+
log(5, 'Message: getAnswerMessage called');
|
32 |
+
return this.answerAlternatives ? this.answerAlternatives.getActiveMessage() : null;
|
33 |
+
}
|
34 |
+
|
35 |
+
/**
|
36 |
+
* Serializes the message to a JSON-compatible object.
|
37 |
+
* @returns {Object} The serialized message.
|
38 |
+
*/
|
39 |
+
toJSON() {
|
40 |
+
log(6, 'Message: toJSON called');
|
41 |
+
return {
|
42 |
+
value: this.value,
|
43 |
+
metadata: this.metadata,
|
44 |
+
answerAlternatives: this.answerAlternatives
|
45 |
+
};
|
46 |
+
}
|
47 |
+
|
48 |
+
/**
|
49 |
+
* Sets the content of the message.
|
50 |
+
* @param {string} content - The new content.
|
51 |
+
*/
|
52 |
+
setContent(content) {
|
53 |
+
log(5, 'Message: setContent called');
|
54 |
+
this.value.content = content;
|
55 |
+
this.cache = null;
|
56 |
+
}
|
57 |
+
|
58 |
+
/**
|
59 |
+
* Appends a delta to the content of the message.
|
60 |
+
* @param {string} delta - The content to append.
|
61 |
+
*/
|
62 |
+
appendContent(delta) {
|
63 |
+
log(5, 'Message: appendContent called with delta', delta);
|
64 |
+
if (this.value === null) {
|
65 |
+
this.value = { role: 'assistant', content: delta };
|
66 |
+
} else {
|
67 |
+
if (!this.value.content) this.value.content = '';
|
68 |
+
this.value.content += delta;
|
69 |
+
}
|
70 |
+
this.cache = null;
|
71 |
+
}
|
72 |
+
}
|
73 |
+
|
74 |
+
/**
|
75 |
+
* @class Alternatives
|
76 |
+
* Manages a set of alternative messages at a given point in the chatlog.
|
77 |
+
*/
|
78 |
+
class Alternatives {
|
79 |
+
constructor() {
|
80 |
+
log(5, 'Alternatives: Constructor called');
|
81 |
+
this.messages = [];
|
82 |
+
this.activeMessageIndex = -1;
|
83 |
+
}
|
84 |
+
|
85 |
+
/**
|
86 |
+
* Adds a new message or updates the active one if it's null.
|
87 |
+
* @param {Object} value - The value for the new message.
|
88 |
+
* @returns {Message} The new or updated message.
|
89 |
+
*/
|
90 |
+
addMessage(value) {
|
91 |
+
log(5, 'Alternatives: addMessage called with value', value);
|
92 |
+
this.clearCache();
|
93 |
+
const current = this.getActiveMessage();
|
94 |
+
if (current) {
|
95 |
+
if (current && current.value === null) {
|
96 |
+
current.value = value;
|
97 |
+
return current;
|
98 |
+
}
|
99 |
+
}
|
100 |
+
const newMessage = new Message(value);
|
101 |
+
this.activeMessageIndex = this.messages.push(newMessage) - 1;
|
102 |
+
return newMessage;
|
103 |
+
}
|
104 |
+
|
105 |
+
/**
|
106 |
+
* Gets the currently active message.
|
107 |
+
* @returns {Message | null} The active message or null.
|
108 |
+
*/
|
109 |
+
getActiveMessage() {
|
110 |
+
log(5, 'Alternatives: getActiveMessage called');
|
111 |
+
return this.activeMessageIndex !== -1 ? this.messages[this.activeMessageIndex] || null : null;
|
112 |
+
}
|
113 |
+
|
114 |
+
/**
|
115 |
+
* Cycles to the next alternative message.
|
116 |
+
* @returns {Message | null} The next message.
|
117 |
+
*/
|
118 |
+
next() {
|
119 |
+
log(5, 'Alternatives: next called');
|
120 |
+
if (this.activeMessageIndex === -1) return null;
|
121 |
+
if (!this.messages[this.activeMessageIndex] || this.messages[this.activeMessageIndex].value === null) {
|
122 |
+
this.messages.splice(this.activeMessageIndex, 1);
|
123 |
+
}
|
124 |
+
this.activeMessageIndex = (this.activeMessageIndex + 1) % this.messages.length;
|
125 |
+
return this.messages[this.activeMessageIndex];
|
126 |
+
}
|
127 |
+
|
128 |
+
/**
|
129 |
+
* Cycles to the previous alternative message.
|
130 |
+
* @returns {Message | null} The previous message.
|
131 |
+
*/
|
132 |
+
prev() {
|
133 |
+
log(5, 'Alternatives: prev called');
|
134 |
+
if (this.activeMessageIndex === -1) return null;
|
135 |
+
if (!this.messages[this.activeMessageIndex] || this.messages[this.activeMessageIndex].value === null) {
|
136 |
+
this.messages.splice(this.activeMessageIndex, 1);
|
137 |
+
}
|
138 |
+
this.activeMessageIndex = (this.activeMessageIndex - 1 + this.messages.length) % this.messages.length;
|
139 |
+
return this.messages[this.activeMessageIndex];
|
140 |
+
}
|
141 |
+
|
142 |
+
/**
|
143 |
+
* Clears the cache for all messages in this set of alternatives.
|
144 |
+
*/
|
145 |
+
clearCache() {
|
146 |
+
log(5, 'Alternatives: clearCache called');
|
147 |
+
this.messages.forEach(msg => { if (msg) msg.cache = null; });
|
148 |
+
}
|
149 |
+
|
150 |
+
/**
|
151 |
+
* Serializes the alternatives to a JSON-compatible object.
|
152 |
+
* @returns {Object} The serialized alternatives.
|
153 |
+
*/
|
154 |
+
toJSON() {
|
155 |
+
log(6, 'Alternatives: toJSON called');
|
156 |
+
return {
|
157 |
+
messages: this.messages.map(msg => msg ? msg.toJSON() : null),
|
158 |
+
activeMessageIndex: this.activeMessageIndex
|
159 |
+
};
|
160 |
+
}
|
161 |
+
}
|
162 |
+
|
163 |
+
/**
|
164 |
+
* @class Chatlog
|
165 |
+
* Manages the entire chat history as a tree of alternatives.
|
166 |
+
*/
|
167 |
+
class Chatlog {
|
168 |
+
constructor() {
|
169 |
+
log(5, 'Chatlog: Constructor called');
|
170 |
+
this.rootAlternatives = null;
|
171 |
+
this.subscribers = [];
|
172 |
+
}
|
173 |
+
|
174 |
+
/**
|
175 |
+
* Subscribes a callback to chatlog changes.
|
176 |
+
* @param {Function} cb - The callback to subscribe.
|
177 |
+
*/
|
178 |
+
subscribe(cb) {
|
179 |
+
log(5, 'Chatlog: subscribe called');
|
180 |
+
this.subscribers.push(cb);
|
181 |
+
}
|
182 |
+
|
183 |
+
/**
|
184 |
+
* Unsubscribes a callback from chatlog changes.
|
185 |
+
* @param {Function} cb - The callback to unsubscribe.
|
186 |
+
*/
|
187 |
+
unsubscribe(cb) {
|
188 |
+
log(5, 'Chatlog: unsubscribe called');
|
189 |
+
this.subscribers = this.subscribers.filter(s => s !== cb);
|
190 |
+
}
|
191 |
+
|
192 |
+
/**
|
193 |
+
* Notifies all subscribers of a change.
|
194 |
+
* @param {boolean} [scroll=true] - Whether to scroll to the bottom.
|
195 |
+
*/
|
196 |
+
notify(scroll = true) {
|
197 |
+
log(5, 'Chatlog: notify called');
|
198 |
+
this.subscribers.forEach(cb => cb(scroll));
|
199 |
+
hooks.onChatUpdated.forEach(fn => fn(this));
|
200 |
+
}
|
201 |
+
|
202 |
+
/**
|
203 |
+
* Adds a message to the chatlog.
|
204 |
+
* @param {Object} value - The value of the message to add.
|
205 |
+
* @returns {Message} The newly added message.
|
206 |
+
*/
|
207 |
+
addMessage(value) {
|
208 |
+
log(4, 'Chatlog: addMessage called with role', value?.role);
|
209 |
+
const lastMessage = this.getLastMessage();
|
210 |
+
if (!lastMessage) {
|
211 |
+
this.rootAlternatives = new Alternatives();
|
212 |
+
const msg = this.rootAlternatives.addMessage(value);
|
213 |
+
this.notify();
|
214 |
+
return msg;
|
215 |
+
}
|
216 |
+
if (lastMessage.value === null) {
|
217 |
+
lastMessage.value = value;
|
218 |
+
this.notify();
|
219 |
+
return lastMessage;
|
220 |
+
}
|
221 |
+
lastMessage.answerAlternatives = new Alternatives();
|
222 |
+
const msg = lastMessage.answerAlternatives.addMessage(value);
|
223 |
+
this.notify();
|
224 |
+
return msg;
|
225 |
+
}
|
226 |
+
|
227 |
+
/**
|
228 |
+
* Gets the position of a message in the active path.
|
229 |
+
* @param {Message} message - The message to find.
|
230 |
+
* @returns {number} The position of the message.
|
231 |
+
*/
|
232 |
+
getMessagePos(message) {
|
233 |
+
log(5, 'Chatlog: getMessagePos called');
|
234 |
+
let pos = 0;
|
235 |
+
let current = this.rootAlternatives;
|
236 |
+
while (current) {
|
237 |
+
const activeMessage = current.getActiveMessage();
|
238 |
+
if (!activeMessage || !activeMessage.answerAlternatives || activeMessage === message) return pos;
|
239 |
+
current = activeMessage.answerAlternatives;
|
240 |
+
pos++;
|
241 |
+
}
|
242 |
+
return 0;
|
243 |
+
}
|
244 |
+
|
245 |
+
/**
|
246 |
+
* Gets the first message in the active path.
|
247 |
+
* @returns {Message | null} The first message.
|
248 |
+
*/
|
249 |
+
getFirstMessage() {
|
250 |
+
log(5, 'Chatlog: getFirstMessage called');
|
251 |
+
return this.rootAlternatives ? this.rootAlternatives.getActiveMessage() : null;
|
252 |
+
}
|
253 |
+
|
254 |
+
/**
|
255 |
+
* Gets the last message in the active path.
|
256 |
+
* @returns {Message | null} The last message.
|
257 |
+
*/
|
258 |
+
getLastMessage() {
|
259 |
+
log(5, 'Chatlog: getLastMessage called');
|
260 |
+
const lastAlternatives = this.getLastAlternatives();
|
261 |
+
return lastAlternatives ? lastAlternatives.getActiveMessage() : null;
|
262 |
+
}
|
263 |
+
|
264 |
+
/**
|
265 |
+
* Gets the nth message in the active path.
|
266 |
+
* @param {number} n - The index of the message to get.
|
267 |
+
* @returns {Message | null} The nth message.
|
268 |
+
*/
|
269 |
+
getNthMessage(n) {
|
270 |
+
log(5, 'Chatlog: getNthMessage called for n', n);
|
271 |
+
const alternatives = this.getNthAlternatives(parseInt(n));
|
272 |
+
return alternatives ? alternatives.getActiveMessage() : null;
|
273 |
+
}
|
274 |
+
|
275 |
+
/**
|
276 |
+
* Gets the alternatives at the nth position in the active path.
|
277 |
+
* @param {number} n - The index of the alternatives to get.
|
278 |
+
* @returns {Alternatives | null} The nth alternatives.
|
279 |
+
*/
|
280 |
+
getNthAlternatives(n) {
|
281 |
+
log(5, 'Chatlog: getNthAlternatives called for n', n);
|
282 |
+
let pos = 0;
|
283 |
+
let current = this.rootAlternatives;
|
284 |
+
while (current) {
|
285 |
+
if (pos >= n) return current;
|
286 |
+
const activeMessage = current.getActiveMessage();
|
287 |
+
if (!activeMessage || !activeMessage.answerAlternatives) break;
|
288 |
+
current = activeMessage.answerAlternatives;
|
289 |
+
pos++;
|
290 |
+
}
|
291 |
+
return null;
|
292 |
+
}
|
293 |
+
|
294 |
+
/**
|
295 |
+
* Gets the last set of alternatives in the active path.
|
296 |
+
* @returns {Alternatives | null} The last alternatives.
|
297 |
+
*/
|
298 |
+
getLastAlternatives() {
|
299 |
+
log(5, 'Chatlog: getLastAlternatives called');
|
300 |
+
let current = this.rootAlternatives;
|
301 |
+
let last = current;
|
302 |
+
while (current) {
|
303 |
+
last = current;
|
304 |
+
const activeMessage = current.getActiveMessage();
|
305 |
+
if (!activeMessage || !activeMessage.answerAlternatives) break;
|
306 |
+
current = activeMessage.answerAlternatives;
|
307 |
+
}
|
308 |
+
return last;
|
309 |
+
}
|
310 |
+
|
311 |
+
/**
|
312 |
+
* Returns an array of active message values along the path.
|
313 |
+
* @returns {Array<Object>} The active message values.
|
314 |
+
*/
|
315 |
+
getActiveMessageValues() {
|
316 |
+
log(5, 'Chatlog: getActiveMessageValues called');
|
317 |
+
const result = [];
|
318 |
+
let message = this.getFirstMessage();
|
319 |
+
while (message && message.value) {
|
320 |
+
result.push(message.value);
|
321 |
+
message = message.getAnswerMessage();
|
322 |
+
}
|
323 |
+
return result;
|
324 |
+
}
|
325 |
+
|
326 |
+
/**
|
327 |
+
* Loads the chatlog from serialized alternatives data.
|
328 |
+
* @param {Object} alternativesData - The serialized alternatives data.
|
329 |
+
*/
|
330 |
+
load(alternativesData) {
|
331 |
+
log(5, 'Chatlog: load called');
|
332 |
+
let msgCount = 0;
|
333 |
+
const buildAlternatives = (data) => {
|
334 |
+
if (!data) return null;
|
335 |
+
const alt = new Alternatives();
|
336 |
+
alt.activeMessageIndex = data.activeMessageIndex;
|
337 |
+
data.messages.forEach(parsedMsg => {
|
338 |
+
if (!parsedMsg) return;
|
339 |
+
const msg = new Message(parsedMsg.value);
|
340 |
+
msg.metadata = parsedMsg.metadata;
|
341 |
+
msg.answerAlternatives = buildAlternatives(parsedMsg.answerAlternatives);
|
342 |
+
alt.messages.push(msg);
|
343 |
+
msgCount++;
|
344 |
+
});
|
345 |
+
return alt;
|
346 |
+
};
|
347 |
+
this.rootAlternatives = buildAlternatives(alternativesData);
|
348 |
+
this.clean();
|
349 |
+
log(3, 'Chatlog: Loaded with message count', msgCount);
|
350 |
+
this.notify();
|
351 |
+
}
|
352 |
+
|
353 |
+
/**
|
354 |
+
* Removes messages with null values (incomplete messages).
|
355 |
+
*/
|
356 |
+
clean() {
|
357 |
+
log(4, 'Chatlog: clean called');
|
358 |
+
if (!this.rootAlternatives) return;
|
359 |
+
const badMessages = [];
|
360 |
+
const stack = [this.rootAlternatives];
|
361 |
+
while (stack.length > 0) {
|
362 |
+
const alt = stack.pop();
|
363 |
+
alt.messages.forEach(msg => {
|
364 |
+
if (msg.value === null || (msg.value && msg.value.content === null)) {
|
365 |
+
badMessages.push(msg);
|
366 |
+
}
|
367 |
+
if (msg.answerAlternatives) stack.push(msg.answerAlternatives);
|
368 |
+
});
|
369 |
+
}
|
370 |
+
badMessages.forEach(msg => this.deleteMessage(msg));
|
371 |
+
this.notify();
|
372 |
+
}
|
373 |
+
|
374 |
+
/**
|
375 |
+
* Clears all caches in the chatlog.
|
376 |
+
*/
|
377 |
+
clearCache() {
|
378 |
+
log(4, 'Chatlog: clearCache called');
|
379 |
+
this.load(this.rootAlternatives);
|
380 |
+
}
|
381 |
+
|
382 |
+
/**
|
383 |
+
* Finds the Alternatives object that contains the given message.
|
384 |
+
* @param {Message} messageToFind - The message to find.
|
385 |
+
* @returns {Alternatives | null} The Alternatives object or null if not found.
|
386 |
+
*/
|
387 |
+
findAlternativesForMessage(messageToFind) {
|
388 |
+
if (!this.rootAlternatives) return null;
|
389 |
+
const stack = [this.rootAlternatives];
|
390 |
+
while (stack.length > 0) {
|
391 |
+
const alts = stack.pop();
|
392 |
+
if (alts.messages.includes(messageToFind)) {
|
393 |
+
return alts;
|
394 |
+
}
|
395 |
+
alts.messages.forEach(msg => {
|
396 |
+
if (msg.answerAlternatives) {
|
397 |
+
stack.push(msg.answerAlternatives);
|
398 |
+
}
|
399 |
+
});
|
400 |
+
}
|
401 |
+
return null;
|
402 |
+
}
|
403 |
+
|
404 |
+
/**
|
405 |
+
* Finds the parent message of a given Alternatives object.
|
406 |
+
* @param {Alternatives} alternativesToFind - The alternatives object to find the parent of.
|
407 |
+
* @returns {Message | null} The parent message or null if it's the root.
|
408 |
+
*/
|
409 |
+
findParentOfAlternatives(alternativesToFind) {
|
410 |
+
if (!this.rootAlternatives || this.rootAlternatives === alternativesToFind) {
|
411 |
+
return null;
|
412 |
+
}
|
413 |
+
const stack = [this.rootAlternatives];
|
414 |
+
while (stack.length > 0) {
|
415 |
+
const alts = stack.pop();
|
416 |
+
for (const msg of alts.messages) {
|
417 |
+
if (msg.answerAlternatives === alternativesToFind) {
|
418 |
+
return msg;
|
419 |
+
}
|
420 |
+
if (msg.answerAlternatives) {
|
421 |
+
stack.push(msg.answerAlternatives);
|
422 |
+
}
|
423 |
+
}
|
424 |
+
}
|
425 |
+
return null;
|
426 |
+
}
|
427 |
+
|
428 |
+
/**
|
429 |
+
* Deletes a specific message from the chatlog and all subsequent messages.
|
430 |
+
* @param {Message} message - The message object to delete.
|
431 |
+
*/
|
432 |
+
deleteMessage(message) {
|
433 |
+
log(4, 'Chatlog: deleteMessage called for', message);
|
434 |
+
const alternatives = this.findAlternativesForMessage(message);
|
435 |
+
if (!alternatives) return;
|
436 |
+
|
437 |
+
const index = alternatives.messages.indexOf(message);
|
438 |
+
if (index === -1) return;
|
439 |
+
|
440 |
+
alternatives.messages.splice(index, 1);
|
441 |
+
|
442 |
+
if (alternatives.messages.length === 0) {
|
443 |
+
const parent = this.findParentOfAlternatives(alternatives);
|
444 |
+
if (parent) {
|
445 |
+
parent.answerAlternatives = null;
|
446 |
+
} else if (alternatives === this.rootAlternatives) {
|
447 |
+
this.rootAlternatives = null;
|
448 |
+
}
|
449 |
+
} else {
|
450 |
+
if (alternatives.activeMessageIndex === index) {
|
451 |
+
alternatives.activeMessageIndex = Math.max(0, alternatives.messages.length - 1);
|
452 |
+
} else if (alternatives.activeMessageIndex > index) {
|
453 |
+
alternatives.activeMessageIndex--;
|
454 |
+
}
|
455 |
+
}
|
456 |
+
|
457 |
+
alternatives.clearCache();
|
458 |
+
this.notify();
|
459 |
+
}
|
460 |
+
|
461 |
+
/**
|
462 |
+
* Deletes only the message at the nth position, preserving subsequent messages by relinking.
|
463 |
+
* @param {number} pos - The position of the message to delete.
|
464 |
+
*/
|
465 |
+
deleteNthMessage(pos) {
|
466 |
+
log(4, 'Chatlog: deleteNthMessage called for pos', pos);
|
467 |
+
const msgToDelete = this.getNthMessage(pos);
|
468 |
+
if (!msgToDelete) return;
|
469 |
+
|
470 |
+
const childAlternatives = msgToDelete.answerAlternatives;
|
471 |
+
|
472 |
+
if (pos === 0) {
|
473 |
+
// If we're deleting the root message, its children become the new root
|
474 |
+
this.rootAlternatives = childAlternatives;
|
475 |
+
} else {
|
476 |
+
// If we're deleting a message in the middle, link its parent to its children
|
477 |
+
const parentMsg = this.getNthMessage(pos - 1);
|
478 |
+
if (parentMsg) {
|
479 |
+
parentMsg.answerAlternatives = childAlternatives;
|
480 |
+
}
|
481 |
+
}
|
482 |
+
this.notify();
|
483 |
+
}
|
484 |
+
|
485 |
+
/**
|
486 |
+
* Cycles through the alternative messages for a given message.
|
487 |
+
* @param {Message} message - The message whose alternatives to cycle.
|
488 |
+
* @param {'next' | 'prev'} direction - The direction to cycle.
|
489 |
+
*/
|
490 |
+
cycleAlternatives(message, direction) {
|
491 |
+
log(4, `Chatlog: cycleAlternatives called for`, message, `direction: ${direction}`);
|
492 |
+
const alternatives = this.findAlternativesForMessage(message);
|
493 |
+
if (!alternatives) return;
|
494 |
+
|
495 |
+
if (direction === 'next') {
|
496 |
+
alternatives.next();
|
497 |
+
} else if (direction === 'prev') {
|
498 |
+
alternatives.prev();
|
499 |
+
}
|
500 |
+
|
501 |
+
this.notify(false);
|
502 |
+
}
|
503 |
+
|
504 |
+
/**
|
505 |
+
* Adds a new alternative message at the same level as the given message.
|
506 |
+
* @param {Message} message - The message to add an alternative to.
|
507 |
+
* @param {Object} newValue - The value for the new message.
|
508 |
+
* @returns {Message} The newly created message.
|
509 |
+
*/
|
510 |
+
addAlternative(message, newValue) {
|
511 |
+
log(4, `Chatlog: addAlternative called for`, message);
|
512 |
+
const alternatives = this.findAlternativesForMessage(message);
|
513 |
+
if (!alternatives) return null;
|
514 |
+
|
515 |
+
const newMessage = alternatives.addMessage(newValue);
|
516 |
+
this.notify();
|
517 |
+
return newMessage;
|
518 |
+
}
|
519 |
+
|
520 |
+
/**
|
521 |
+
* Serializes the chatlog to a JSON-compatible object.
|
522 |
+
* @returns {Object} The serialized chatlog.
|
523 |
+
*/
|
524 |
+
toJSON() {
|
525 |
+
log(5, 'Chatlog: toJSON called');
|
526 |
+
return this.rootAlternatives ? this.rootAlternatives.toJSON() : null;
|
527 |
+
}
|
528 |
+
}
|
529 |
+
|
530 |
+
export { Chatlog, Message, Alternatives };
|
js/components/settings-panel.js
ADDED
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview The SettingsPanel component provides the UI for application settings.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { log, triggerError } from '../utils/logger.js';
|
8 |
+
import { hooks } from '../hooks.js';
|
9 |
+
|
10 |
+
/**
|
11 |
+
* @class SettingsPanel
|
12 |
+
* Manages the settings panel UI.
|
13 |
+
*/
|
14 |
+
class SettingsPanel {
|
15 |
+
/**
|
16 |
+
* @param {Object} options - The options for the settings panel.
|
17 |
+
* @param {Function} options.configService - The configuration service.
|
18 |
+
* @param {Function} options.app - The main app object.
|
19 |
+
*/
|
20 |
+
constructor({ configService, app }) {
|
21 |
+
this.configService = configService;
|
22 |
+
this.app = app;
|
23 |
+
|
24 |
+
this.ui = {
|
25 |
+
settingsEl: document.getElementById('settings'),
|
26 |
+
settingsButton: document.getElementById('settingsButton'),
|
27 |
+
endpointEl: document.getElementById('endpoint'),
|
28 |
+
apiKeyEl: document.getElementById('apiKey'),
|
29 |
+
loginBtn: document.getElementById('login-btn'),
|
30 |
+
logoutBtn: document.getElementById('logout-btn'),
|
31 |
+
};
|
32 |
+
|
33 |
+
this.init();
|
34 |
+
}
|
35 |
+
|
36 |
+
/**
|
37 |
+
* Initializes the settings panel by setting up event listeners.
|
38 |
+
*/
|
39 |
+
init() {
|
40 |
+
log(3, 'SettingsPanel: init called');
|
41 |
+
this.ui.settingsButton.addEventListener('click', () => this.toggle());
|
42 |
+
|
43 |
+
// Other Buttons
|
44 |
+
this.ui.loginBtn.addEventListener('click', () => this.handleLogin());
|
45 |
+
this.ui.logoutBtn.addEventListener('click', () => this.handleLogout());
|
46 |
+
}
|
47 |
+
|
48 |
+
/**
|
49 |
+
* Toggles the visibility of the settings panel.
|
50 |
+
*/
|
51 |
+
toggle(state = 'toggle') {
|
52 |
+
let isOpen;
|
53 |
+
switch (state) {
|
54 |
+
case 'open':
|
55 |
+
this.ui.settingsEl.classList.add('open');
|
56 |
+
isOpen = true;
|
57 |
+
break;
|
58 |
+
case 'close':
|
59 |
+
this.ui.settingsEl.classList.remove('open');
|
60 |
+
break;
|
61 |
+
case 'toggle':
|
62 |
+
default:
|
63 |
+
isOpen = this.ui.settingsEl.classList.toggle('open');
|
64 |
+
break;
|
65 |
+
}
|
66 |
+
if (isOpen) {
|
67 |
+
const globalSettings = this.configService.getModelSettings();
|
68 |
+
// Pass null for chatId and agentId to indicate global scope
|
69 |
+
const container = document.getElementById('global-model-settings-container');
|
70 |
+
hooks.onModelSettingsRender.forEach(fn => fn(container, globalSettings, null, null));
|
71 |
+
}
|
72 |
+
}
|
73 |
+
|
74 |
+
/**
|
75 |
+
* Handles the login button click.
|
76 |
+
*/
|
77 |
+
handleLogin() {
|
78 |
+
log(4, 'SettingsPanel: handleLogin called');
|
79 |
+
const apiKey = this.ui.apiKeyEl.value.trim();
|
80 |
+
const endpoint = this.ui.endpointEl.value.trim();
|
81 |
+
if (!endpoint) {
|
82 |
+
triggerError('Please enter an API Endpoint.');
|
83 |
+
return;
|
84 |
+
}
|
85 |
+
if (apiKey) {
|
86 |
+
this.configService.setItem('apiKey', apiKey);
|
87 |
+
} else {
|
88 |
+
this.configService.removeItem('apiKey');
|
89 |
+
}
|
90 |
+
if (endpoint) {
|
91 |
+
this.configService.setItem('endpoint', endpoint);
|
92 |
+
} else {
|
93 |
+
this.configService.removeItem('endpoint');
|
94 |
+
}
|
95 |
+
this.app.handleLogin();
|
96 |
+
}
|
97 |
+
|
98 |
+
/**
|
99 |
+
* Handles the logout button click.
|
100 |
+
*/
|
101 |
+
handleLogout() {
|
102 |
+
this.ui.apiKeyEl.value = '';
|
103 |
+
this.ui.endpointEl.value = '';
|
104 |
+
this.configService.removeItem('apiKey');
|
105 |
+
this.configService.removeItem('endpoint');
|
106 |
+
this.app.handleLogin();
|
107 |
+
hooks.onLogout.forEach(fn => fn(this.ui.settingsEl));
|
108 |
+
}
|
109 |
+
|
110 |
+
/**
|
111 |
+
* Sets the API key in the input field.
|
112 |
+
* @param {string} apiKey - The API key to set.
|
113 |
+
*/
|
114 |
+
setApiKey(apiKey) {
|
115 |
+
this.ui.apiKeyEl.value = apiKey;
|
116 |
+
}
|
117 |
+
|
118 |
+
/**
|
119 |
+
* Sets the endpoint in the input field.
|
120 |
+
* @param {string} endpoint - The endpoint to set.
|
121 |
+
*/
|
122 |
+
setEndpoint(endpoint) {
|
123 |
+
this.ui.endpointEl.value = endpoint;
|
124 |
+
}
|
125 |
+
|
126 |
+
}
|
127 |
+
|
128 |
+
export default SettingsPanel;
|
js/config.js
ADDED
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview Configuration constants for the application.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
/**
|
8 |
+
* The initial system prompt defining AI behavior and capabilities.
|
9 |
+
* @type {string}
|
10 |
+
*/
|
11 |
+
export const firstPrompt = `You have the ability to present perspectives and provide real-time date and time information.
|
12 |
+
You can create and understand visuals, such as images, graphs, and charts, using SVG technology.
|
13 |
+
Unless otherwise specified by the user, always use SVG for drawings.
|
14 |
+
Express mathematical equations using latex notation, symbolized by $ and $$.
|
15 |
+
Use Markdown wherever possible.
|
16 |
+
|
17 |
+
Strictly adhere to user instructions, and in the event of conflicting directives, seek clarification or prioritize based on the user's needs.
|
18 |
+
Be aware of the user's level of knowledge in the fields of programming and science to tailor your responses accordingly.
|
19 |
+
If you don't know the user's level of knowledge, assume a very high level of knowledge.
|
20 |
+
|
21 |
+
Before responding, thoroughly analyze the user's problem, considering the most efficient strategy to tackle and solve it step by step.
|
22 |
+
If you encounter an issue you can't solve or an error in your processes, communicate this clearly to the user and seek further guidance.
|
23 |
+
|
24 |
+
Always apply thoughtful consideration in all tasks.
|
25 |
+
Your responses are backed by your extensive knowledge in programming and science.
|
26 |
+
Be clear in articulating any ambiguities to ensure effective communication.`;
|
27 |
+
|
28 |
+
/**
|
29 |
+
* The initial message displayed in new chats.
|
30 |
+
* @type {string}
|
31 |
+
*/
|
32 |
+
export const startMessage = '';
|
33 |
+
|
34 |
+
/**
|
35 |
+
* SVG icon for the submit button.
|
36 |
+
* @type {string}
|
37 |
+
*/
|
38 |
+
export const messageSubmit = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 6.741c0-1.544 1.674-2.505 3.008-1.728l9.015 5.26c1.323.771 1.323 2.683 0 3.455l-9.015 5.258C7.674 19.764 6 18.803 6 17.26V6.741zM17.015 12L8 6.741V17.26L17.015 12z" fill="currentColor"/></svg>';
|
39 |
+
|
40 |
+
/**
|
41 |
+
* SVG icon for the stop button.
|
42 |
+
* @type {string}
|
43 |
+
*/
|
44 |
+
export const messageStop = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V7zm12 0H7v10h10V7z" fill="currentColor"/></svg>';
|
45 |
+
|
46 |
+
/**
|
47 |
+
* The default API endpoint.
|
48 |
+
* @type {string}
|
49 |
+
*/
|
50 |
+
export const defaultEndpoint = 'http://localhost:1234/v1/chat/completions';
|
51 |
+
|
52 |
+
/**
|
53 |
+
* The debug level for logging.
|
54 |
+
* 0: none, 1: errors, 2: warnings, 3: info, 4: debug, 5: trace, 6: full trace (includes partial AI messages during streaming)
|
55 |
+
* @type {number}
|
56 |
+
*/
|
57 |
+
export const DEBUG_LEVEL = 3;
|
js/hooks.js
ADDED
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
'use strict';
|
2 |
+
|
3 |
+
export const hooks = {
|
4 |
+
beforeUserMessageAdd: [], // Called before adding a user/system message to the chatlog. Allows modifying content or cancelling addition. Args: (content, role) => modifiedContent or false to cancel.
|
5 |
+
afterMessageAdd: [], // Called after a message is added to the chatlog. Args: (messageObj).
|
6 |
+
beforeApiCall: [], // Called before making the API call, allows modifying the payload. Args: (payload, chatbox) => modifiedPayload.
|
7 |
+
onChunkReceived: [], // Called when a streaming chunk is received from the API. Args: (deltaContent).
|
8 |
+
onMessageComplete: [], // Called when an assistant message is fully streamed/completed. Args: (messageObj, chatbox).
|
9 |
+
onError: [], // Called on API or processing errors. Args: (error).
|
10 |
+
onFormatContent: [], // Called during content formatting (e.g., for Markdown, etc.). Args: (text, pos) => html.
|
11 |
+
onPostFormatContent: [], // Called after content is formatted into a wrapper element. Args: (wrapperEl, messageObj, pos).
|
12 |
+
onRenderMessage: [], // Called when rendering a message element, allows modifying the DOM element. Args: (el, message).
|
13 |
+
onRenderMessageControls: [], // Called to add controls (buttons) to a message element. Args: (containerEl, message, chatlog, chatbox).
|
14 |
+
onSettingsRender: [], // Called when the settings panel is opened, allows adding elements to it. Args: (settingsEl).
|
15 |
+
onModifySystemPrompt: [], // Called to modify the system prompt before API call. Args: (systemContent) => modifiedSystemContent.
|
16 |
+
onStateChange: [], // Called on central state changes (e.g., receiving, apiKey). Args: (key, value).
|
17 |
+
onChatUpdated: [], // Called when chatlog updates. Args: (chatlog).
|
18 |
+
onGenerateAIResponse: [], // Called to request an AI response. Args: (options, chatlog).
|
19 |
+
onCancel: [], // Called when the user cancels an AI response.
|
20 |
+
onLogout: [], // Called when the user clicks the logout button. Args: (settingsEl).
|
21 |
+
/**
|
22 |
+
* Renders model settings UI elements for the chat with ID chatId (or main settings if null), and optionally for agentId.
|
23 |
+
* @param {HTMLElement} modelSettingsEl - The container element for settings UI.
|
24 |
+
* @param {Object} modelSettings - The settings data object (global, per-chat, or per-agent).
|
25 |
+
* @param {string|null} chatId - The chat ID or null for global.
|
26 |
+
* @param {string|null} agentId - The agent ID or null if not agent-specific.
|
27 |
+
*/
|
28 |
+
onModelSettingsRender: [],
|
29 |
+
/**
|
30 |
+
* Modifies the API payload with model settings.
|
31 |
+
* @param {Object} payload - The API payload to modify.
|
32 |
+
* @param {Object} modelSettings - The merged settings (global + per-chat + per-agent).
|
33 |
+
*/
|
34 |
+
onModelSettings: [],
|
35 |
+
/**
|
36 |
+
* Exports model settings to storage/JSON.
|
37 |
+
* @param {Object} modelSettingsExport - The export data structure.
|
38 |
+
* @param {Object} modelSettings - The settings to export.
|
39 |
+
*/
|
40 |
+
onModelSettingsExport: [],
|
41 |
+
/**
|
42 |
+
* Imports model settings from storage/JSON.
|
43 |
+
* @param {Object} modelSettingsImport - The imported data.
|
44 |
+
* @param {Object} modelSettings - The settings object to populate.
|
45 |
+
*/
|
46 |
+
onModelSettingsImport: [],
|
47 |
+
/**
|
48 |
+
* Aggregates settings from various plugins.
|
49 |
+
* @param {Object} settings - The settings object to be populated.
|
50 |
+
*/
|
51 |
+
onGetModelSettings: [],
|
52 |
+
/**
|
53 |
+
* Delegates the update of a specific setting to the relevant plugin.
|
54 |
+
* @param {string} key - The setting key.
|
55 |
+
* @param {*} value - The setting value.
|
56 |
+
*/
|
57 |
+
onUpdateModelSettings: [],
|
58 |
+
/**
|
59 |
+
* Handles changes to model settings, persisting them.
|
60 |
+
* @param {HTMLElement} modelSettingsEl - The settings UI element.
|
61 |
+
* @param {Object} modelSettings - The updated settings.
|
62 |
+
* @param {string|null} chatId - The chat ID or null.
|
63 |
+
* @param {string|null} agentId - The agent ID or null.
|
64 |
+
*/
|
65 |
+
onModelSettingsChanged: [],
|
66 |
+
};
|
67 |
+
|
68 |
+
window.hooks = hooks; // TODO: only for testing: remove
|
69 |
+
|
70 |
+
export function registerPlugin(plugin, app) {
|
71 |
+
if (plugin.init) {
|
72 |
+
plugin.init(app);
|
73 |
+
}
|
74 |
+
Object.entries(plugin.hooks || {}).forEach(([hookName, fn]) => {
|
75 |
+
if (hooks[hookName]) {
|
76 |
+
hooks[hookName].push(fn);
|
77 |
+
} else {
|
78 |
+
console.warn(`Unknown hook ${hookName} in plugin`);
|
79 |
+
}
|
80 |
+
});
|
81 |
+
}
|
js/main.js
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview Main entry point for the application.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import App from './app.js';
|
8 |
+
|
9 |
+
document.addEventListener('DOMContentLoaded', async () => {
|
10 |
+
const app = new App();
|
11 |
+
await app.init();
|
12 |
+
});
|
js/plugins/agents.js
ADDED
@@ -0,0 +1,1110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview Plugin for agents and flow management.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { log, triggerError } from '../utils/logger.js';
|
8 |
+
import { hooks } from '../hooks.js';
|
9 |
+
import { parseFunctionCalls } from '../utils/parsers.js';
|
10 |
+
import { addAlternativeToChat } from '../utils/chat.js';
|
11 |
+
import { createControlButton } from '../utils/ui.js';
|
12 |
+
import { processToolCalls, exportJson, importJson } from '../utils/shared.js';
|
13 |
+
import { defaultEndpoint } from '../config.js';
|
14 |
+
|
15 |
+
const INTERACTIVE_TAGS = ['INPUT', 'TEXTAREA', 'SELECT', 'OPTION', 'BUTTON', 'LABEL'];
|
16 |
+
|
17 |
+
// --- UI Rendering Functions ---
|
18 |
+
function renderAgentList(store) {
|
19 |
+
const agentList = document.getElementById('agent-list');
|
20 |
+
agentList.innerHTML = '';
|
21 |
+
const chat = store.get('currentChat');
|
22 |
+
if (!chat || !chat.agents) return;
|
23 |
+
chat.agents.forEach(agent => {
|
24 |
+
const card = document.createElement('div');
|
25 |
+
const isActive = agent.id === chat.activeAgentId;
|
26 |
+
card.className = `agent-card ${isActive ? 'active' : ''}`;
|
27 |
+
card.innerHTML = `
|
28 |
+
<h3>${agent.name}</h3><p>${agent.description}</p>
|
29 |
+
<div class="agent-card-buttons">
|
30 |
+
<button class="agents-flow-btn activate-agent-btn" data-id="${agent.id}">${isActive ? 'Deactivate' : 'Activate'}</button>
|
31 |
+
<button class="agents-flow-btn edit-agent-btn" data-id="${agent.id}">Edit</button>
|
32 |
+
<button class="agents-flow-btn delete-agent-btn" data-id="${agent.id}">Delete</button>
|
33 |
+
</div>`;
|
34 |
+
agentList.appendChild(card);
|
35 |
+
});
|
36 |
+
}
|
37 |
+
|
38 |
+
function showAgentForm(agent, store) {
|
39 |
+
const formContainer = document.getElementById('agent-form-container');
|
40 |
+
const form = document.getElementById('agent-form');
|
41 |
+
form.reset();
|
42 |
+
document.getElementById('agent-id').value = agent ? agent.id : '';
|
43 |
+
|
44 |
+
const modelSettingsEl = document.getElementById('agent-model-settings');
|
45 |
+
const useCustomSettingsCheckbox = document.getElementById('agent-use-custom-settings');
|
46 |
+
|
47 |
+
modelSettingsEl.innerHTML = ''; // Clear previous settings
|
48 |
+
|
49 |
+
if (agent) {
|
50 |
+
document.getElementById('agent-name').value = agent.name;
|
51 |
+
document.getElementById('agent-description').value = agent.description;
|
52 |
+
document.getElementById('agent-system-prompt').value = agent.systemPrompt;
|
53 |
+
document.getElementById('agent-available-as-tool').checked = agent.availableAsTool;
|
54 |
+
useCustomSettingsCheckbox.checked = agent.useCustomModelSettings || false;
|
55 |
+
|
56 |
+
modelSettingsEl.style.display = useCustomSettingsCheckbox.checked ? 'block' : 'none';
|
57 |
+
|
58 |
+
const chat = store.get('currentChat');
|
59 |
+
if (useCustomSettingsCheckbox.checked) {
|
60 |
+
if (!agent.modelSettings) agent.modelSettings = {};
|
61 |
+
hooks.onModelSettingsRender.forEach(fn => fn(modelSettingsEl, agent.modelSettings, chat.id, agent.id));
|
62 |
+
}
|
63 |
+
} else {
|
64 |
+
useCustomSettingsCheckbox.checked = false;
|
65 |
+
modelSettingsEl.style.display = 'none';
|
66 |
+
}
|
67 |
+
|
68 |
+
// Use a fresh listener to avoid duplicates
|
69 |
+
const newCheckbox = useCustomSettingsCheckbox.cloneNode(true);
|
70 |
+
useCustomSettingsCheckbox.parentNode.replaceChild(newCheckbox, useCustomSettingsCheckbox);
|
71 |
+
newCheckbox.addEventListener('change', (e) => {
|
72 |
+
modelSettingsEl.style.display = e.target.checked ? 'block' : 'none';
|
73 |
+
if (e.target.checked && agent) {
|
74 |
+
const chat = store.get('currentChat');
|
75 |
+
if (!agent.modelSettings) agent.modelSettings = {};
|
76 |
+
hooks.onModelSettingsRender.forEach(fn => fn(modelSettingsEl, agent.modelSettings, chat.id, agent.id));
|
77 |
+
}
|
78 |
+
});
|
79 |
+
formContainer.style.display = 'block';
|
80 |
+
}
|
81 |
+
|
82 |
+
function hideAgentForm() {
|
83 |
+
document.getElementById('agent-form-container').style.display = 'none';
|
84 |
+
}
|
85 |
+
|
86 |
+
function renderFlow(store) {
|
87 |
+
const chat = store.get('currentChat');
|
88 |
+
const nodeContainer = document.getElementById('flow-node-container');
|
89 |
+
const svgLayer = document.getElementById('flow-svg-layer');
|
90 |
+
nodeContainer.innerHTML = '';
|
91 |
+
svgLayer.innerHTML = '';
|
92 |
+
if (!chat || !chat.flow) return;
|
93 |
+
|
94 |
+
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
95 |
+
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
|
96 |
+
marker.setAttribute('id', 'arrowhead');
|
97 |
+
marker.setAttribute('viewBox', '0 0 10 10');
|
98 |
+
marker.setAttribute('refX', '15');
|
99 |
+
marker.setAttribute('refY', '5');
|
100 |
+
marker.setAttribute('markerWidth', '6');
|
101 |
+
marker.setAttribute('markerHeight', '6');
|
102 |
+
marker.setAttribute('orient', 'auto-start-reverse');
|
103 |
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
104 |
+
path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
|
105 |
+
path.setAttribute('fill', 'var(--text-color)');
|
106 |
+
marker.appendChild(path);
|
107 |
+
defs.appendChild(marker);
|
108 |
+
svgLayer.appendChild(defs);
|
109 |
+
|
110 |
+
(chat.flow.steps || []).forEach(step => {
|
111 |
+
const node = document.createElement('div');
|
112 |
+
node.className = 'flow-step-card';
|
113 |
+
if (step.type) {
|
114 |
+
node.classList.add(`flow-step-${step.type}`);
|
115 |
+
}
|
116 |
+
if (step.isMinimized) {
|
117 |
+
node.classList.add('minimized');
|
118 |
+
}
|
119 |
+
node.dataset.id = step.id;
|
120 |
+
node.style.left = `${step.x}px`;
|
121 |
+
node.style.top = `${step.y}px`;
|
122 |
+
let content = '';
|
123 |
+
const type = step.type || 'agent'; // Default to agent for old steps
|
124 |
+
const agentOptions = (chat.agents || []).map(a => `<option value="${a.id}" ${step.agentId === a.id ? 'selected' : ''}>${a.name}</option>`).join('');
|
125 |
+
|
126 |
+
switch (type) {
|
127 |
+
case 'simple-prompt':
|
128 |
+
content = `
|
129 |
+
<h4>Simple Prompt</h4>
|
130 |
+
<div class="flow-step-content">
|
131 |
+
<label>Agent:</label>
|
132 |
+
<select class="flow-step-agent flow-step-input" data-id="${step.id}"><option value="">Select Agent</option>${agentOptions}</select>
|
133 |
+
<label>Prompt:</label>
|
134 |
+
<textarea class="flow-step-prompt flow-step-input" rows="3" data-id="${step.id}">${step.prompt || ''}</textarea>
|
135 |
+
</div>
|
136 |
+
`;
|
137 |
+
break;
|
138 |
+
case 'prompt-and-clear':
|
139 |
+
content = `
|
140 |
+
<h4>Prompt and Clear</h4>
|
141 |
+
<div class="flow-step-content">
|
142 |
+
<label>Agent:</label>
|
143 |
+
<select class="flow-step-agent flow-step-input" data-id="${step.id}"><option value="">Select Agent</option>${agentOptions}</select>
|
144 |
+
<label>Prompt:</label>
|
145 |
+
<textarea class="flow-step-prompt flow-step-input" rows="3" data-id="${step.id}">${step.prompt || ''}</textarea>
|
146 |
+
</div>
|
147 |
+
`;
|
148 |
+
break;
|
149 |
+
case 'conditional-stop':
|
150 |
+
content = `
|
151 |
+
<h4>Conditional Stop</h4>
|
152 |
+
<div class="flow-step-content">
|
153 |
+
<label>Last Response Condition:</label>
|
154 |
+
<select class="flow-step-condition-type flow-step-input" data-id="${step.id}">
|
155 |
+
<option value="contains" ${step.conditionType === 'contains' ? 'selected' : ''}>Contains String</option>
|
156 |
+
<option value="matches" ${step.conditionType === 'matches' ? 'selected' : ''}>Matches String</option>
|
157 |
+
<option value="regex" ${step.conditionType === 'regex' ? 'selected' : ''}>Matches Regex</option>
|
158 |
+
</select>
|
159 |
+
<textarea class="flow-step-condition flow-step-input" rows="2" data-id="${step.id}" placeholder="Enter value...">${step.condition || ''}</textarea>
|
160 |
+
<label>On Match:</label>
|
161 |
+
<select class="flow-step-on-match flow-step-input" data-id="${step.id}">
|
162 |
+
<option value="stop" ${step.onMatch === 'stop' ? 'selected' : ''}>Stop flow</option>
|
163 |
+
<option value="continue" ${step.onMatch === 'continue' ? 'selected' : ''}>Must match to continue</option>
|
164 |
+
</select>
|
165 |
+
</div>
|
166 |
+
`;
|
167 |
+
break;
|
168 |
+
case 'branching-prompt':
|
169 |
+
content = `
|
170 |
+
<h4>Branching Prompt</h4>
|
171 |
+
<div class="flow-step-content">
|
172 |
+
<label>Last Response Condition:</label>
|
173 |
+
<select class="flow-step-condition-type flow-step-input" data-id="${step.id}">
|
174 |
+
<option value="contains" ${step.conditionType === 'contains' ? 'selected' : ''}>Contains String</option>
|
175 |
+
<option value="matches" ${step.conditionType === 'matches' ? 'selected' : ''}>Matches String</option>
|
176 |
+
<option value="regex" ${step.conditionType === 'regex' ? 'selected' : ''}>Matches Regex</option>
|
177 |
+
</select>
|
178 |
+
<textarea class="flow-step-condition flow-step-input" rows="2" data-id="${step.id}" placeholder="Enter value...">${step.condition || ''}</textarea>
|
179 |
+
</div>
|
180 |
+
`;
|
181 |
+
break;
|
182 |
+
case 'multi-prompt':
|
183 |
+
content = `
|
184 |
+
<h4>Multi Prompt</h4>
|
185 |
+
<div class="flow-step-content">
|
186 |
+
<label>Agent:</label>
|
187 |
+
<select class="flow-step-agent flow-step-input" data-id="${step.id}"><option value="">Select Agent</option>${agentOptions}</select>
|
188 |
+
<label>Prompt:</label>
|
189 |
+
<textarea class="flow-step-prompt flow-step-input" rows="3" data-id="${step.id}">${step.prompt || ''}</textarea>
|
190 |
+
<label>Number of alternatives:</label>
|
191 |
+
<input type="number" class="flow-step-count flow-step-input" data-id="${step.id}" value="${step.count || 1}" min="1" max="10">
|
192 |
+
</div>
|
193 |
+
`;
|
194 |
+
break;
|
195 |
+
case 'consolidator':
|
196 |
+
content = `
|
197 |
+
<h4>Alternatives Consolidator</h4>
|
198 |
+
<div class="flow-step-content">
|
199 |
+
<label>Agent:</label>
|
200 |
+
<select class="flow-step-agent flow-step-input" data-id="${step.id}"><option value="">Select Agent</option>${agentOptions}</select>
|
201 |
+
<label>Text before alternatives:</label>
|
202 |
+
<textarea class="flow-step-pre-prompt flow-step-input" rows="2" data-id="${step.id}">${step.prePrompt || ''}</textarea>
|
203 |
+
<label>Text after alternatives:</label>
|
204 |
+
<textarea class="flow-step-post-prompt flow-step-input" rows="2" data-id="${step.id}">${step.postPrompt || ''}</textarea>
|
205 |
+
</div>
|
206 |
+
`;
|
207 |
+
break;
|
208 |
+
}
|
209 |
+
|
210 |
+
let outputConnectors = '';
|
211 |
+
if (type === 'branching-prompt') {
|
212 |
+
outputConnectors = `
|
213 |
+
<div class="connector-group">
|
214 |
+
<div class="connector bottom" data-id="${step.id}" data-type="out" data-output-name="pass"><span class="connector-label">Pass</span></div>
|
215 |
+
<div class="connector bottom" data-id="${step.id}" data-type="out" data-output-name="fail"><span class="connector-label">Fail</span></div>
|
216 |
+
</div>
|
217 |
+
`;
|
218 |
+
} else {
|
219 |
+
outputConnectors = `<div class="connector bottom" data-id="${step.id}" data-type="out" data-output-name="default"></div>`;
|
220 |
+
}
|
221 |
+
|
222 |
+
|
223 |
+
node.innerHTML = `
|
224 |
+
<button class="minimize-flow-step-btn" data-id="${step.id}">${step.isMinimized ? '+' : '-'}</button>
|
225 |
+
<div class="connector top" data-id="${step.id}" data-type="in"></div>
|
226 |
+
${content}
|
227 |
+
<div class="flow-step-content">
|
228 |
+
<button class="delete-flow-step-btn agents-flow-btn" data-id="${step.id}">Delete Step</button>
|
229 |
+
</div>
|
230 |
+
${outputConnectors}
|
231 |
+
`;
|
232 |
+
nodeContainer.appendChild(node);
|
233 |
+
});
|
234 |
+
|
235 |
+
(chat.flow.connections || []).forEach(conn => {
|
236 |
+
const fromNode = nodeContainer.querySelector(`.flow-step-card[data-id="${conn.from}"]`);
|
237 |
+
const toNode = nodeContainer.querySelector(`.flow-step-card[data-id="${conn.to}"]`);
|
238 |
+
if (fromNode && toNode) {
|
239 |
+
const outConnector = fromNode.querySelector(`.connector.bottom[data-output-name="${conn.outputName || 'default'}"]`);
|
240 |
+
const inConnector = toNode.querySelector('.connector.top');
|
241 |
+
if (!outConnector) {
|
242 |
+
console.error('Could not find output connector for connection:', conn);
|
243 |
+
return;
|
244 |
+
}
|
245 |
+
const x1 = fromNode.offsetLeft + outConnector.offsetLeft + outConnector.offsetWidth / 2;
|
246 |
+
const y1 = fromNode.offsetTop + outConnector.offsetTop + outConnector.offsetHeight / 2;
|
247 |
+
const x2 = toNode.offsetLeft + inConnector.offsetLeft + inConnector.offsetWidth / 2;
|
248 |
+
const y2 = toNode.offsetTop + inConnector.offsetTop + inConnector.offsetHeight / 2;
|
249 |
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
250 |
+
line.setAttribute('x1', x1);
|
251 |
+
line.setAttribute('y1', y1);
|
252 |
+
line.setAttribute('x2', x2);
|
253 |
+
line.setAttribute('y2', y2);
|
254 |
+
line.setAttribute('stroke', 'var(--text-color)');
|
255 |
+
line.setAttribute('stroke-width', '2');
|
256 |
+
line.setAttribute('marker-end', 'url(#arrowhead)');
|
257 |
+
svgLayer.appendChild(line);
|
258 |
+
|
259 |
+
// Create a proper HTML button for deleting connections
|
260 |
+
const deleteBtn = createControlButton(
|
261 |
+
'Delete Connection',
|
262 |
+
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 4a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v2h4a1 1 0 1 1 0 2h-1.069l-.867 12.142A2 2 0 0 1 17.069 22H6.93a2 2 0 0 1-1.995-1.858L4.07 8H3a1 1 0 0 1 0-2h4V4zm2 2h6V4H9v2zM6.074 8l.857 12H17.07l.857-12H6.074zM10 10a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-6a1 1 0 0 1 1-1zm4 0a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-6a1 1 0 0 1 1-1z" fill="currentColor"/></svg>');
|
263 |
+
deleteBtn.classList.add('delete-connection-btn');
|
264 |
+
deleteBtn.dataset.from = conn.from;
|
265 |
+
deleteBtn.dataset.to = conn.to;
|
266 |
+
deleteBtn.dataset.outputName = conn.outputName || 'default';
|
267 |
+
deleteBtn.style.position = 'absolute';
|
268 |
+
deleteBtn.style.left = `${(x1 + x2) / 2 - 12}px`;
|
269 |
+
deleteBtn.style.top = `${(y1 + y2) / 2 - 12}px`;
|
270 |
+
deleteBtn.style.zIndex = '10';
|
271 |
+
nodeContainer.appendChild(deleteBtn);
|
272 |
+
}
|
273 |
+
});
|
274 |
+
}
|
275 |
+
|
276 |
+
// --- Main Plugin Object ---
|
277 |
+
const agentsPlugin = {
|
278 |
+
name: 'agents',
|
279 |
+
app: null,
|
280 |
+
store: null,
|
281 |
+
flowRunning: false,
|
282 |
+
currentStepId: null,
|
283 |
+
stepCounter: 0,
|
284 |
+
maxSteps: 20,
|
285 |
+
dragInfo: { active: false, target: null, offsetX: 0, offsetY: 0 },
|
286 |
+
panInfo: { active: false, startX: 0, startY: 0, scrollLeft: 0, scrollTop: 0 },
|
287 |
+
connectionInfo: { active: false, fromNode: null, fromConnector: null, tempLine: null },
|
288 |
+
multiMessageInfo: { active: false, step: null, counter: 0, messageToBranchFrom: null },
|
289 |
+
|
290 |
+
init: function(app) {
|
291 |
+
this.app = app;
|
292 |
+
this.store = app.store;
|
293 |
+
|
294 |
+
// Dynamically add tabs
|
295 |
+
const tabs = document.getElementById('main-tabs');
|
296 |
+
const agentsTabButton = document.createElement('button');
|
297 |
+
agentsTabButton.classList.add('tab-button');
|
298 |
+
agentsTabButton.dataset.tab = 'agents';
|
299 |
+
agentsTabButton.textContent = 'Agents';
|
300 |
+
tabs.appendChild(agentsTabButton);
|
301 |
+
|
302 |
+
const flowTabButton = document.createElement('button');
|
303 |
+
flowTabButton.classList.add('tab-button');
|
304 |
+
flowTabButton.dataset.tab = 'flow';
|
305 |
+
flowTabButton.textContent = 'Flow';
|
306 |
+
tabs.appendChild(flowTabButton);
|
307 |
+
|
308 |
+
// Dynamically add tab panes
|
309 |
+
const tabContent = document.getElementById('tab-content');
|
310 |
+
const agentsTabPane = document.createElement('div');
|
311 |
+
agentsTabPane.classList.add('tab-pane');
|
312 |
+
agentsTabPane.id = 'agents-tab-pane';
|
313 |
+
agentsTabPane.innerHTML = `
|
314 |
+
<div class="agents-flow-toolbar">
|
315 |
+
<button id="add-agent-btn" class="agents-flow-btn">Add Agent</button>
|
316 |
+
<button id="export-agents-btn" class="agents-flow-btn">Export Agents</button>
|
317 |
+
<button id="import-agents-btn" class="agents-flow-btn">Import Agents</button>
|
318 |
+
</div>
|
319 |
+
<div id="agent-list"></div>
|
320 |
+
<div id="agent-form-container" style="display: none;">
|
321 |
+
<form id="agent-form">
|
322 |
+
<input type="hidden" id="agent-id" value="">
|
323 |
+
<label for="agent-name">Name:</label>
|
324 |
+
<input type="text" id="agent-name" required>
|
325 |
+
<label for="agent-description">Description:</label>
|
326 |
+
<textarea id="agent-description" rows="2"></textarea>
|
327 |
+
<label for="agent-system-prompt">System Prompt:</label>
|
328 |
+
<textarea id="agent-system-prompt" rows="5"></textarea>
|
329 |
+
<label>
|
330 |
+
<input type="checkbox" id="agent-available-as-tool">
|
331 |
+
Available as a tool
|
332 |
+
</label>
|
333 |
+
<label>
|
334 |
+
<input type="checkbox" id="agent-use-custom-settings">
|
335 |
+
Custom Model Parameters
|
336 |
+
</label>
|
337 |
+
<div id="agent-model-settings" style="display: none;"></div>
|
338 |
+
<div id="agent-form-buttons">
|
339 |
+
<button type="submit" class="agents-flow-btn">Save Agent</button>
|
340 |
+
<button type="button" id="cancel-agent-form" class="agents-flow-btn">Cancel</button>
|
341 |
+
</div>
|
342 |
+
</form>
|
343 |
+
</div>
|
344 |
+
`;
|
345 |
+
tabContent.appendChild(agentsTabPane);
|
346 |
+
|
347 |
+
const flowTabPane = document.createElement('div');
|
348 |
+
flowTabPane.classList.add('tab-pane');
|
349 |
+
flowTabPane.id = 'flow-tab-pane';
|
350 |
+
flowTabPane.innerHTML = `
|
351 |
+
<div class="agents-flow-toolbar">
|
352 |
+
<div class="dropdown">
|
353 |
+
<button id="add-flow-step-btn-dropdown" class="agents-flow-btn">Add Step ▾</button>
|
354 |
+
<div id="add-step-dropdown-content" class="dropdown-content">
|
355 |
+
<a href="#" data-step-type="simple-prompt">Simple Prompt</a>
|
356 |
+
<a href="#" data-step-type="multi-prompt">Multi Prompt</a>
|
357 |
+
<a href="#" data-step-type="consolidator">Alt. Consolidator</a>
|
358 |
+
<a href="#" data-step-type="prompt-and-clear">Prompt and Clear</a>
|
359 |
+
<a href="#" data-step-type="branching-prompt">Branching Prompt</a>
|
360 |
+
<a href="#" data-step-type="conditional-stop">Conditional Stop</a>
|
361 |
+
</div>
|
362 |
+
</div>
|
363 |
+
<button id="run-flow-btn" class="agents-flow-btn">Run Flow</button>
|
364 |
+
<button id="export-flow-btn" class="agents-flow-btn">Export Flow</button>
|
365 |
+
<button id="import-flow-btn" class="agents-flow-btn">Load Flow</button>
|
366 |
+
</div>
|
367 |
+
<div id="flow-canvas-wrapper">
|
368 |
+
<div id="flow-canvas">
|
369 |
+
<svg id="flow-svg-layer"></svg>
|
370 |
+
<div id="flow-node-container"></div>
|
371 |
+
</div>
|
372 |
+
</div>
|
373 |
+
`;
|
374 |
+
tabContent.appendChild(flowTabPane);
|
375 |
+
|
376 |
+
// --- Event Listeners ---
|
377 |
+
// Tab switching
|
378 |
+
document.getElementById('tabs').addEventListener('click', e => this.handleTabClick(e));
|
379 |
+
// Agent UI
|
380 |
+
document.getElementById('add-agent-btn').addEventListener('click', () => showAgentForm(null, this.store));
|
381 |
+
document.getElementById('cancel-agent-form').addEventListener('click', hideAgentForm);
|
382 |
+
document.getElementById('agent-form').addEventListener('submit', e => this.saveAgent(e));
|
383 |
+
document.getElementById('agent-list').addEventListener('click', e => this.handleAgentListClick(e));
|
384 |
+
document.getElementById('export-agents-btn').addEventListener('click', () => this.exportAgents());
|
385 |
+
document.getElementById('import-agents-btn').addEventListener('click', () => this.importAgents());
|
386 |
+
// Flow UI
|
387 |
+
document.getElementById('add-flow-step-btn-dropdown').addEventListener('click', (e) => {
|
388 |
+
e.stopPropagation();
|
389 |
+
document.getElementById('add-step-dropdown-content').classList.toggle('show');
|
390 |
+
});
|
391 |
+
document.getElementById('add-step-dropdown-content').addEventListener('click', (e) => {
|
392 |
+
if (e.target.tagName === 'A') {
|
393 |
+
const stepType = e.target.dataset.stepType;
|
394 |
+
this.addFlowStep(stepType);
|
395 |
+
document.getElementById('add-step-dropdown-content').classList.remove('show');
|
396 |
+
}
|
397 |
+
});
|
398 |
+
document.getElementById('run-flow-btn').addEventListener('click', () => this.toggleFlow());
|
399 |
+
document.getElementById('export-flow-btn').addEventListener('click', () => this.exportFlow());
|
400 |
+
document.getElementById('import-flow-btn').addEventListener('click', () => this.importFlow());
|
401 |
+
|
402 |
+
window.addEventListener('click', (e) => {
|
403 |
+
if (!e.target.matches('#add-flow-step-btn-dropdown')) {
|
404 |
+
const dropdown = document.getElementById('add-step-dropdown-content');
|
405 |
+
if (dropdown.classList.contains('show')) {
|
406 |
+
dropdown.classList.remove('show');
|
407 |
+
}
|
408 |
+
}
|
409 |
+
});
|
410 |
+
const canvas = document.getElementById('flow-canvas');
|
411 |
+
canvas.addEventListener('mousedown', e => this.handleFlowCanvasMouseDown(e));
|
412 |
+
canvas.addEventListener('mousemove', e => this.handleFlowCanvasMouseMove(e));
|
413 |
+
canvas.addEventListener('mouseup', e => this.handleFlowCanvasMouseUp(e));
|
414 |
+
canvas.addEventListener('change', e => this.handleFlowStepChange(e));
|
415 |
+
canvas.addEventListener('click', e => this.handleFlowCanvasClick(e));
|
416 |
+
|
417 |
+
// --- Store Subscription ---
|
418 |
+
this.store.subscribe('currentChat', () => {
|
419 |
+
renderAgentList(this.store);
|
420 |
+
setTimeout(() => renderFlow(this.store), 0);
|
421 |
+
});
|
422 |
+
|
423 |
+
// --- Hooks ---
|
424 |
+
hooks.onCancel.push(() => {
|
425 |
+
if (this.flowRunning) this.stopFlow('Execution cancelled by user.');
|
426 |
+
});
|
427 |
+
},
|
428 |
+
|
429 |
+
// --- Event Handlers ---
|
430 |
+
handleTabClick(e) {
|
431 |
+
if (e.target.classList.contains('tab-button')) {
|
432 |
+
const tabName = e.target.dataset.tab;
|
433 |
+
if (tabName === 'flow') {
|
434 |
+
setTimeout(() => renderFlow(this.store), 0);
|
435 |
+
}
|
436 |
+
document.querySelectorAll('#tabs .tab-button, #tab-content .tab-pane').forEach(el => el.classList.remove('active'));
|
437 |
+
e.target.classList.add('active');
|
438 |
+
document.getElementById(`${tabName}-tab-pane`).classList.add('active');
|
439 |
+
}
|
440 |
+
},
|
441 |
+
|
442 |
+
saveAgent(e) {
|
443 |
+
e.preventDefault();
|
444 |
+
const id = document.getElementById('agent-id').value;
|
445 |
+
const chat = this.store.get('currentChat');
|
446 |
+
if (!chat.agents) chat.agents = [];
|
447 |
+
|
448 |
+
const existingAgent = id ? chat.agents.find(a => a.id === id) : null;
|
449 |
+
|
450 |
+
const useCustomSettings = document.getElementById('agent-use-custom-settings').checked;
|
451 |
+
const agentData = {
|
452 |
+
id: id || `agent-${Date.now()}`,
|
453 |
+
name: document.getElementById('agent-name').value,
|
454 |
+
description: document.getElementById('agent-description').value,
|
455 |
+
systemPrompt: document.getElementById('agent-system-prompt').value,
|
456 |
+
availableAsTool: document.getElementById('agent-available-as-tool').checked,
|
457 |
+
useCustomModelSettings: useCustomSettings,
|
458 |
+
modelSettings: existingAgent ? existingAgent.modelSettings : {},
|
459 |
+
};
|
460 |
+
|
461 |
+
if (!useCustomSettings) {
|
462 |
+
agentData.modelSettings = {};
|
463 |
+
}
|
464 |
+
|
465 |
+
const index = existingAgent ? chat.agents.findIndex(a => a.id === id) : -1;
|
466 |
+
|
467 |
+
if (index > -1) {
|
468 |
+
chat.agents[index] = agentData;
|
469 |
+
} else {
|
470 |
+
chat.agents.push(agentData);
|
471 |
+
}
|
472 |
+
this.store.set('currentChat', { ...chat });
|
473 |
+
hideAgentForm();
|
474 |
+
},
|
475 |
+
|
476 |
+
handleAgentListClick(e) {
|
477 |
+
const id = e.target.dataset.id;
|
478 |
+
if (!id) return;
|
479 |
+
const chat = this.store.get('currentChat');
|
480 |
+
if (e.target.classList.contains('activate-agent-btn')) {
|
481 |
+
chat.activeAgentId = chat.activeAgentId === id ? null : id;
|
482 |
+
} else if (e.target.classList.contains('edit-agent-btn')) {
|
483 |
+
const agent = chat.agents.find(a => a.id === id);
|
484 |
+
showAgentForm(agent, this.store);
|
485 |
+
} else if (e.target.classList.contains('delete-agent-btn')) {
|
486 |
+
if (confirm(`Delete agent?`)) {
|
487 |
+
chat.agents = chat.agents.filter(a => a.id !== id);
|
488 |
+
if (chat.activeAgentId === id) chat.activeAgentId = null;
|
489 |
+
}
|
490 |
+
}
|
491 |
+
this.store.set('currentChat', { ...chat });
|
492 |
+
},
|
493 |
+
|
494 |
+
addFlowStep(type = 'simple-prompt') {
|
495 |
+
const chat = this.store.get('currentChat');
|
496 |
+
if (!chat.flow) chat.flow = { steps: [], connections: [] };
|
497 |
+
const newStep = {
|
498 |
+
id: `step-${Date.now()}`,
|
499 |
+
type: type,
|
500 |
+
x: 50,
|
501 |
+
y: 50,
|
502 |
+
isMinimized: false,
|
503 |
+
};
|
504 |
+
switch (type) {
|
505 |
+
case 'simple-prompt':
|
506 |
+
newStep.agentId = '';
|
507 |
+
newStep.prompt = '';
|
508 |
+
break;
|
509 |
+
case 'prompt-and-clear':
|
510 |
+
newStep.agentId = '';
|
511 |
+
newStep.prompt = 'Summarize everything we discussed so far in a way that will allow us to continue our conversation later, only based on that summary.';
|
512 |
+
break;
|
513 |
+
case 'conditional-stop':
|
514 |
+
newStep.conditionType = 'contains';
|
515 |
+
newStep.condition = '';
|
516 |
+
newStep.onMatch = 'stop';
|
517 |
+
break;
|
518 |
+
case 'multi-prompt':
|
519 |
+
newStep.agentId = '';
|
520 |
+
newStep.prompt = '';
|
521 |
+
newStep.count = 2;
|
522 |
+
break;
|
523 |
+
case 'branching-prompt':
|
524 |
+
newStep.conditionType = 'contains';
|
525 |
+
newStep.condition = '';
|
526 |
+
break;
|
527 |
+
case 'consolidator':
|
528 |
+
newStep.agentId = '';
|
529 |
+
newStep.prePrompt = 'Please choose the best of the following answers:';
|
530 |
+
newStep.postPrompt = 'Explain your choice.';
|
531 |
+
break;
|
532 |
+
}
|
533 |
+
chat.flow.steps.push(newStep);
|
534 |
+
this.store.set('currentChat', { ...chat });
|
535 |
+
},
|
536 |
+
|
537 |
+
handleFlowStepChange(e) {
|
538 |
+
const id = e.target.dataset.id;
|
539 |
+
const chat = this.store.get('currentChat');
|
540 |
+
const step = chat.flow.steps.find(s => s.id === id);
|
541 |
+
if (!step) return;
|
542 |
+
|
543 |
+
const target = e.target;
|
544 |
+
if (target.classList.contains('flow-step-agent')) step.agentId = target.value;
|
545 |
+
if (target.classList.contains('flow-step-prompt')) step.prompt = target.value;
|
546 |
+
if (target.classList.contains('flow-step-condition')) step.condition = target.value;
|
547 |
+
if (target.classList.contains('flow-step-condition-type')) step.conditionType = target.value;
|
548 |
+
if (target.classList.contains('flow-step-on-match')) step.onMatch = target.value;
|
549 |
+
if (target.classList.contains('flow-step-count')) step.count = parseInt(target.value, 10);
|
550 |
+
if (target.classList.contains('flow-step-pre-prompt')) step.prePrompt = target.value;
|
551 |
+
if (target.classList.contains('flow-step-post-prompt')) step.postPrompt = target.value;
|
552 |
+
|
553 |
+
this.store.set('currentChat', { ...chat });
|
554 |
+
},
|
555 |
+
|
556 |
+
handleFlowCanvasClick(e) {
|
557 |
+
const chat = this.store.get('currentChat');
|
558 |
+
let chatModified = false;
|
559 |
+
|
560 |
+
const minimizeBtn = e.target.closest('.minimize-flow-step-btn');
|
561 |
+
if (minimizeBtn) {
|
562 |
+
const stepId = minimizeBtn.dataset.id;
|
563 |
+
const step = chat.flow.steps.find(s => s.id === stepId);
|
564 |
+
if (step) {
|
565 |
+
step.isMinimized = !step.isMinimized;
|
566 |
+
chatModified = true;
|
567 |
+
}
|
568 |
+
}
|
569 |
+
|
570 |
+
const stepDeleteBtn = e.target.closest('.delete-flow-step-btn');
|
571 |
+
if (stepDeleteBtn) {
|
572 |
+
const stepId = stepDeleteBtn.dataset.id;
|
573 |
+
if (stepId && confirm('Are you sure you want to delete this step?')) {
|
574 |
+
chat.flow.steps = chat.flow.steps.filter(s => s.id !== stepId);
|
575 |
+
chat.flow.connections = (chat.flow.connections || []).filter(c => c.from !== stepId && c.to !== stepId);
|
576 |
+
chatModified = true;
|
577 |
+
}
|
578 |
+
}
|
579 |
+
|
580 |
+
const connDeleteBtn = e.target.closest('.delete-connection-btn');
|
581 |
+
if (connDeleteBtn) {
|
582 |
+
const fromId = connDeleteBtn.dataset.from;
|
583 |
+
const toId = connDeleteBtn.dataset.to;
|
584 |
+
const outputName = connDeleteBtn.dataset.outputName;
|
585 |
+
if (fromId && toId) {
|
586 |
+
chat.flow.connections = (chat.flow.connections || []).filter(c =>
|
587 |
+
!(c.from === fromId && c.to === toId && (c.outputName || 'default') === outputName)
|
588 |
+
);
|
589 |
+
chatModified = true;
|
590 |
+
}
|
591 |
+
}
|
592 |
+
|
593 |
+
if (chatModified) {
|
594 |
+
this.store.set('currentChat', { ...chat });
|
595 |
+
}
|
596 |
+
},
|
597 |
+
|
598 |
+
handleFlowCanvasMouseDown(e) {
|
599 |
+
const target = e.target;
|
600 |
+
const canvasWrapper = document.getElementById('flow-canvas-wrapper');
|
601 |
+
|
602 |
+
// Prevent interference with form elements inside a step card
|
603 |
+
if (target.closest('.flow-step-card') && INTERACTIVE_TAGS.includes(target.tagName)) {
|
604 |
+
return;
|
605 |
+
}
|
606 |
+
|
607 |
+
if (target.classList.contains('connector')) {
|
608 |
+
this.connectionInfo.active = true;
|
609 |
+
this.connectionInfo.fromNode = target.closest('.flow-step-card');
|
610 |
+
this.connectionInfo.fromConnector = target;
|
611 |
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
612 |
+
line.setAttribute('stroke', 'red');
|
613 |
+
line.setAttribute('stroke-width', '2');
|
614 |
+
this.connectionInfo.tempLine = line;
|
615 |
+
document.getElementById('flow-svg-layer').appendChild(line);
|
616 |
+
} else if (target.closest('.flow-step-card') && !INTERACTIVE_TAGS.includes(target.tagName)) {
|
617 |
+
e.preventDefault();
|
618 |
+
this.dragInfo.active = true;
|
619 |
+
this.dragInfo.target = target.closest('.flow-step-card');
|
620 |
+
this.dragInfo.offsetX = e.clientX - this.dragInfo.target.offsetLeft;
|
621 |
+
this.dragInfo.offsetY = e.clientY - this.dragInfo.target.offsetTop;
|
622 |
+
} else if (e.target.id === 'flow-canvas' || e.target.id === 'flow-node-container' || e.target.id === 'flow-svg-layer') {
|
623 |
+
e.preventDefault();
|
624 |
+
this.panInfo.active = true;
|
625 |
+
this.panInfo.startX = e.clientX;
|
626 |
+
this.panInfo.startY = e.clientY;
|
627 |
+
this.panInfo.scrollLeft = canvasWrapper.scrollLeft;
|
628 |
+
this.panInfo.scrollTop = canvasWrapper.scrollTop;
|
629 |
+
e.target.closest('#flow-canvas').classList.add('panning');
|
630 |
+
}
|
631 |
+
},
|
632 |
+
|
633 |
+
handleFlowCanvasMouseMove(e) {
|
634 |
+
if (this.dragInfo.active) {
|
635 |
+
const newX = e.clientX - this.dragInfo.offsetX;
|
636 |
+
const newY = e.clientY - this.dragInfo.offsetY;
|
637 |
+
this.dragInfo.target.style.left = `${newX}px`;
|
638 |
+
this.dragInfo.target.style.top = `${newY}px`;
|
639 |
+
const step = this.store.get('currentChat').flow.steps.find(s => s.id === this.dragInfo.target.dataset.id);
|
640 |
+
if (step) { step.x = newX; step.y = newY; }
|
641 |
+
renderFlow(this.store);
|
642 |
+
} else if (this.connectionInfo.active) {
|
643 |
+
const fromRect = this.connectionInfo.fromConnector.getBoundingClientRect();
|
644 |
+
const canvasWrapper = document.getElementById('flow-canvas-wrapper');
|
645 |
+
const canvasRect = canvasWrapper.getBoundingClientRect();
|
646 |
+
const startX = fromRect.left - canvasRect.left + fromRect.width / 2 + canvasWrapper.scrollLeft;
|
647 |
+
const startY = fromRect.top - canvasRect.top + fromRect.height / 2 + canvasWrapper.scrollTop;
|
648 |
+
this.connectionInfo.tempLine.setAttribute('x1', startX);
|
649 |
+
this.connectionInfo.tempLine.setAttribute('y1', startY);
|
650 |
+
this.connectionInfo.tempLine.setAttribute('x2', e.clientX - canvasRect.left + canvasWrapper.scrollLeft);
|
651 |
+
this.connectionInfo.tempLine.setAttribute('y2', e.clientY - canvasRect.top + canvasWrapper.scrollTop);
|
652 |
+
} else if (this.panInfo.active) {
|
653 |
+
e.preventDefault();
|
654 |
+
const canvasWrapper = document.getElementById('flow-canvas-wrapper');
|
655 |
+
const dx = e.clientX - this.panInfo.startX;
|
656 |
+
const dy = e.clientY - this.panInfo.startY;
|
657 |
+
canvasWrapper.scrollLeft = this.panInfo.scrollLeft - dx;
|
658 |
+
canvasWrapper.scrollTop = this.panInfo.scrollTop - dy;
|
659 |
+
}
|
660 |
+
},
|
661 |
+
|
662 |
+
handleFlowCanvasMouseUp(e) {
|
663 |
+
if (this.dragInfo.active) {
|
664 |
+
this.store.set('currentChat', { ...this.store.get('currentChat') });
|
665 |
+
} else if (this.connectionInfo.active) {
|
666 |
+
const toConnector = e.target.classList.contains('connector') ? e.target : e.target.closest('.connector');
|
667 |
+
if (toConnector && toConnector.dataset.type === 'in' && toConnector !== this.connectionInfo.fromConnector) {
|
668 |
+
const toNode = toConnector.closest('.flow-step-card');
|
669 |
+
const fromNode = this.connectionInfo.fromNode;
|
670 |
+
const fromConnector = this.connectionInfo.fromConnector;
|
671 |
+
const chat = this.store.get('currentChat');
|
672 |
+
|
673 |
+
if (!chat.flow.connections) chat.flow.connections = [];
|
674 |
+
|
675 |
+
const newConnection = {
|
676 |
+
from: fromNode.dataset.id,
|
677 |
+
to: toNode.dataset.id,
|
678 |
+
outputName: fromConnector.dataset.outputName
|
679 |
+
};
|
680 |
+
|
681 |
+
// Prevent duplicate connections from the same output port
|
682 |
+
const connectionExists = chat.flow.connections.some(c =>
|
683 |
+
c.from === newConnection.from && c.outputName === newConnection.outputName
|
684 |
+
);
|
685 |
+
|
686 |
+
if (!connectionExists) {
|
687 |
+
chat.flow.connections.push(newConnection);
|
688 |
+
this.store.set('currentChat', { ...chat });
|
689 |
+
} else {
|
690 |
+
log(2, "Connection from this output port already exists.");
|
691 |
+
}
|
692 |
+
}
|
693 |
+
this.connectionInfo.tempLine.remove();
|
694 |
+
} else if (this.panInfo.active) {
|
695 |
+
document.getElementById('flow-canvas').classList.remove('panning');
|
696 |
+
}
|
697 |
+
this.dragInfo.active = false;
|
698 |
+
this.connectionInfo.active = false;
|
699 |
+
this.panInfo.active = false;
|
700 |
+
},
|
701 |
+
|
702 |
+
exportFlow() {
|
703 |
+
const chat = this.store.get('currentChat');
|
704 |
+
if (!chat || !chat.flow) {
|
705 |
+
triggerError('No flow to export.');
|
706 |
+
return;
|
707 |
+
}
|
708 |
+
const filenameBase = `flow_${chat.title.replace(/\s/g, '_')}`;
|
709 |
+
exportJson(chat.flow, filenameBase);
|
710 |
+
},
|
711 |
+
|
712 |
+
importFlow() {
|
713 |
+
importJson('application/json', (importedFlow) => {
|
714 |
+
if (importedFlow && Array.isArray(importedFlow.steps) && Array.isArray(importedFlow.connections)) {
|
715 |
+
const chat = this.store.get('currentChat');
|
716 |
+
chat.flow = importedFlow;
|
717 |
+
this.store.set('currentChat', { ...chat });
|
718 |
+
} else {
|
719 |
+
triggerError('Invalid flow file format.');
|
720 |
+
}
|
721 |
+
});
|
722 |
+
},
|
723 |
+
|
724 |
+
exportAgents() {
|
725 |
+
const chat = this.store.get('currentChat');
|
726 |
+
if (!chat || !chat.agents || chat.agents.length === 0) {
|
727 |
+
triggerError('No agents to export.');
|
728 |
+
return;
|
729 |
+
}
|
730 |
+
const filenameBase = `agents_${chat.title.replace(/\s/g, '_')}`;
|
731 |
+
exportJson(chat.agents, filenameBase);
|
732 |
+
},
|
733 |
+
|
734 |
+
importAgents() {
|
735 |
+
importJson('application/json', (importedAgents) => {
|
736 |
+
if (!Array.isArray(importedAgents)) {
|
737 |
+
triggerError('Invalid agents file format. Expected a JSON array.');
|
738 |
+
return;
|
739 |
+
}
|
740 |
+
|
741 |
+
const chat = this.store.get('currentChat');
|
742 |
+
if (!chat.agents) chat.agents = [];
|
743 |
+
|
744 |
+
const existingAgentIds = new Set(chat.agents.map(a => a.id));
|
745 |
+
|
746 |
+
importedAgents.forEach(importedAgent => {
|
747 |
+
if (existingAgentIds.has(importedAgent.id)) {
|
748 |
+
const index = chat.agents.findIndex(a => a.id === importedAgent.id);
|
749 |
+
chat.agents[index] = importedAgent;
|
750 |
+
} else {
|
751 |
+
chat.agents.push(importedAgent);
|
752 |
+
}
|
753 |
+
});
|
754 |
+
|
755 |
+
this.store.set('currentChat', { ...chat });
|
756 |
+
});
|
757 |
+
},
|
758 |
+
|
759 |
+
// --- Flow Execution Logic ---
|
760 |
+
toggleFlow() {
|
761 |
+
if (this.flowRunning) this.stopFlow();
|
762 |
+
else this.startFlow();
|
763 |
+
},
|
764 |
+
|
765 |
+
updateRunButton(isRunning) {
|
766 |
+
document.getElementById('run-flow-btn').textContent = isRunning ? 'Stop Flow' : 'Run Flow';
|
767 |
+
},
|
768 |
+
|
769 |
+
stopFlow(message = 'Flow stopped.') {
|
770 |
+
this.flowRunning = false;
|
771 |
+
this.currentStepId = null;
|
772 |
+
this.multiMessageInfo = { active: false, step: null, counter: 0, messageToBranchFrom: null };
|
773 |
+
this.updateRunButton(false);
|
774 |
+
const chat = this.store.get('currentChat');
|
775 |
+
if (chat) {
|
776 |
+
chat.activeAgentId = null;
|
777 |
+
this.store.set('currentChat', { ...chat });
|
778 |
+
}
|
779 |
+
log(3, message);
|
780 |
+
},
|
781 |
+
|
782 |
+
executeStep(step) {
|
783 |
+
if (!this.flowRunning) return;
|
784 |
+
if (this.stepCounter++ >= this.maxSteps) {
|
785 |
+
triggerError('Flow execution stopped: Maximum step limit reached.');
|
786 |
+
this.stopFlow();
|
787 |
+
return;
|
788 |
+
}
|
789 |
+
|
790 |
+
this.currentStepId = step.id;
|
791 |
+
const chat = this.store.get('currentChat');
|
792 |
+
const type = step.type || 'simple-prompt'; // Default to agent for old steps
|
793 |
+
const chatlog = this.app.ui.chatBox.chatlog;
|
794 |
+
|
795 |
+
switch (type) {
|
796 |
+
case 'simple-prompt':
|
797 |
+
case 'prompt-and-clear':
|
798 |
+
if (!step.agentId || !step.prompt) {
|
799 |
+
triggerError(`Agent step is not fully configured.`);
|
800 |
+
return this.stopFlow('Step not configured.');
|
801 |
+
}
|
802 |
+
chat.activeAgentId = step.agentId;
|
803 |
+
this.store.set('currentChat', { ...chat });
|
804 |
+
this.app.submitUserMessage(step.prompt, 'user');
|
805 |
+
break;
|
806 |
+
case 'branching-prompt':
|
807 |
+
const bpLastMessage = this.app.ui.chatBox.chatlog.getLastMessage()?.value.content || '';
|
808 |
+
let bpIsMatch = false;
|
809 |
+
const bpCondition = step.condition || '';
|
810 |
+
|
811 |
+
try {
|
812 |
+
switch(step.conditionType) {
|
813 |
+
case 'regex':
|
814 |
+
bpIsMatch = new RegExp(bpCondition).test(bpLastMessage);
|
815 |
+
break;
|
816 |
+
case 'matches':
|
817 |
+
bpIsMatch = (bpLastMessage === bpCondition);
|
818 |
+
break;
|
819 |
+
case 'contains':
|
820 |
+
default:
|
821 |
+
bpIsMatch = bpLastMessage.includes(bpCondition);
|
822 |
+
break;
|
823 |
+
}
|
824 |
+
} catch (e) {
|
825 |
+
triggerError(`Invalid regex in branching step: ${e.message}`);
|
826 |
+
return this.stopFlow('Invalid regex.');
|
827 |
+
}
|
828 |
+
|
829 |
+
const outputName = bpIsMatch ? 'pass' : 'fail';
|
830 |
+
const nextStep = this.getNextStep(step.id, outputName);
|
831 |
+
if (nextStep) {
|
832 |
+
this.executeStep(nextStep);
|
833 |
+
} else {
|
834 |
+
this.stopFlow('Flow execution complete.');
|
835 |
+
}
|
836 |
+
break;
|
837 |
+
case 'multi-prompt':
|
838 |
+
if (!step.agentId || !step.prompt) {
|
839 |
+
triggerError(`Multi-Message step is not fully configured.`);
|
840 |
+
return this.stopFlow('Step not configured.');
|
841 |
+
}
|
842 |
+
this.multiMessageInfo.active = true;
|
843 |
+
this.multiMessageInfo.step = step;
|
844 |
+
this.multiMessageInfo.counter = 1;
|
845 |
+
chat.activeAgentId = step.agentId;
|
846 |
+
this.store.set('currentChat', { ...chat });
|
847 |
+
|
848 |
+
chatlog.addMessage({ role: 'user', content: step.prompt });
|
849 |
+
const assistantMessageToBranchFrom = chatlog.addMessage(null);
|
850 |
+
this.multiMessageInfo.messageToBranchFrom = assistantMessageToBranchFrom;
|
851 |
+
|
852 |
+
this.app.generateAIResponse({}, chatlog);
|
853 |
+
break;
|
854 |
+
case 'conditional-stop':
|
855 |
+
const lastMessage = this.app.ui.chatBox.chatlog.getLastMessage()?.value.content || '';
|
856 |
+
let isMatch = false;
|
857 |
+
const condition = step.condition || '';
|
858 |
+
|
859 |
+
try {
|
860 |
+
switch(step.conditionType) {
|
861 |
+
case 'regex':
|
862 |
+
isMatch = new RegExp(condition).test(lastMessage);
|
863 |
+
break;
|
864 |
+
case 'matches':
|
865 |
+
isMatch = (lastMessage === condition);
|
866 |
+
break;
|
867 |
+
case 'contains':
|
868 |
+
default:
|
869 |
+
isMatch = lastMessage.includes(condition);
|
870 |
+
break;
|
871 |
+
}
|
872 |
+
} catch (e) {
|
873 |
+
triggerError(`Invalid regex in conditional step: ${e.message}`);
|
874 |
+
return this.stopFlow('Invalid regex.');
|
875 |
+
}
|
876 |
+
|
877 |
+
let shouldContinue = true;
|
878 |
+
if (isMatch) {
|
879 |
+
if (step.onMatch === 'stop') {
|
880 |
+
this.stopFlow('Flow stopped by conditional match.');
|
881 |
+
shouldContinue = false;
|
882 |
+
}
|
883 |
+
} else {
|
884 |
+
if (step.onMatch === 'continue') {
|
885 |
+
this.stopFlow('Flow stopped: condition not met.');
|
886 |
+
shouldContinue = false;
|
887 |
+
}
|
888 |
+
}
|
889 |
+
|
890 |
+
if (shouldContinue) {
|
891 |
+
const nextStep = this.getNextStep(step.id);
|
892 |
+
if (nextStep) {
|
893 |
+
this.executeStep(nextStep);
|
894 |
+
} else {
|
895 |
+
this.stopFlow('Flow execution complete.');
|
896 |
+
}
|
897 |
+
}
|
898 |
+
break;
|
899 |
+
case 'consolidator':
|
900 |
+
const activeMessages = chatlog.getActiveMessageValues().map((_, i) => chatlog.getNthMessage(i));
|
901 |
+
|
902 |
+
let sourceMessage = null;
|
903 |
+
for (let i = activeMessages.length - 1; i >= 0; i--) {
|
904 |
+
const msg = activeMessages[i];
|
905 |
+
if (msg && msg.answerAlternatives && msg.answerAlternatives.messages.length > 1) {
|
906 |
+
sourceMessage = msg;
|
907 |
+
break;
|
908 |
+
}
|
909 |
+
}
|
910 |
+
|
911 |
+
if (!sourceMessage) {
|
912 |
+
triggerError(`Consolidator could not find a preceding step with alternatives.`);
|
913 |
+
return this.stopFlow('Invalid flow structure for Consolidator.');
|
914 |
+
}
|
915 |
+
|
916 |
+
const consolidatedContent = sourceMessage.answerAlternatives.messages.map((alternativeStartMessage, i) => {
|
917 |
+
let turnContent = '';
|
918 |
+
let currentMessageInTurn = alternativeStartMessage;
|
919 |
+
while (currentMessageInTurn) {
|
920 |
+
if (currentMessageInTurn.value) {
|
921 |
+
const { role, content } = currentMessageInTurn.value;
|
922 |
+
turnContent += `**${role.charAt(0).toUpperCase() + role.slice(1)}:**\n${content}\n\n`;
|
923 |
+
}
|
924 |
+
|
925 |
+
if (currentMessageInTurn.answerAlternatives && currentMessageInTurn.answerAlternatives.messages.length > 0) {
|
926 |
+
// In a non-branching chain (like agent-tool-agent), there should only be one message.
|
927 |
+
// We assume the first message is the continuation of the chain.
|
928 |
+
currentMessageInTurn = currentMessageInTurn.answerAlternatives.messages[0];
|
929 |
+
} else {
|
930 |
+
currentMessageInTurn = null;
|
931 |
+
}
|
932 |
+
}
|
933 |
+
return `--- ALTERNATIVE ${i + 1} ---\n${turnContent.trim()}`;
|
934 |
+
}).join('\n\n');
|
935 |
+
|
936 |
+
const finalPrompt = `${step.prePrompt || ''}\n\n${consolidatedContent}\n\n${step.postPrompt || ''}`;
|
937 |
+
chat.activeAgentId = step.agentId;
|
938 |
+
this.store.set('currentChat', { ...chat });
|
939 |
+
this.app.submitUserMessage(finalPrompt, 'user');
|
940 |
+
break;
|
941 |
+
}
|
942 |
+
},
|
943 |
+
|
944 |
+
startFlow() {
|
945 |
+
log(3, 'Starting flow execution...');
|
946 |
+
const chat = this.store.get('currentChat');
|
947 |
+
const { steps, connections } = chat.flow;
|
948 |
+
if (!steps || steps.length === 0) {
|
949 |
+
triggerError('Flow has no steps.');
|
950 |
+
return;
|
951 |
+
}
|
952 |
+
const nodesWithIncoming = new Set((connections || []).map(c => c.to));
|
953 |
+
const startingNodes = steps.filter(s => !nodesWithIncoming.has(s.id));
|
954 |
+
if (startingNodes.length !== 1) {
|
955 |
+
triggerError('Flow must have exactly one starting node.');
|
956 |
+
return;
|
957 |
+
}
|
958 |
+
this.flowRunning = true;
|
959 |
+
this.stepCounter = 0;
|
960 |
+
this.updateRunButton(true);
|
961 |
+
this.executeStep(startingNodes[0]);
|
962 |
+
},
|
963 |
+
getNextStep(stepId, outputName = 'default') {
|
964 |
+
const chat = this.store.get('currentChat');
|
965 |
+
const connection = chat.flow.connections.find(c => c.from === stepId && (c.outputName || 'default') === outputName);
|
966 |
+
return connection ? chat.flow.steps.find(s => s.id === connection.to) : null;
|
967 |
+
},
|
968 |
+
|
969 |
+
// --- Hooks Definition ---
|
970 |
+
hooks: {
|
971 |
+
onModifySystemPrompt: (systemContent) => {
|
972 |
+
const store = agentsPlugin.store;
|
973 |
+
if (!store) return systemContent;
|
974 |
+
let cleanedContent = systemContent
|
975 |
+
.replace(/\n\n--- AGENT DEFINITION ---\n[\s\S]*?\n--- END AGENT DEFINITION ---/g, '')
|
976 |
+
.replace(/\n\n--- AGENT TOOLS ---\n[\s\S]*?\n--- END AGENT TOOLS ---/g, '');
|
977 |
+
const chat = store.get('currentChat');
|
978 |
+
if (!chat || !chat.activeAgentId) return cleanedContent;
|
979 |
+
const agent = chat.agents.find(a => a.id === chat.activeAgentId);
|
980 |
+
if (!agent) return cleanedContent;
|
981 |
+
let modified = cleanedContent + `\n\n--- AGENT DEFINITION ---\n${agent.systemPrompt}\n--- END AGENT DEFINITION ---`;
|
982 |
+
const tools = chat.agents.filter(a => a.availableAsTool && a.id !== chat.activeAgentId);
|
983 |
+
if (tools.length > 0) {
|
984 |
+
modified += '\n\n--- AGENT TOOLS ---\n';
|
985 |
+
|
986 |
+
modified += `### Agent Tools:
|
987 |
+
|
988 |
+
To call an agent tool, use: <dma:tool_call name="agent_name_agent"><parameter name="prompt">...</parameter></dma:tool_call>
|
989 |
+
### Available Tools:\n\n`;
|
990 |
+
|
991 |
+
tools.forEach(t => { modified += `- ${t.name}: ${t.description}\n`; });
|
992 |
+
modified += '\n--- END AGENT TOOLS ---';
|
993 |
+
}
|
994 |
+
return modified;
|
995 |
+
},
|
996 |
+
onMessageComplete: async (message, chatlog, chatbox) => {
|
997 |
+
if (!message.value) return; // Defend against null message value
|
998 |
+
const { toolCalls } = parseFunctionCalls(message.value.content);
|
999 |
+
|
1000 |
+
// --- Multi-Message Continuation ---
|
1001 |
+
if (agentsPlugin.flowRunning && agentsPlugin.multiMessageInfo.active) {
|
1002 |
+
if (toolCalls.length > 0) return; // Wait for tool calls to complete
|
1003 |
+
|
1004 |
+
const { step, counter, messageToBranchFrom } = agentsPlugin.multiMessageInfo;
|
1005 |
+
if (counter < step.count) {
|
1006 |
+
agentsPlugin.multiMessageInfo.counter++;
|
1007 |
+
const chat = agentsPlugin.store.get('currentChat');
|
1008 |
+
chat.activeAgentId = step.agentId;
|
1009 |
+
agentsPlugin.store.set('currentChat', { ...chat });
|
1010 |
+
addAlternativeToChat(chatlog, messageToBranchFrom, null);
|
1011 |
+
agentsPlugin.app.generateAIResponse({}, chatlog);
|
1012 |
+
return;
|
1013 |
+
} else {
|
1014 |
+
agentsPlugin.multiMessageInfo = { active: false, step: null, counter: 0, messageToBranchFrom: null };
|
1015 |
+
}
|
1016 |
+
}
|
1017 |
+
|
1018 |
+
// --- Agent Tool Call Processing ---
|
1019 |
+
const context = {
|
1020 |
+
app: agentsPlugin.app,
|
1021 |
+
store: agentsPlugin.store,
|
1022 |
+
};
|
1023 |
+
await processToolCalls(message, chatlog, chatbox, filterAgentCalls, executeAgentCall, context);
|
1024 |
+
|
1025 |
+
// --- Flow Continuation ---
|
1026 |
+
// Re-parse *after* processToolCalls might have added its own messages.
|
1027 |
+
const newToolCalls = parseFunctionCalls(message.value.content).toolCalls;
|
1028 |
+
if (agentsPlugin.flowRunning && newToolCalls.length === 0) {
|
1029 |
+
const currentChat = agentsPlugin.store.get('currentChat');
|
1030 |
+
const currentStep = currentChat.flow.steps.find(s => s.id === agentsPlugin.currentStepId);
|
1031 |
+
|
1032 |
+
if (currentStep && currentStep.type === 'prompt-and-clear') {
|
1033 |
+
const activeMessages = chatlog.getActiveMessageValues();
|
1034 |
+
const userMessageIndex = activeMessages.length - 2;
|
1035 |
+
const firstMessage = chatlog.getFirstMessage();
|
1036 |
+
const hasSystemPrompt = firstMessage && firstMessage.value.role === 'system';
|
1037 |
+
const startIndex = hasSystemPrompt ? 1 : 0;
|
1038 |
+
for (let i = userMessageIndex - 1; i >= startIndex; i--) {
|
1039 |
+
chatlog.deleteNthMessage(i);
|
1040 |
+
}
|
1041 |
+
}
|
1042 |
+
|
1043 |
+
const { steps, connections } = currentChat.flow;
|
1044 |
+
const nextConnection = connections.find(c => c.from === agentsPlugin.currentStepId);
|
1045 |
+
const nextStep = nextConnection ? steps.find(s => s.id === nextConnection.to) : null;
|
1046 |
+
if (nextStep) {
|
1047 |
+
agentsPlugin.executeStep(nextStep);
|
1048 |
+
} else {
|
1049 |
+
agentsPlugin.stopFlow('Flow execution complete.');
|
1050 |
+
}
|
1051 |
+
}
|
1052 |
+
}
|
1053 |
+
}
|
1054 |
+
};
|
1055 |
+
function filterAgentCalls(call) {
|
1056 |
+
return call.name.endsWith('_agent');
|
1057 |
+
}
|
1058 |
+
|
1059 |
+
async function executeAgentCall(call, context) {
|
1060 |
+
const { app, store } = context;
|
1061 |
+
const currentChat = store.get('currentChat');
|
1062 |
+
const agentToCall = currentChat.agents.find(a => `${a.name.toLowerCase().replace(/\s+/g, '_')}_agent` === call.name);
|
1063 |
+
|
1064 |
+
if (!agentToCall) {
|
1065 |
+
return { id: call.id, error: `Agent "${call.name}" not found.` };
|
1066 |
+
}
|
1067 |
+
const prompt = call.params.prompt;
|
1068 |
+
if (typeof prompt !== 'string') {
|
1069 |
+
return { id: call.id, error: `Agent call to "${call.name}" is missing the "prompt" parameter.` };
|
1070 |
+
}
|
1071 |
+
|
1072 |
+
const payload = {
|
1073 |
+
model: app.configService.getItem('model', ''),
|
1074 |
+
messages: [
|
1075 |
+
{ role: 'system', content: agentToCall.systemPrompt },
|
1076 |
+
{ role: 'user', content: prompt }
|
1077 |
+
],
|
1078 |
+
temperature: Number(app.ui.temperatureEl.value),
|
1079 |
+
top_p: Number(app.ui.topPEl.value),
|
1080 |
+
stream: true
|
1081 |
+
};
|
1082 |
+
|
1083 |
+
try {
|
1084 |
+
const reader = await app.apiService.streamAPIResponse(payload, app.configService.getItem('endpoint', defaultEndpoint), app.configService.getItem('apiKey', ''), new AbortController().signal);
|
1085 |
+
let responseContent = '';
|
1086 |
+
const decoder = new TextDecoder();
|
1087 |
+
while (true) {
|
1088 |
+
const { done, value } = await reader.read();
|
1089 |
+
if (done) break;
|
1090 |
+
const chunk = decoder.decode(value);
|
1091 |
+
chunk.split('\n').forEach(line => {
|
1092 |
+
if (line.startsWith('data: ')) {
|
1093 |
+
const data = line.substring(6);
|
1094 |
+
if (data.trim() !== '[DONE]') {
|
1095 |
+
try {
|
1096 |
+
responseContent += JSON.parse(data).choices[0]?.delta?.content || '';
|
1097 |
+
} catch (e) {
|
1098 |
+
log(2, 'Error parsing agent response chunk', e);
|
1099 |
+
}
|
1100 |
+
}
|
1101 |
+
}
|
1102 |
+
});
|
1103 |
+
}
|
1104 |
+
return { id: call.id, content: responseContent };
|
1105 |
+
} catch (error) {
|
1106 |
+
log(1, 'Agent call failed', error);
|
1107 |
+
return { id: call.id, error: error.message || 'Unknown error during agent execution.' };
|
1108 |
+
}
|
1109 |
+
}
|
1110 |
+
export { agentsPlugin };
|
js/plugins/avatars.js
ADDED
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview A plugin for handling user and assistant avatars.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { triggerError, log } from '../utils/logger.js';
|
8 |
+
|
9 |
+
/**
|
10 |
+
* Default SVG avatar for the user.
|
11 |
+
* @type {string}
|
12 |
+
*/
|
13 |
+
const avatarPing = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="80" height="80">
|
14 |
+
<circle cx="40" cy="40" r="40" fill="#FFC107" />
|
15 |
+
<circle cx="25" cy="30" r="5" fill="white" />
|
16 |
+
<circle cx="55" cy="30" r="5" fill="white" />
|
17 |
+
<path d="M 25 55 Q 40 65, 55 55" fill="none" stroke="white" stroke-width="4" />
|
18 |
+
</svg>`;
|
19 |
+
|
20 |
+
/**
|
21 |
+
* Default SVG avatar for the assistant.
|
22 |
+
* @type {string}
|
23 |
+
*/
|
24 |
+
const avatarPong = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="80" height="80">
|
25 |
+
<rect x="2" y="2" width="76" height="76" fill="#2196F3" />
|
26 |
+
<circle cx="25" cy="30" r="5" fill="white" />
|
27 |
+
<circle cx="55" cy="30" r="5" fill="white" />
|
28 |
+
<rect x="15" y="50" width="50" height="5" fill="#ffffff" />
|
29 |
+
<rect x="25" y="60" width="30" height="5" fill="#ffffff" />
|
30 |
+
</svg>`;
|
31 |
+
|
32 |
+
/**
|
33 |
+
* @typedef {import('../components/chatbox.js').ChatBox} ChatBox
|
34 |
+
* @typedef {import('../components/chatlog.js').Message} Message
|
35 |
+
*/
|
36 |
+
|
37 |
+
/**
|
38 |
+
* Plugin for handling avatars, allowing custom uploads via localStorage.
|
39 |
+
* @type {import('../hooks.js').Plugin}
|
40 |
+
*/
|
41 |
+
export const avatarsPlugin = {
|
42 |
+
name: 'avatars',
|
43 |
+
configService: null,
|
44 |
+
|
45 |
+
init: function(app) {
|
46 |
+
this.configService = app.configService;
|
47 |
+
|
48 |
+
// Bind all methods to this instance
|
49 |
+
Object.keys(this.hooks).forEach(hookName => {
|
50 |
+
this.hooks[hookName] = this.hooks[hookName].bind(this);
|
51 |
+
});
|
52 |
+
},
|
53 |
+
|
54 |
+
hooks: {
|
55 |
+
/**
|
56 |
+
* Renders the avatar for a message.
|
57 |
+
* @param {HTMLElement} el - The message element.
|
58 |
+
* @param {Message} message - The message object.
|
59 |
+
* @param {ChatBox} chatbox - The ChatBox instance.
|
60 |
+
*/
|
61 |
+
onRenderMessage: function (el, message, chatbox) {
|
62 |
+
log(4, 'avatarsPlugin: Rendering avatar for role', message.value.role);
|
63 |
+
let type = 'ping'; // Default to user avatar type.
|
64 |
+
if (message.value.role === 'assistant') type = 'pong'; // Switch to assistant type if role is assistant.
|
65 |
+
const avatar = document.createElement('img');
|
66 |
+
let avatarSrc = this.configService.getItem(`${type}Avatar`);
|
67 |
+
const isCustom = !!avatarSrc; // Check if a custom avatar is set.
|
68 |
+
avatar.classList.add('avatar', 'clickable');
|
69 |
+
avatar.src = avatarSrc || `data:image/svg+xml,${encodeURIComponent(type === 'ping' ? avatarPing : avatarPong)}`;
|
70 |
+
|
71 |
+
avatar.addEventListener('click', () => {
|
72 |
+
log(5, 'avatarsPlugin: Avatar clicked for type', type);
|
73 |
+
if (isCustom) {
|
74 |
+
log(4, 'avatarsPlugin: Resetting to default avatar for type', type);
|
75 |
+
avatar.src = `data:image/svg+xml,${encodeURIComponent(type === 'ping' ? avatarPing : avatarPong)}`;
|
76 |
+
this.configService.removeItem(`${type}Avatar`);
|
77 |
+
chatbox.chatlog.clearCache();
|
78 |
+
chatbox.update(false);
|
79 |
+
return;
|
80 |
+
}
|
81 |
+
const input = document.createElement('input');
|
82 |
+
input.type = 'file';
|
83 |
+
input.accept = 'image/*';
|
84 |
+
input.addEventListener('change', () => {
|
85 |
+
log(5, 'avatarsPlugin: File input changed');
|
86 |
+
const file = input.files[0];
|
87 |
+
if (!file) return;
|
88 |
+
log(3, 'avatarsPlugin: Selected file', file.name, 'size:', file.size);
|
89 |
+
if (!file.type.startsWith('image/')) {
|
90 |
+
log(2, 'avatarsPlugin: Invalid file type', file.type);
|
91 |
+
triggerError('Invalid file type. Please upload an image.');
|
92 |
+
return;
|
93 |
+
}
|
94 |
+
if (file.size > 1024 * 1024 * 2) { // 2MB limit
|
95 |
+
log(2, 'avatarsPlugin: File too large', file.size);
|
96 |
+
triggerError('File too large. Maximum size is 2MB.');
|
97 |
+
return;
|
98 |
+
}
|
99 |
+
// Read the file as Data URL.
|
100 |
+
const reader = new FileReader();
|
101 |
+
reader.addEventListener('load', () => {
|
102 |
+
log(4, 'avatarsPlugin: File loaded successfully');
|
103 |
+
this.configService.setItem(`${type}Avatar`, reader.result);
|
104 |
+
avatar.src = reader.result;
|
105 |
+
chatbox.chatlog.clearCache();
|
106 |
+
chatbox.update(false);
|
107 |
+
});
|
108 |
+
reader.addEventListener('error', () => {
|
109 |
+
log(1, 'avatarsPlugin: Failed to read file');
|
110 |
+
triggerError('Failed to read avatar file.');
|
111 |
+
});
|
112 |
+
reader.readAsDataURL(file);
|
113 |
+
});
|
114 |
+
input.click();
|
115 |
+
});
|
116 |
+
|
117 |
+
el.insertAdjacentElement('afterbegin', avatar);
|
118 |
+
}
|
119 |
+
}
|
120 |
+
};
|
js/plugins/error-bubble.js
ADDED
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview A plugin for displaying error messages in a bubble UI element.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { log } from '../utils/logger.js';
|
8 |
+
|
9 |
+
/**
|
10 |
+
* The timeout ID for the error bubble.
|
11 |
+
* @type {number|null}
|
12 |
+
*/
|
13 |
+
let timeoutId = null;
|
14 |
+
|
15 |
+
/**
|
16 |
+
* Hides the error bubble.
|
17 |
+
*/
|
18 |
+
function hideBubble() {
|
19 |
+
log(5, 'errorBubblePlugin: Hiding error bubble');
|
20 |
+
const bubble = document.getElementById('error-bubble');
|
21 |
+
if (!bubble) return;
|
22 |
+
bubble.classList.add('hiding');
|
23 |
+
bubble.addEventListener('animationend', () => {
|
24 |
+
bubble.style.display = 'none';
|
25 |
+
bubble.classList.remove('hiding');
|
26 |
+
document.getElementById('error-bubble-content').innerHTML = '';
|
27 |
+
}, { once: true }); // One-time listener.
|
28 |
+
}
|
29 |
+
|
30 |
+
/**
|
31 |
+
* Plugin for displaying error messages in a bubble UI element.
|
32 |
+
* @type {import('../hooks.js').Plugin}
|
33 |
+
*/
|
34 |
+
export const errorBubblePlugin = {
|
35 |
+
name: 'error-bubble',
|
36 |
+
hooks: {
|
37 |
+
/**
|
38 |
+
* Displays an error message in the error bubble.
|
39 |
+
* @param {...*} args - The error arguments to display.
|
40 |
+
*/
|
41 |
+
onError: function (...args) {
|
42 |
+
log(5, 'errorBubblePlugin: onError called with args', args);
|
43 |
+
if (args.length === 0) {
|
44 |
+
args = ['Unknown error'];
|
45 |
+
}
|
46 |
+
// Format each argument into a string for display.
|
47 |
+
const formattedParts = args.map(arg => {
|
48 |
+
if (arg instanceof Error) {
|
49 |
+
return arg.message;
|
50 |
+
} else if (typeof arg === 'object' && arg !== null) {
|
51 |
+
return JSON.stringify(arg, null, 2);
|
52 |
+
} else {
|
53 |
+
return String(arg);
|
54 |
+
}
|
55 |
+
});
|
56 |
+
const bubble = document.getElementById('error-bubble');
|
57 |
+
if (!bubble) return;
|
58 |
+
const content = document.getElementById('error-bubble-content');
|
59 |
+
content.style.padding = '15px 5px 0 5px';
|
60 |
+
formattedParts.forEach(part => {
|
61 |
+
const messageEl = document.createElement('div');
|
62 |
+
messageEl.style.wordBreak = 'break-word';
|
63 |
+
messageEl.style.margin = '0 0 15px 0'; // Spacing between parts
|
64 |
+
messageEl.style.backgroundColor = '#772222a0';
|
65 |
+
messageEl.style.padding = '5px';
|
66 |
+
messageEl.style.borderRadius = '10px';
|
67 |
+
// Split the part into lines and create elements for each
|
68 |
+
const lines = part.split('\n');
|
69 |
+
lines.forEach((line, index) => {
|
70 |
+
const lineEl = document.createElement('div');
|
71 |
+
lineEl.textContent = line;
|
72 |
+
lineEl.style.whiteSpace = 'pre-wrap';
|
73 |
+
if (index < lines.length - 1) {
|
74 |
+
lineEl.style.marginBottom = '10px';
|
75 |
+
}
|
76 |
+
messageEl.appendChild(lineEl);
|
77 |
+
});
|
78 |
+
content.appendChild(messageEl);
|
79 |
+
});
|
80 |
+
bubble.style.display = 'block';
|
81 |
+
bubble.classList.remove('hiding');
|
82 |
+
if (timeoutId) clearTimeout(timeoutId);
|
83 |
+
timeoutId = setTimeout(hideBubble, 20000);
|
84 |
+
}
|
85 |
+
}
|
86 |
+
};
|
87 |
+
|
88 |
+
document.getElementById('error-bubble-close').addEventListener('click', hideBubble);
|
js/plugins/formatting.js
ADDED
@@ -0,0 +1,521 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview A collection of plugins for formatting message content.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { triggerError, log } from '../utils/logger.js';
|
8 |
+
|
9 |
+
/**
|
10 |
+
* @typedef {import('../hooks.js').Plugin} Plugin
|
11 |
+
*/
|
12 |
+
|
13 |
+
/**
|
14 |
+
* A collection of plugins for formatting message content.
|
15 |
+
* Each plugin applies transformations like SVG normalization, thinking tags, Markdown, KaTeX, and clip badges.
|
16 |
+
* @type {Plugin[]}
|
17 |
+
*/
|
18 |
+
export const formattingPlugins = [
|
19 |
+
{
|
20 |
+
name: 'svg_normalization',
|
21 |
+
hooks: {
|
22 |
+
/**
|
23 |
+
* Normalizes SVG content. Wraps SVGs in code blocks and fixes data URIs.
|
24 |
+
* @param {string} text - The text content to format.
|
25 |
+
* @param {int} pos - The position of the message.
|
26 |
+
* @returns {string} The formatted text.
|
27 |
+
*/
|
28 |
+
onFormatContent: function (text, pos) {
|
29 |
+
log(5, 'formattingPlugins: svg_normalization onFormatContent called');
|
30 |
+
text = text.replace(/((?:```\w*?\s*?)|(?:<render_component[^>]*?>\s*?)|)(<svg[^>]*?>)([\s\S]*?)(<\/svg>(?:\s*?```|\s*?<\/render_component>|)|$)/gi,
|
31 |
+
(match, prefix, svgStart, content, closing) => {
|
32 |
+
let output = '```svg\n' + svgStart;
|
33 |
+
if (closing?.startsWith('</svg>')) {
|
34 |
+
output += content + '</svg>\n```';
|
35 |
+
} else {
|
36 |
+
// Incomplete: don't add </svg> or closing ```.
|
37 |
+
output += content;
|
38 |
+
}
|
39 |
+
return output;
|
40 |
+
}
|
41 |
+
);
|
42 |
+
text = text.replace(/\(data:image\/svg\+xml,([a-z0-9_"'%+-]+?)\)/gmi, (match, g1) => {
|
43 |
+
let data = decodeURIComponent(g1);
|
44 |
+
data = data.replace(/<svg\s/gmi, '<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" ');
|
45 |
+
return `(data:image/svg+xml,${encodeURIComponent(data)})`;
|
46 |
+
});
|
47 |
+
return text;
|
48 |
+
}
|
49 |
+
}
|
50 |
+
},
|
51 |
+
{
|
52 |
+
name: 'preDetailsWrapper',
|
53 |
+
hooks: {
|
54 |
+
/**
|
55 |
+
* Wraps system prompt sections and tool calls in in special tags for later wrapping in <details> tags.
|
56 |
+
* @param {string} text - The text content to format.
|
57 |
+
* @param {int} pos - The position of the message.
|
58 |
+
* @returns {string} The formatted text.
|
59 |
+
*/
|
60 |
+
onFormatContent: function (text, pos) {
|
61 |
+
// Wrap tool calls in special tags to be able to later wrap in <details>
|
62 |
+
text = text.replace(/<dma:tool_call[^>]+?name="([^>]*?)"[^>]*?(?:\/>|>[\s\S]*?<\/dma:tool_call\s*>)/gi, (match, name) => {
|
63 |
+
const title = name ? name : '';
|
64 |
+
return `\n-#--#- TOOL CALL -#--#- ${title.trim()} -#--#-\n\`\`\`html\n${match.trim()}\n\`\`\`\n-#--#- END TOOL CALL -#--#-\n`;
|
65 |
+
});
|
66 |
+
// Wrap tool responses in special tags to be able to later wrap in <details>
|
67 |
+
text = text.replace(/<dma:tool_response[^>]+?name="([^>]*?)"[^>]*?(?:\/>|>[\s\S]*?<\/dma:tool_response\s*>)/gi, (match, name) => {
|
68 |
+
const title = name ? name : '';
|
69 |
+
return `\n-#--#- TOOL RESPONSE -#--#- ${title.trim()} -#--#-\n\`\`\`html\n${match.trim()}\n\`\`\`\n-#--#- END TOOL RESPONSE -#--#-\n`;
|
70 |
+
});
|
71 |
+
return text;
|
72 |
+
}
|
73 |
+
}
|
74 |
+
},
|
75 |
+
{
|
76 |
+
name: 'markdown',
|
77 |
+
hooks: {
|
78 |
+
/**
|
79 |
+
* Renders Markdown using markdown-it and syntax highlighting with highlight.js.
|
80 |
+
* @param {string} text - The text content to format.
|
81 |
+
* @param {int} pos - The position of the message.
|
82 |
+
* @returns {string} The formatted text.
|
83 |
+
*/
|
84 |
+
onFormatContent: function (text, pos) {
|
85 |
+
log(5, 'formattingPlugins: markdown onFormatContent called');
|
86 |
+
const mdSettings = {
|
87 |
+
html: false, // Disable HTML tags in source.
|
88 |
+
xhtmlOut: false, // Use '/' to close single tags (<br />).
|
89 |
+
breaks: false, // Convert '\n' in paragraphs into <br>.
|
90 |
+
langPrefix: 'language-', // CSS language prefix for fenced blocks.
|
91 |
+
linkify: true, // Autoconvert URL-like text to links.
|
92 |
+
typographer: false, // Enable some language-neutral replacement + quotes beautification.
|
93 |
+
quotes: `""''`, // Double + single quotes replacement pairs.
|
94 |
+
// Highlight function for code blocks.
|
95 |
+
highlight: function (code, language) { // This needs to be a regular function, because arrow functions do not bind their own "this"-context and this.langPrefix would not be accessible.
|
96 |
+
let value = '';
|
97 |
+
try {
|
98 |
+
if (language && hljs.getLanguage(language)) {
|
99 |
+
value = hljs.highlight(code, { language, ignoreIllegals: true }).value;
|
100 |
+
} else {
|
101 |
+
const highlighted = hljs.highlightAuto(code);
|
102 |
+
language = highlighted.language || 'unknown';
|
103 |
+
value = highlighted.value;
|
104 |
+
}
|
105 |
+
} catch (error) {
|
106 |
+
triggerError('Highlight error:', error, code);
|
107 |
+
}
|
108 |
+
return `<pre class="hljs ${this.langPrefix}${language}" data-plaintext="${encodeURIComponent(code.trim())}"><code>${value}</code></pre>`;
|
109 |
+
}
|
110 |
+
};
|
111 |
+
const md = window.markdownit(mdSettings);
|
112 |
+
md.validateLink = link => !['javascript:', 'dma:'].some(prefix => link.startsWith(prefix));
|
113 |
+
return md.render(text);
|
114 |
+
}
|
115 |
+
}
|
116 |
+
},
|
117 |
+
{
|
118 |
+
name: 'think',
|
119 |
+
hooks: {
|
120 |
+
/**
|
121 |
+
* Handles <think> tags. Converts them to collapsible HTML details elements.
|
122 |
+
* @param {string} text - The text content to format.
|
123 |
+
* @param {int} pos - The position of the message.
|
124 |
+
* @returns {string} The formatted text.
|
125 |
+
*/
|
126 |
+
onFormatContent: function (text, pos) {
|
127 |
+
log(5, 'formattingPlugins: think onFormatContent called');
|
128 |
+
text = text.replace(/<think>([\s\S]*?)<\/think>/g, '<details class="think"><summary>Thinking</summary><div class="think-content">$1</div></details>');
|
129 |
+
// Unmatched <think> at the end with open details.
|
130 |
+
text = text.replace(/<think>([\s\S]*)$/, '<details open class="think"><summary>Thinking</summary><div class="think-content">$1</div></details>');
|
131 |
+
return text;
|
132 |
+
}
|
133 |
+
}
|
134 |
+
},
|
135 |
+
{
|
136 |
+
name: 'detailsWrapper',
|
137 |
+
hooks: {
|
138 |
+
/**
|
139 |
+
* Wraps system prompt sections and tool calls in <details> tags.
|
140 |
+
* @param {string} text - The text content to format.
|
141 |
+
* @param {int} pos - The position of the message.
|
142 |
+
* @returns {string} The formatted text.
|
143 |
+
*/
|
144 |
+
onFormatContent: function (text, pos) {
|
145 |
+
log(5, 'formattingPlugins: detailsWrapper onFormatContent called');
|
146 |
+
// Wrap system prompt sections in <details>
|
147 |
+
text = text.replace(/<p>--- (.*?) ---(?:<\/p>|\n)([\s\S]*?)(?:<p>|\n)--- END \1 ---<\/p>/g, (match, title, content) => {
|
148 |
+
return `<details class="system-prompt-section"><summary>${title}</summary><div class="system-prompt-content"><p>--- ${title} ---</p>${content}<p>--- END ${title} ---</p></div></details>`;
|
149 |
+
});
|
150 |
+
|
151 |
+
const open = (pos === 0) ? ' open' : '';
|
152 |
+
// Wrap tool calls in <details>
|
153 |
+
text = text.replace(/-#--#- TOOL CALL -#--#- (.*?) -#--#-<\/p>([\s\S]*?)<p>-#--#- END TOOL CALL -#--#-/g, (match, name, content) => {
|
154 |
+
const title = name ? ': ' + name : '';
|
155 |
+
return `<details${open} class="tool-call"><summary>Tool Call${title}</summary>${content}</details>`;
|
156 |
+
});
|
157 |
+
// Wrap tool responses in <details>
|
158 |
+
text = text.replace(/-#--#- TOOL RESPONSE -#--#- (.*?) -#--#-<\/p>([\s\S]*?)<p>-#--#- END TOOL RESPONSE -#--#-/g, (match, name, content) => {
|
159 |
+
const title = name ? ': ' + name : '';
|
160 |
+
return `<details${open} class="tool-response"><summary>Tool Response${title}</summary>${content}</details>`;
|
161 |
+
});
|
162 |
+
|
163 |
+
return text;
|
164 |
+
}
|
165 |
+
}
|
166 |
+
},
|
167 |
+
{
|
168 |
+
name: 'katex',
|
169 |
+
hooks: {
|
170 |
+
/**
|
171 |
+
* Renders LaTeX math with KaTeX.
|
172 |
+
* @param {HTMLElement} wrapper - The wrapper element containing the content.
|
173 |
+
*/
|
174 |
+
onPostFormatContent: function (wrapper) {
|
175 |
+
log(5, 'formattingPlugins: katex onPostFormatContent called');
|
176 |
+
const origFormulas = [];
|
177 |
+
const ktSettings = {
|
178 |
+
delimiters: [
|
179 |
+
{ left: '$$ ', right: ' $$', display: true },
|
180 |
+
{ left: '$', right: '$', display: false },
|
181 |
+
{ left: '\$$ ', right: '\ $$', display: true }
|
182 |
+
],
|
183 |
+
ignoredTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code', 'option', 'table', 'svg'],
|
184 |
+
throwOnError: false,
|
185 |
+
preProcess: math => {
|
186 |
+
origFormulas.push(math);
|
187 |
+
return math;
|
188 |
+
}
|
189 |
+
};
|
190 |
+
renderMathInElement(wrapper, ktSettings);
|
191 |
+
wrapper.querySelectorAll('.katex').forEach((elem, i) => {
|
192 |
+
if (i >= origFormulas.length) return;
|
193 |
+
const formula = elem.parentElement;
|
194 |
+
if (formula.classList.contains('katex-display')) {
|
195 |
+
const div = document.createElement('div');
|
196 |
+
div.classList.add('hljs', 'language-latex');
|
197 |
+
div.dataset.plaintext = encodeURIComponent(origFormulas[i].trim());
|
198 |
+
const pe = formula.parentElement;
|
199 |
+
pe.insertBefore(div, formula);
|
200 |
+
div.appendChild(formula);
|
201 |
+
}
|
202 |
+
});
|
203 |
+
}
|
204 |
+
}
|
205 |
+
},
|
206 |
+
{
|
207 |
+
name: 'clipbadge',
|
208 |
+
hooks: {
|
209 |
+
/**
|
210 |
+
* Adds copy-to-clipboard badges to code blocks and tables.
|
211 |
+
* @param {HTMLElement} el - The message element.
|
212 |
+
* @param {import('../components/chatlog.js').Message} message - The message object.
|
213 |
+
*/
|
214 |
+
onRenderMessage: function (el, message) {
|
215 |
+
log(5, 'formattingPlugins: clipbadge onRenderMessage called');
|
216 |
+
el.classList.add('hljs-nobg', 'hljs-message');
|
217 |
+
el.dataset.plaintext = encodeURIComponent(message.value.content.trim());
|
218 |
+
const tableToCSV = (table) => {
|
219 |
+
const separator = ';';
|
220 |
+
const rows = table.querySelectorAll('tr');
|
221 |
+
return Array.from(rows).map(row =>
|
222 |
+
Array.from(row.querySelectorAll('td, th')).map(col =>
|
223 |
+
`"${col.innerText.replace(/(\r\n|\n|\r)/gm, '').replace(/(\s\s)/gm, ' ').replace(/"/g, '""')}"`
|
224 |
+
).join(separator)
|
225 |
+
).join('\n');
|
226 |
+
};
|
227 |
+
el.querySelectorAll('table').forEach(table => {
|
228 |
+
const div = document.createElement('div');
|
229 |
+
div.classList.add('hljs-nobg', 'hljs-table', 'language-table');
|
230 |
+
div.dataset.plaintext = encodeURIComponent(tableToCSV(table));
|
231 |
+
const pe = table.parentElement;
|
232 |
+
pe.insertBefore(div, table);
|
233 |
+
div.appendChild(table);
|
234 |
+
});
|
235 |
+
const clipBadge = new ClipBadge({ autoRun: false });
|
236 |
+
clipBadge.addTo(el);
|
237 |
+
}
|
238 |
+
}
|
239 |
+
}
|
240 |
+
];
|
241 |
+
/**
|
242 |
+
* @class ClipBadge
|
243 |
+
* Provides copy-to-clipboard badges for code blocks and tables.
|
244 |
+
* It is a heavily modified version of this:
|
245 |
+
* https://unpkg.com/highlightjs-badge@0.1.9/highlightjs-badge.js
|
246 |
+
*
|
247 |
+
* Use like this:
|
248 |
+
*
|
249 |
+
* const cb = new ClipBadge({
|
250 |
+
* templateSelector: '#my-badge-template',
|
251 |
+
* contentSelector: '#my-clip-snippets',
|
252 |
+
* autoRun: true,
|
253 |
+
* copyIconClass: 'fa fa-copy',
|
254 |
+
* copyIconContent: ' Copy',
|
255 |
+
* checkIconClass: 'fa fa-check text-success',
|
256 |
+
* checkIconContent: ' Copied!',
|
257 |
+
* onBeforeCodeCopied: (text, code) => {
|
258 |
+
* // Modify the text or code element before copying
|
259 |
+
* return text;
|
260 |
+
* },
|
261 |
+
* codeButtonContent: 'Code',
|
262 |
+
* imageButtonContent: 'Image'
|
263 |
+
* });
|
264 |
+
*/
|
265 |
+
class ClipBadge {
|
266 |
+
/**
|
267 |
+
* @param {Object} [options={}] - The options for the clip badge.
|
268 |
+
*/
|
269 |
+
constructor(options = {}) {
|
270 |
+
this.settings = { ...this.defaults, ...options };
|
271 |
+
log(5, 'ClipBadge: Constructor called with options', options);
|
272 |
+
if (document.readyState === 'loading') {
|
273 |
+
document.addEventListener('DOMContentLoaded', () => this.init());
|
274 |
+
} else {
|
275 |
+
this.init();
|
276 |
+
}
|
277 |
+
}
|
278 |
+
defaults = {
|
279 |
+
templateSelector: '#clip-badge-template',
|
280 |
+
contentSelector: 'body',
|
281 |
+
autoRun: true,
|
282 |
+
copyIconClass: '',
|
283 |
+
copyIconContent: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2 4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4h4a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2v-4H4a2 2 0 0 1-2-2V4zm8 12v4h10V10h-4v4a2 2 0 0 1-2 2h-4zm4-2V4H4v10h10z" fill="currentColor"/></svg>',
|
284 |
+
checkIconClass: 'text-success',
|
285 |
+
checkIconContent: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M20.664 5.253a1 1 0 0 1 .083 1.411l-10.666 12a1 1 0 0 1-1.495 0l-5.333-6a1 1 0 0 1 1.494-1.328l4.586 5.159 9.92-11.16a1 1 0 0 1 1.411-.082z" fill="currentColor"/></svg> Copied!',
|
286 |
+
onBeforeCodeCopied: null,
|
287 |
+
codeButtonContent: 'Code',
|
288 |
+
imageButtonContent: 'Image'
|
289 |
+
};
|
290 |
+
/**
|
291 |
+
* Initializes the ClipBadge by appending styles and template.
|
292 |
+
*/
|
293 |
+
init() {
|
294 |
+
log(4, 'ClipBadge: init called');
|
295 |
+
const node = this.getTemplate();
|
296 |
+
document.head.appendChild(node.content.querySelector('style').cloneNode(true));
|
297 |
+
this.settings.template = node.content.querySelector('.clip-badge').cloneNode(true);
|
298 |
+
if (this.settings.autoRun) this.addAll();
|
299 |
+
}
|
300 |
+
/**
|
301 |
+
* Adds badges to all highlighted elements in the content selector.
|
302 |
+
*/
|
303 |
+
addAll() {
|
304 |
+
log(5, 'ClipBadge: addAll called');
|
305 |
+
const content = document.querySelector(this.settings.contentSelector);
|
306 |
+
content.querySelectorAll('.hljs, .hljs-nobg').forEach(el => this.addBadge(el));
|
307 |
+
}
|
308 |
+
/**
|
309 |
+
* Adds badges to highlighted elements within a specific container.
|
310 |
+
* @param {HTMLElement} container - The container to add badges to.
|
311 |
+
*/
|
312 |
+
addTo(container) {
|
313 |
+
log(5, 'ClipBadge: addTo called for container', container);
|
314 |
+
container.querySelectorAll('.hljs, .hljs-nobg').forEach(el => this.addBadge(el));
|
315 |
+
if (container.classList.contains('hljs') || container.classList.contains('hljs-nobg')) this.addBadge(container);
|
316 |
+
}
|
317 |
+
/**
|
318 |
+
* Adds a copy badge to a highlighted element.
|
319 |
+
* @param {HTMLElement} highlightEl - The element to add the badge to.
|
320 |
+
*/
|
321 |
+
addBadge(highlightEl) {
|
322 |
+
log(5, 'ClipBadge: addBadge called for element', highlightEl);
|
323 |
+
if (highlightEl.classList.contains('clip-badge-pre')) return;
|
324 |
+
highlightEl.classList.add('clip-badge-pre');
|
325 |
+
const badge = this.createBadgeElement(highlightEl);
|
326 |
+
highlightEl.insertAdjacentElement('afterbegin', badge);
|
327 |
+
}
|
328 |
+
/**
|
329 |
+
* Creates the badge element.
|
330 |
+
* @param {HTMLElement} highlightEl - The highlighted element.
|
331 |
+
* @returns {HTMLElement} The badge element.
|
332 |
+
*/
|
333 |
+
createBadgeElement(highlightEl) {
|
334 |
+
const plainText = decodeURIComponent(highlightEl.dataset.plaintext) || highlightEl.textContent;
|
335 |
+
let language = highlightEl.className.match(/\blanguage-(?<lang>[a-z0-9_-]+)\b/i)?.groups?.lang || 'unknown';
|
336 |
+
let svgText = '';
|
337 |
+
let htmlText = '';
|
338 |
+
if (language.toLowerCase() === 'svg' && plainText) {
|
339 |
+
svgText = highlightEl.innerHTML;
|
340 |
+
highlightEl.innerHTML = plainText;
|
341 |
+
} else if (language === 'table') {
|
342 |
+
language = '';
|
343 |
+
htmlText = highlightEl.innerHTML;
|
344 |
+
}
|
345 |
+
if (highlightEl.classList.contains('hljs-message')) {
|
346 |
+
language = '';
|
347 |
+
const right = highlightEl.querySelector('small > span > span.right');
|
348 |
+
if (right) {
|
349 |
+
language = right.textContent;
|
350 |
+
right.remove();
|
351 |
+
}
|
352 |
+
}
|
353 |
+
const badge = this.settings.template.cloneNode(true);
|
354 |
+
badge.classList.add('clip-badge');
|
355 |
+
badge.querySelector('.clip-badge-language').textContent = language;
|
356 |
+
if (svgText) {
|
357 |
+
this.handleSvg(badge, highlightEl, plainText, svgText);
|
358 |
+
}
|
359 |
+
this.handleCopy(badge, highlightEl, plainText, htmlText);
|
360 |
+
return badge;
|
361 |
+
}
|
362 |
+
/**
|
363 |
+
* Handles the logic for SVG code blocks.
|
364 |
+
* @param {HTMLElement} badge - The badge element.
|
365 |
+
* @param {HTMLElement} highlightEl - The highlighted element.
|
366 |
+
* @param {string} plainText - The plain text content.
|
367 |
+
* @param {string} svgText - The SVG text content.
|
368 |
+
*/
|
369 |
+
handleSvg(badge, highlightEl, plainText, svgText) {
|
370 |
+
const swapBtn = badge.querySelector('.clip-badge-swap');
|
371 |
+
swapBtn.classList.add('clip-badge-swap-enabled');
|
372 |
+
swapBtn.dataset.showing = 'html';
|
373 |
+
swapBtn.innerHTML = this.settings.codeButtonContent;
|
374 |
+
swapBtn.addEventListener('click', () => {
|
375 |
+
log(5, 'ClipBadge: Swap button clicked, current showing', swapBtn.dataset.showing);
|
376 |
+
if (swapBtn.dataset.showing === 'html') {
|
377 |
+
swapBtn.dataset.showing = 'text';
|
378 |
+
swapBtn.innerHTML = this.settings.imageButtonContent;
|
379 |
+
highlightEl.innerHTML = svgText;
|
380 |
+
} else {
|
381 |
+
swapBtn.dataset.showing = 'html';
|
382 |
+
swapBtn.innerHTML = this.settings.codeButtonContent;
|
383 |
+
highlightEl.innerHTML = plainText;
|
384 |
+
}
|
385 |
+
highlightEl.insertAdjacentElement('afterbegin', badge);
|
386 |
+
});
|
387 |
+
}
|
388 |
+
/**
|
389 |
+
* Handles the copy to clipboard logic.
|
390 |
+
* @param {HTMLElement} badge - The badge element.
|
391 |
+
* @param {HTMLElement} highlightEl - The highlighted element.
|
392 |
+
* @param {string} plainText - The plain text content.
|
393 |
+
* @param {string} htmlText - The HTML text content.
|
394 |
+
*/
|
395 |
+
handleCopy(badge, highlightEl, plainText, htmlText) {
|
396 |
+
const copyIcon = badge.querySelector('.clip-badge-copy-icon');
|
397 |
+
copyIcon.className = this.settings.copyIconClass;
|
398 |
+
copyIcon.classList.add('clip-badge-copy-icon');
|
399 |
+
copyIcon.innerHTML = this.settings.copyIconContent;
|
400 |
+
copyIcon.addEventListener('click', event => {
|
401 |
+
log(5, 'ClipBadge: Copy icon clicked');
|
402 |
+
event.preventDefault();
|
403 |
+
event.stopPropagation();
|
404 |
+
if (copyIcon.classList.contains('text-success')) return;
|
405 |
+
let textToCopy = plainText;
|
406 |
+
if (this.settings.onBeforeCodeCopied) {
|
407 |
+
textToCopy = this.settings.onBeforeCodeCopied(plainText, highlightEl);
|
408 |
+
}
|
409 |
+
const setCopied = () => {
|
410 |
+
copyIcon.className = this.settings.checkIconClass;
|
411 |
+
copyIcon.classList.add('clip-badge-copy-icon');
|
412 |
+
copyIcon.innerHTML = this.settings.checkIconContent;
|
413 |
+
setTimeout(() => {
|
414 |
+
copyIcon.className = this.settings.copyIconClass;
|
415 |
+
copyIcon.classList.add('clip-badge-copy-icon');
|
416 |
+
copyIcon.innerHTML = this.settings.copyIconContent;
|
417 |
+
}, 2000);
|
418 |
+
};
|
419 |
+
if (navigator.clipboard?.write) {
|
420 |
+
const clipboardData = { 'text/plain': new Blob([textToCopy], { type: 'text/plain' }) };
|
421 |
+
if (htmlText) clipboardData['text/html'] = new Blob([htmlText], { type: 'text/html' });
|
422 |
+
navigator.clipboard.write([new ClipboardItem(clipboardData)]).then(setCopied).catch(err => {
|
423 |
+
log(1, 'ClipBadge: Clipboard API failed', err);
|
424 |
+
triggerError('Clipboard API failed:', err);
|
425 |
+
});
|
426 |
+
} else {
|
427 |
+
const textArea = document.createElement('textarea');
|
428 |
+
textArea.value = textToCopy;
|
429 |
+
textArea.style.position = 'fixed';
|
430 |
+
textArea.style.top = '0';
|
431 |
+
textArea.style.left = '0';
|
432 |
+
document.body.appendChild(textArea);
|
433 |
+
textArea.focus();
|
434 |
+
textArea.select();
|
435 |
+
try {
|
436 |
+
if (document.execCommand('copy')) setCopied();
|
437 |
+
else {
|
438 |
+
log(1, 'ClipBadge: Fallback copy failed');
|
439 |
+
triggerError('Fallback copy failed.');
|
440 |
+
}
|
441 |
+
} catch (err) {
|
442 |
+
log(1, 'ClipBadge: Fallback copy error', err);
|
443 |
+
triggerError('Fallback copy error:', err);
|
444 |
+
}
|
445 |
+
document.body.removeChild(textArea);
|
446 |
+
}
|
447 |
+
});
|
448 |
+
}
|
449 |
+
/**
|
450 |
+
* Retrieves the badge template from the DOM or creates a default one.
|
451 |
+
* @returns {HTMLTemplateElement} The template element.
|
452 |
+
*/
|
453 |
+
getTemplate() {
|
454 |
+
log(5, 'ClipBadge: getTemplate called');
|
455 |
+
let node = document.querySelector(this.settings.templateSelector);
|
456 |
+
if (!node) {
|
457 |
+
node = document.createElement('template');
|
458 |
+
node.innerHTML = `
|
459 |
+
<style>
|
460 |
+
.clip-badge-pre { position: relative; }
|
461 |
+
@media print { .clip-badge { display: none; } }
|
462 |
+
.clip-badge {
|
463 |
+
display: flex;
|
464 |
+
flex-flow: row nowrap;
|
465 |
+
align-items: flex-start;
|
466 |
+
white-space: normal;
|
467 |
+
color: white;
|
468 |
+
font-size: 12px;
|
469 |
+
opacity: 0.3;
|
470 |
+
transition: opacity linear 0.4s;
|
471 |
+
position: absolute;
|
472 |
+
right: 0;
|
473 |
+
top: 0;
|
474 |
+
}
|
475 |
+
.hljs-message > .clip-badge { border-radius: 0 16px 0 7px; }
|
476 |
+
.clip-badge.active { opacity: 0.8; }
|
477 |
+
.clip-badge:hover { opacity: .95; }
|
478 |
+
.clip-badge a, .clip-badge a:hover { text-decoration: none; }
|
479 |
+
.clip-badge-language {
|
480 |
+
margin-right: 10px;
|
481 |
+
margin-top: 2px;
|
482 |
+
font-weight: 600;
|
483 |
+
color: goldenrod;
|
484 |
+
}
|
485 |
+
.hljs-message > div > div.clip-badge-language {
|
486 |
+
color: white;
|
487 |
+
font-weight: 200;
|
488 |
+
}
|
489 |
+
.clip-badge-copy-icon {
|
490 |
+
height: 1.2em;
|
491 |
+
font-size: 1em;
|
492 |
+
cursor: pointer;
|
493 |
+
padding: 5px 8px;
|
494 |
+
user-select: none;
|
495 |
+
background: #444;
|
496 |
+
border-radius: 0 5px 0 7px;
|
497 |
+
}
|
498 |
+
.hljs-message > div > div.clip-badge-copy-icon { border-radius: 0 16px 0 7px; }
|
499 |
+
.hljs-table > div > div.clip-badge-copy-icon { border-radius: 0 4px 0 7px; }
|
500 |
+
.clip-badge-copy-icon * { cursor: pointer; vertical-align: top; }
|
501 |
+
.text-success { color: limegreen !important; }
|
502 |
+
.clip-badge-swap {
|
503 |
+
cursor: pointer;
|
504 |
+
background: #444;
|
505 |
+
border-radius: 0 0 7px 7px;
|
506 |
+
padding: 0 7px 3px;
|
507 |
+
margin-right: 5px;
|
508 |
+
display: none;
|
509 |
+
}
|
510 |
+
.clip-badge-swap-enabled { display: block; }
|
511 |
+
</style>
|
512 |
+
<div class="clip-badge">
|
513 |
+
<div class="clip-badge-language"></div>
|
514 |
+
<div class="clip-badge-swap" title="Swap view"></div>
|
515 |
+
<div class="clip-badge-copy-icon" title="Copy to clipboard"></div>
|
516 |
+
</div>
|
517 |
+
`;
|
518 |
+
}
|
519 |
+
return node;
|
520 |
+
}
|
521 |
+
}
|
js/plugins/maximize-view.js
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
'use strict';
|
2 |
+
|
3 |
+
const maximizePlugin = {
|
4 |
+
name: 'maximize',
|
5 |
+
init: function(app) {
|
6 |
+
const maximizeButton = document.createElement('button');
|
7 |
+
maximizeButton.id = 'maximize-button';
|
8 |
+
maximizeButton.classList.add('toolButton');
|
9 |
+
maximizeButton.title = 'Maximize';
|
10 |
+
// SVG for maximize icon
|
11 |
+
maximizeButton.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 8V4h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M20 8V4h-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v4h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M20 16v4h-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
12 |
+
|
13 |
+
const tabButtons = document.getElementById('tab-buttons');
|
14 |
+
tabButtons.appendChild(maximizeButton);
|
15 |
+
|
16 |
+
maximizeButton.addEventListener('click', () => {
|
17 |
+
document.body.classList.toggle('maximized-view');
|
18 |
+
const isMaximized = document.body.classList.contains('maximized-view');
|
19 |
+
maximizeButton.title = isMaximized ? 'Restore' : 'Maximize';
|
20 |
+
// SVG for restore icon
|
21 |
+
maximizeButton.innerHTML = isMaximized
|
22 |
+
? `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 4v4H4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M16 4v4h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 20v-4H4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M16 20v-4h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`
|
23 |
+
: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 8V4h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M20 8V4h-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 16v4h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M20 16v4h-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
24 |
+
});
|
25 |
+
}
|
26 |
+
};
|
27 |
+
|
28 |
+
export { maximizePlugin };
|
js/plugins/mcp.js
ADDED
@@ -0,0 +1,453 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview Plugin for MCP (Model Context Protocol) integration.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { log } from '../utils/logger.js';
|
8 |
+
import { hooks } from '../hooks.js';
|
9 |
+
import { processToolCalls } from '../utils/shared.js';
|
10 |
+
|
11 |
+
|
12 |
+
/**
|
13 |
+
* The header for the tools section in the system prompt.
|
14 |
+
* @type {string}
|
15 |
+
*/
|
16 |
+
const toolsHeader = `### MCP Tools:
|
17 |
+
|
18 |
+
You can use tool calls. Make sure to follow the following XML-inspired format:
|
19 |
+
<dma:tool_call name="example_tool_name">
|
20 |
+
<parameter name="example_arg_name1">
|
21 |
+
example_arg_value1
|
22 |
+
</parameter>
|
23 |
+
<parameter name="example_arg_name2">
|
24 |
+
example_arg_value2
|
25 |
+
</parameter>
|
26 |
+
</dma:tool_call>
|
27 |
+
Do not escape any of the tool call arguments. The arguments will be parsed as normal text. There is one exception: If you need to write </dma:tool_call> or </parameter> as value inside a <parameter>, write it like <\/dma:tool_call> or <\/parameter>.
|
28 |
+
|
29 |
+
You can use multiple tools in one message, but either use tools or write an answer in a message. Use tools only if you need them.
|
30 |
+
|
31 |
+
IMPORTANT: Write files only if explicitely instructed to do so.
|
32 |
+
|
33 |
+
#### Available Tools:\n\n`;
|
34 |
+
|
35 |
+
/**
|
36 |
+
* @typedef {import('../hooks.js').Plugin} Plugin
|
37 |
+
*/
|
38 |
+
|
39 |
+
/**
|
40 |
+
* Plugin for MCP (Model Context Protocol) integration, handling tools and citations.
|
41 |
+
* @type {Plugin}
|
42 |
+
*/
|
43 |
+
export const mcpPlugin = {
|
44 |
+
name: 'mcp',
|
45 |
+
configService: null,
|
46 |
+
mcpUrl: null,
|
47 |
+
mcpSessionId: null,
|
48 |
+
cachedToolsSection: '',
|
49 |
+
isInitialized: false,
|
50 |
+
initPromise: null,
|
51 |
+
|
52 |
+
init: function(app) {
|
53 |
+
this.configService = app.configService;
|
54 |
+
|
55 |
+
// Bind all methods to this instance
|
56 |
+
Object.keys(this.hooks).forEach(hookName => {
|
57 |
+
this.hooks[hookName] = this.hooks[hookName].bind(this);
|
58 |
+
});
|
59 |
+
this.filterMcpCalls = this.filterMcpCalls.bind(this);
|
60 |
+
this.executeMcpCall = this.executeMcpCall.bind(this);
|
61 |
+
this.generateToolsSection = this.generateToolsSection.bind(this);
|
62 |
+
this.initMcpSession = this.initMcpSession.bind(this);
|
63 |
+
this.sendMcpRequest = this.sendMcpRequest.bind(this);
|
64 |
+
this.mcpJsonRpc = this.mcpJsonRpc.bind(this);
|
65 |
+
this.continueInit = this.continueInit.bind(this);
|
66 |
+
|
67 |
+
this.mcpUrl = this.configService.getItem('mcpServer');
|
68 |
+
|
69 |
+
if (!this.mcpUrl) {
|
70 |
+
// Attempt to get from the fetched /api/config
|
71 |
+
fetch('/api/config').then(response => response.json()).then(config => {
|
72 |
+
if (config.mcp_endpoint) {
|
73 |
+
this.mcpUrl = config.mcp_endpoint;
|
74 |
+
this.configService.setItem('mcpServer', this.mcpUrl);
|
75 |
+
this.continueInit();
|
76 |
+
}
|
77 |
+
}).catch(error => log(2, 'mcpPlugin: Could not fetch /api/config', error));
|
78 |
+
} else {
|
79 |
+
this.continueInit();
|
80 |
+
}
|
81 |
+
},
|
82 |
+
|
83 |
+
continueInit: function() {
|
84 |
+
if (!this.mcpUrl) return;
|
85 |
+
this.mcpSessionId = this.configService.getItem(`mcpSession_${this.mcpUrl}`) || null;
|
86 |
+
if (!this.cachedToolsSection) {
|
87 |
+
log(4, 'mcpPlugin: Pre-fetching tools from MCP', this.mcpUrl);
|
88 |
+
this.mcpJsonRpc('tools/list').then(response => {
|
89 |
+
const toolsArray = Array.isArray(response.tools) ? response.tools : [];
|
90 |
+
this.cachedToolsSection = this.generateToolsSection(toolsArray);
|
91 |
+
log(3, 'mcpPlugin: Tools section cached successfully');
|
92 |
+
}).catch(error => {
|
93 |
+
log(1, 'mcpPlugin: Failed to pre-fetch tools', error);
|
94 |
+
this.cachedToolsSection = '';
|
95 |
+
});
|
96 |
+
}
|
97 |
+
},
|
98 |
+
|
99 |
+
hooks: {
|
100 |
+
/**
|
101 |
+
* Renders a settings input for the MCP server URL.
|
102 |
+
* @param {HTMLElement} settingsEl - The settings element.
|
103 |
+
*/
|
104 |
+
onSettingsRender: function (settingsEl) {
|
105 |
+
log(5, 'mcpPlugin: onSettingsRender called');
|
106 |
+
if (settingsEl.querySelector('#mcpServer')) return;
|
107 |
+
const p = document.createElement('p');
|
108 |
+
const label = document.createElement('label');
|
109 |
+
label.style = 'margin-top: 16px; margin-bottom: 4px; margin-left: 4px; display: block;';
|
110 |
+
label.htmlFor = 'mcpServer';
|
111 |
+
label.textContent = 'MCP Server URL';
|
112 |
+
const input = document.createElement('input');
|
113 |
+
input.type = 'text';
|
114 |
+
input.id = 'mcpServer';
|
115 |
+
input.placeholder = 'e.g., http://localhost:3000/mcp';
|
116 |
+
input.value = this.configService.getItem('mcpServer', '');
|
117 |
+
input.addEventListener('input', () => this.configService.setItem('mcpServer', input.value));
|
118 |
+
p.appendChild(label);
|
119 |
+
p.appendChild(input);
|
120 |
+
settingsEl.appendChild(p);
|
121 |
+
},
|
122 |
+
/**
|
123 |
+
* Appends tool descriptions to the system prompt before API calls if MCP is configured.
|
124 |
+
* @param {Object} payload - The API payload.
|
125 |
+
* @param {import('../components/chatbox.js').ChatBox} chatbox - The ChatBox instance.
|
126 |
+
* @returns {Object} The modified payload.
|
127 |
+
*/
|
128 |
+
beforeApiCall: function (payload, chatbox) {
|
129 |
+
log(5, 'mcpPlugin: beforeApiCall called');
|
130 |
+
const mcpUrl = this.configService.getItem('mcpServer');
|
131 |
+
|
132 |
+
const systemMessage = chatbox.chatlog.getFirstMessage();
|
133 |
+
if (!systemMessage) return payload;
|
134 |
+
|
135 |
+
// Always remove the old tools section first
|
136 |
+
let content = systemMessage.value.content;
|
137 |
+
const originalContent = content;
|
138 |
+
content = content.replace(/\n\n\n--- MCP TOOLS ---\n[\s\S]*?\n--- END MCP TOOLS ---/g, ''); // Due to truncation, the ending \n is not always there.
|
139 |
+
|
140 |
+
// If MCP is configured and we have tools, add the new section
|
141 |
+
if (mcpUrl && this.cachedToolsSection) {
|
142 |
+
log(3, 'mcpPlugin: Adding tools section to system prompt');
|
143 |
+
content += '\n\n\n--- MCP TOOLS ---\n' + toolsHeader + this.cachedToolsSection + '\n--- END MCP TOOLS ---';
|
144 |
+
}
|
145 |
+
|
146 |
+
if (content !== originalContent) {
|
147 |
+
systemMessage.value.content = content;
|
148 |
+
systemMessage.cache = null;
|
149 |
+
chatbox.update();
|
150 |
+
}
|
151 |
+
|
152 |
+
return payload;
|
153 |
+
},
|
154 |
+
/**
|
155 |
+
* Processes completed assistant messages: parses tool calls, executes them via MCP,
|
156 |
+
* adds tool outputs to chatlog, and auto-continues the assistant response.
|
157 |
+
* @param {import('../components/chatlog.js').Message} message - The completed message.
|
158 |
+
* @param {import('../components/chatlog.js').Chatlog} chatlog - The chatlog.
|
159 |
+
* @param {import('../components/chatbox.js').ChatBox} chatbox - The ChatBox instance.
|
160 |
+
*/
|
161 |
+
onMessageComplete: async function (message, chatlog, chatbox) {
|
162 |
+
if (!message.value || message.value.role !== 'assistant' || message !== chatlog.getLastMessage()) {
|
163 |
+
return;
|
164 |
+
}
|
165 |
+
const context = { message, plugin: this }; // Pass message for metadata updates
|
166 |
+
await processToolCalls(message, chatlog, chatbox, this.filterMcpCalls, this.executeMcpCall, context);
|
167 |
+
},
|
168 |
+
/**
|
169 |
+
* Replaces citation XML tags with HTML superscript links.
|
170 |
+
* @param {HTMLElement} wrapper - The wrapper element containing the content.
|
171 |
+
* @param {import('../components/chatlog.js').Message} message - The message object.
|
172 |
+
*/
|
173 |
+
onPostFormatContent: function (wrapper, message) {
|
174 |
+
log(5, 'mcpPlugin: onPostFormatContent called');
|
175 |
+
wrapper.querySelectorAll('dma\\:render[type="render_inline_citation"]').forEach(node => {
|
176 |
+
const argNode = node.querySelector('argument[name="citation_id"]');
|
177 |
+
const id = argNode ? parseInt(argNode.textContent.trim()) : null;
|
178 |
+
if (!id) {
|
179 |
+
log(2, 'mcpPlugin: Invalid citation_id, removing node');
|
180 |
+
node.parentNode.removeChild(node);
|
181 |
+
return;
|
182 |
+
}
|
183 |
+
const source = message.metadata?.sources?.[id - 1];
|
184 |
+
const sup = document.createElement('sup');
|
185 |
+
const a = document.createElement('a');
|
186 |
+
if (source) {
|
187 |
+
a.href = source.url;
|
188 |
+
a.title = source.title || 'Source';
|
189 |
+
} else {
|
190 |
+
log(2, 'mcpPlugin: Citation not found for id', id);
|
191 |
+
a.title = 'Citation not found';
|
192 |
+
a.style.color = 'red';
|
193 |
+
}
|
194 |
+
a.textContent = `[${id}]`;
|
195 |
+
sup.appendChild(a);
|
196 |
+
node.parentNode.replaceChild(sup, node);
|
197 |
+
});
|
198 |
+
},
|
199 |
+
/**
|
200 |
+
* Clears the MCP server API endpoint.
|
201 |
+
* @param {HTMLElement} settingsEl - The settings element.
|
202 |
+
*/
|
203 |
+
onLogout: function (settingsEl) {
|
204 |
+
const mcpSettings = settingsEl.querySelector('#mcpServer');
|
205 |
+
if (!mcpSettings) return;
|
206 |
+
mcpSettings.value = '';
|
207 |
+
}
|
208 |
+
},
|
209 |
+
|
210 |
+
filterMcpCalls: function(call) {
|
211 |
+
// In this context, any non-agent call is considered an MCP call.
|
212 |
+
return !call.name.endsWith('_agent');
|
213 |
+
},
|
214 |
+
|
215 |
+
executeMcpCall: async function(call, context) {
|
216 |
+
const { message } = context;
|
217 |
+
log(4, 'mcpPlugin: Executing tool', call.name, 'with params', call.params);
|
218 |
+
try {
|
219 |
+
const result = await this.mcpJsonRpc('tools/call', { name: call.name, arguments: call.params });
|
220 |
+
|
221 |
+
// Add sources to metadata for certain tools
|
222 |
+
if (call.name === 'web_search' || call.name === 'browse_page' || call.name.startsWith('x_')) {
|
223 |
+
message.metadata = { ...message.metadata || {}, sources: result.sources || [] };
|
224 |
+
log(4, 'mcpPlugin: Added sources to metadata', result.sources?.length || 0);
|
225 |
+
}
|
226 |
+
|
227 |
+
let content = '';
|
228 |
+
let error = null;
|
229 |
+
|
230 |
+
if (result.isError) {
|
231 |
+
if (result.content && Array.isArray(result.content)) {
|
232 |
+
content = result.content.map(part => part.type === 'text' ? part.text : '').filter(t => t).join('\n');
|
233 |
+
}
|
234 |
+
error = content || 'Unknown error';
|
235 |
+
content = null;
|
236 |
+
} else {
|
237 |
+
if (result.content && Array.isArray(result.content)) {
|
238 |
+
content = result.content.map(part => part.type === 'text' ? part.text : '').filter(t => t).join('\n');
|
239 |
+
} else if (result.structuredContent) {
|
240 |
+
content = JSON.stringify(result.structuredContent);
|
241 |
+
} else {
|
242 |
+
content = JSON.stringify(result);
|
243 |
+
}
|
244 |
+
if (result.sources && result.sources.length > 0) {
|
245 |
+
content += '\n\nReferences:\n' + result.sources.map((s, i) => `[${i + 1}] ${s.title || 'Source'} - ${s.url}`).join('\n');
|
246 |
+
}
|
247 |
+
}
|
248 |
+
return { id: call.id, content, error };
|
249 |
+
} catch (err) {
|
250 |
+
log(1, 'mcpPlugin: Tool execution error', err);
|
251 |
+
return { id: call.id, content: null, error: err.message || 'Unknown error' };
|
252 |
+
}
|
253 |
+
},
|
254 |
+
|
255 |
+
/**
|
256 |
+
* Generates the tools Markdown section from a list of tools.
|
257 |
+
* @param {Object[]} tools - The list of tools.
|
258 |
+
* @returns {string} The Markdown section.
|
259 |
+
*/
|
260 |
+
generateToolsSection: function(tools) {
|
261 |
+
const sections = [];
|
262 |
+
tools.forEach((tool, idx) => {
|
263 |
+
const desc = tool.description || 'No description provided.';
|
264 |
+
const action = tool.name;
|
265 |
+
const displayName = action.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
266 |
+
let argsStr = '';
|
267 |
+
const properties = tool.inputSchema?.properties || {};
|
268 |
+
const requiredSet = new Set(tool.inputSchema?.required || []);
|
269 |
+
Object.entries(properties).forEach(([name, arg]) => {
|
270 |
+
const argDesc = arg.description || arg.title || 'No description.';
|
271 |
+
const argType = arg.type || 'unknown';
|
272 |
+
const required = requiredSet.has(name) ? '(required)' : '(optional)';
|
273 |
+
const defaultStr = arg.default !== undefined ? ` (default: ${JSON.stringify(arg.default)})` : '';
|
274 |
+
argsStr += ` - \`${name}\`: ${argDesc} (type: ${argType})${required}${defaultStr}\n`;
|
275 |
+
});
|
276 |
+
const section = `${idx + 1}. **${displayName}**\n - **Description**: ${desc}\n - **Action** (dma:tool_call name): \`${action}\`\n - **Arguments** (parameter name): \n${argsStr}`;
|
277 |
+
sections.push(section);
|
278 |
+
});
|
279 |
+
return sections.join('\n');
|
280 |
+
},
|
281 |
+
|
282 |
+
/**
|
283 |
+
* Initializes the MCP session.
|
284 |
+
*/
|
285 |
+
initMcpSession: async function() {
|
286 |
+
if (this.initPromise) return this.initPromise;
|
287 |
+
this.initPromise = (async () => {
|
288 |
+
if (this.isInitialized) return;
|
289 |
+
log(4, 'mcpPlugin: Initializing MCP session');
|
290 |
+
const initParams = {
|
291 |
+
protocolVersion: '2025-03-26', // Latest from spec; adjust if needed
|
292 |
+
capabilities: {
|
293 |
+
roots: { listChanged: false }, // Minimal; add more if client supports
|
294 |
+
sampling: {} // Declare if client uses sampling
|
295 |
+
},
|
296 |
+
clientInfo: {
|
297 |
+
name: 'AIFlowChatClient',
|
298 |
+
version: '1.0.0'
|
299 |
+
}
|
300 |
+
};
|
301 |
+
const initData = await this.sendMcpRequest('initialize', initParams, true); // No session for init
|
302 |
+
if (initData.protocolVersion !== '2025-03-26') {
|
303 |
+
throw new Error(`Protocol version mismatch: requested 2025-03-26, got ${initData.protocolVersion}`);
|
304 |
+
}
|
305 |
+
log(4, 'mcpPlugin: Negotiated capabilities', initData.capabilities);
|
306 |
+
// Check if session_id was set from header
|
307 |
+
if (!this.mcpSessionId) {
|
308 |
+
throw new Error('No session ID returned in initialize response header');
|
309 |
+
}
|
310 |
+
this.configService.setItem(`mcpSession_${this.mcpUrl}`, this.mcpSessionId);
|
311 |
+
// Send initialized notification
|
312 |
+
await this.sendMcpRequest('notifications/initialized', {}, false, true); // Notification: no id
|
313 |
+
this.isInitialized = true;
|
314 |
+
log(4, 'mcpPlugin: MCP session initialized', this.mcpSessionId);
|
315 |
+
})();
|
316 |
+
await this.initPromise;
|
317 |
+
this.initPromise = null; // Reset for future calls if needed
|
318 |
+
},
|
319 |
+
|
320 |
+
/**
|
321 |
+
* Sends a JSON-RPC request to the MCP server.
|
322 |
+
* @param {string} method - The JSON-RPC method.
|
323 |
+
* @param {Object} [params={}] - The JSON-RPC parameters.
|
324 |
+
* @param {boolean} [isInit=false] - Whether this is an initialization request.
|
325 |
+
* @param {boolean} [isNotification=false] - Whether this is a notification.
|
326 |
+
* @returns {Promise<Object|null>} The JSON-RPC result.
|
327 |
+
*/
|
328 |
+
sendMcpRequest: async function(method, params = {}, isInit = false, isNotification = false) {
|
329 |
+
const url = this.configService.getItem('mcpServer');
|
330 |
+
if (!url) throw new Error('No MCP server URL set');
|
331 |
+
const body = {
|
332 |
+
jsonrpc: '2.0',
|
333 |
+
method,
|
334 |
+
params
|
335 |
+
};
|
336 |
+
if (!isNotification) {
|
337 |
+
body.id = Math.floor(Math.random() * 1000000);
|
338 |
+
}
|
339 |
+
const headers = {
|
340 |
+
'Content-Type': 'application/json',
|
341 |
+
'Accept': 'application/json, text/event-stream'
|
342 |
+
};
|
343 |
+
if (this.mcpSessionId && !isInit) {
|
344 |
+
headers['mcp-session-id'] = this.mcpSessionId;
|
345 |
+
}
|
346 |
+
const controller = new AbortController();
|
347 |
+
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout
|
348 |
+
try {
|
349 |
+
const resp = await fetch(url, {
|
350 |
+
method: 'POST',
|
351 |
+
headers,
|
352 |
+
body: JSON.stringify(body),
|
353 |
+
signal: controller.signal
|
354 |
+
});
|
355 |
+
clearTimeout(timeoutId);
|
356 |
+
if (!resp.ok) {
|
357 |
+
const errorText = await resp.text();
|
358 |
+
log(1, 'mcpPlugin: MCP response not ok', resp.status, resp.statusText, errorText);
|
359 |
+
throw new Error(`MCP error: ${resp.statusText} - ${errorText}`);
|
360 |
+
}
|
361 |
+
const respHeaders = Object.fromEntries(resp.headers.entries());
|
362 |
+
log(5, 'mcpPlugin: Full response headers', respHeaders);
|
363 |
+
// Handle session_id from header if in response
|
364 |
+
const headerSession = resp.headers.get('mcp-session-id');
|
365 |
+
log(5, 'mcpPlugin: Checked mcp-session-id header', headerSession);
|
366 |
+
if (headerSession) {
|
367 |
+
this.mcpSessionId = headerSession;
|
368 |
+
this.configService.setItem(`mcpSession_${this.mcpUrl}`, this.mcpSessionId);
|
369 |
+
}
|
370 |
+
if (isNotification) {
|
371 |
+
// Notifications do not expect a response body or result
|
372 |
+
return null;
|
373 |
+
}
|
374 |
+
const contentType = resp.headers.get('Content-Type') || '';
|
375 |
+
if (contentType.includes('application/json')) {
|
376 |
+
const data = await resp.json();
|
377 |
+
if (data.error) {
|
378 |
+
log(1, 'mcpPlugin: MCP JSON-RPC error', data.error);
|
379 |
+
throw new Error(data.error.message || 'MCP call failed');
|
380 |
+
}
|
381 |
+
return data.result;
|
382 |
+
} else if (contentType.includes('text/event-stream')) {
|
383 |
+
// Parse SSE: Collect data from 'message' events
|
384 |
+
const reader = resp.body.getReader();
|
385 |
+
let buffer = '';
|
386 |
+
let result = null;
|
387 |
+
while (true) {
|
388 |
+
const { done, value } = await reader.read();
|
389 |
+
if (done) break;
|
390 |
+
buffer += new TextDecoder().decode(value);
|
391 |
+
const lines = buffer.split('\n');
|
392 |
+
buffer = lines.pop(); // Last incomplete line
|
393 |
+
for (const line of lines) {
|
394 |
+
if (line.startsWith('event: message')) {
|
395 |
+
// Next line should be data:
|
396 |
+
} else if (line.startsWith('data: ')) {
|
397 |
+
const dataStr = line.slice(6);
|
398 |
+
try {
|
399 |
+
const partial = JSON.parse(dataStr);
|
400 |
+
if (partial.jsonrpc) {
|
401 |
+
result = partial.result; // Assume last message has full result
|
402 |
+
}
|
403 |
+
} catch {} // Ignore partial JSON
|
404 |
+
}
|
405 |
+
}
|
406 |
+
}
|
407 |
+
if (result) return result;
|
408 |
+
throw new Error('Invalid SSE response: No valid JSON-RPC result');
|
409 |
+
} else {
|
410 |
+
throw new Error(`Unexpected Content-Type: ${contentType}`);
|
411 |
+
}
|
412 |
+
} catch (error) {
|
413 |
+
clearTimeout(timeoutId);
|
414 |
+
if (error.name === 'AbortError') {
|
415 |
+
throw new Error('MCP request timed out');
|
416 |
+
}
|
417 |
+
throw error;
|
418 |
+
}
|
419 |
+
},
|
420 |
+
|
421 |
+
/**
|
422 |
+
* Performs a JSON-RPC call to the MCP server.
|
423 |
+
* @param {string} method - The JSON-RPC method.
|
424 |
+
* @param {Object} [params={}] - The JSON-RPC parameters.
|
425 |
+
* @param {boolean} [retry=false] - Whether to retry on session errors.
|
426 |
+
* @returns {Promise<Object>} The JSON-RPC result.
|
427 |
+
*/
|
428 |
+
mcpJsonRpc: async function(method, params = {}, retry = false) {
|
429 |
+
log(5, 'mcpPlugin: mcpJsonRpc called with method', method, 'params', params);
|
430 |
+
try {
|
431 |
+
await this.initMcpSession(); // Ensure lifecycle is complete
|
432 |
+
const result = await this.sendMcpRequest(method, params);
|
433 |
+
log(4, 'mcpPlugin: MCP JSON-RPC success', result);
|
434 |
+
return result;
|
435 |
+
} catch (error) {
|
436 |
+
log(1, 'mcpPlugin: MCP JSON-RPC failure', error);
|
437 |
+
// Handle session-related errors by resetting and retrying once
|
438 |
+
if (error.message.includes('Missing session ID') || error.message.includes('No valid session ID') || error.message.includes('Invalid session ID')) {
|
439 |
+
this.configService.removeItem(`mcpSession_${this.mcpUrl}`);
|
440 |
+
this.mcpSessionId = null;
|
441 |
+
this.isInitialized = false;
|
442 |
+
if (!retry) {
|
443 |
+
log(3, 'mcpPlugin: Retrying MCP call after session re-init');
|
444 |
+
return this.mcpJsonRpc(method, params, true);
|
445 |
+
}
|
446 |
+
}
|
447 |
+
throw new AggregateError(
|
448 |
+
[error],
|
449 |
+
`Failed to perform MCP JSON-RPC call.\nURL: ${this.mcpUrl}, Method: ${method}, Params: ${JSON.stringify(params)}.\nOriginal error: ${error.message || 'Unknown'}.`
|
450 |
+
);
|
451 |
+
}
|
452 |
+
}
|
453 |
+
};
|
js/plugins/model-params.js
ADDED
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
'use strict';
|
2 |
+
|
3 |
+
import { log } from '../utils/logger.js';
|
4 |
+
import { hooks } from '../hooks.js';
|
5 |
+
|
6 |
+
// Helper function to get the correct storage for settings
|
7 |
+
function getStorage(configService, chatService, chatId, agentId) {
|
8 |
+
if (agentId) {
|
9 |
+
const chat = chatService.chats.find(c => c.id === chatId);
|
10 |
+
const agent = chat?.agents?.find(a => a.id === agentId);
|
11 |
+
if (agent) {
|
12 |
+
if (!agent.modelSettings) agent.modelSettings = {};
|
13 |
+
return agent.modelSettings;
|
14 |
+
}
|
15 |
+
}
|
16 |
+
if (chatId) {
|
17 |
+
const chat = chatService.chats.find(c => c.id === chatId);
|
18 |
+
if (chat) {
|
19 |
+
if (!chat.modelSettings) chat.modelSettings = {};
|
20 |
+
return chat.modelSettings;
|
21 |
+
}
|
22 |
+
}
|
23 |
+
// For global settings, return the configService itself.
|
24 |
+
return configService;
|
25 |
+
}
|
26 |
+
|
27 |
+
function populateModelList(fieldset, idSuffix, currentModel, configService) {
|
28 |
+
// Clear existing model radio buttons, but keep the legend
|
29 |
+
const legend = fieldset.querySelector('legend');
|
30 |
+
fieldset.innerHTML = '';
|
31 |
+
if (legend) {
|
32 |
+
fieldset.appendChild(legend);
|
33 |
+
}
|
34 |
+
|
35 |
+
const models = JSON.parse(configService.getItem('models', '[]'));
|
36 |
+
models.forEach(model => {
|
37 |
+
const radio = document.createElement('input');
|
38 |
+
radio.type = 'radio';
|
39 |
+
radio.id = `${model.id}${idSuffix}`;
|
40 |
+
radio.name = `model${idSuffix}`;
|
41 |
+
radio.value = model.id;
|
42 |
+
const label = document.createElement('label');
|
43 |
+
label.htmlFor = `${model.id}${idSuffix}`;
|
44 |
+
label.textContent = model.id;
|
45 |
+
const modelRow = document.createElement('div');
|
46 |
+
modelRow.classList.add('model-list-row');
|
47 |
+
modelRow.appendChild(radio);
|
48 |
+
modelRow.appendChild(label);
|
49 |
+
fieldset.appendChild(modelRow);
|
50 |
+
});
|
51 |
+
|
52 |
+
if (models.length > 0 && currentModel) {
|
53 |
+
const modelRadio = fieldset.querySelector(`input[value="${currentModel}"]`);
|
54 |
+
if (modelRadio) modelRadio.checked = true;
|
55 |
+
}
|
56 |
+
}
|
57 |
+
|
58 |
+
|
59 |
+
export const modelParamsPlugin = {
|
60 |
+
id: 'model-params',
|
61 |
+
name: 'Model Parameters',
|
62 |
+
description: 'Manages model parameters (model, temperature, top_p, seed) and their UI.',
|
63 |
+
init: function(app) {
|
64 |
+
this.app = app;
|
65 |
+
this.configService = app.configService;
|
66 |
+
this.chatService = app.chatService;
|
67 |
+
|
68 |
+
// Bind 'this' for hooks that need access to the plugin instance
|
69 |
+
this.hooks.onModelSettingsRender = this.hooks.onModelSettingsRender.bind(this);
|
70 |
+
this.hooks.onModelSettingsChanged = this.hooks.onModelSettingsChanged.bind(this);
|
71 |
+
this.hooks.onGetModelSettings = this.hooks.onGetModelSettings.bind(this);
|
72 |
+
this.hooks.onUpdateModelSettings = this.hooks.onUpdateModelSettings.bind(this);
|
73 |
+
},
|
74 |
+
hooks: {
|
75 |
+
onModelSettingsRender: function(modelSettingsEl, modelSettings, chatId, agentId) {
|
76 |
+
const idSuffix = chatId ? `_${chatId}` : (agentId ? `_${agentId}` : '_global');
|
77 |
+
const containerId = `model-params-container${idSuffix}`;
|
78 |
+
|
79 |
+
// If the container already exists, just update the model list.
|
80 |
+
if (document.getElementById(containerId)) {
|
81 |
+
const fieldset = document.getElementById(`modelsFieldset${idSuffix}`);
|
82 |
+
if (fieldset) {
|
83 |
+
populateModelList(fieldset, idSuffix, modelSettings.model, this.configService);
|
84 |
+
}
|
85 |
+
return;
|
86 |
+
}
|
87 |
+
|
88 |
+
const container = document.createElement('div');
|
89 |
+
container.id = containerId;
|
90 |
+
if (agentId) {
|
91 |
+
container.classList.add('agent-model-settings-group');
|
92 |
+
}
|
93 |
+
container.innerHTML = `
|
94 |
+
<fieldset id="modelsFieldset${idSuffix}" class="model-list-fieldset">
|
95 |
+
<legend>Model <button id="refreshModelsButton${idSuffix}" title="Refresh models" class="toolButton small"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z" fill="currentColor"/></svg></button></legend>
|
96 |
+
</fieldset>
|
97 |
+
<p>
|
98 |
+
<label for="temperature${idSuffix}">Temperature</label>
|
99 |
+
<button id="resetTemperature${idSuffix}" class="toolButton small" title="Reset temperature">↺</button><br>
|
100 |
+
<input type="range" id="temperature${idSuffix}" min="0" max="2.0" step="0.1">
|
101 |
+
<span id="temperatureValue${idSuffix}"></span>
|
102 |
+
</p>
|
103 |
+
<p>
|
104 |
+
<label for="topP${idSuffix}">Top-p (nucleus sampling)</label>
|
105 |
+
<button id="resetTopP${idSuffix}" class="toolButton small" title="Reset Top-p">↺</button><br>
|
106 |
+
<input type="range" id="topP${idSuffix}" min="0" max="1.0" step="0.05">
|
107 |
+
<span id="topPValue${idSuffix}"></span>
|
108 |
+
</p>
|
109 |
+
<p>
|
110 |
+
<label for="seed${idSuffix}">Seed</label>
|
111 |
+
<button id="resetSeed${idSuffix}" class="toolButton small" title="Reset seed">↺</button><br>
|
112 |
+
<input type="range" id="seed${idSuffix}" min="0" max="999999999" step="1">
|
113 |
+
<span id="seedValue${idSuffix}"></span>
|
114 |
+
</p>
|
115 |
+
`;
|
116 |
+
modelSettingsEl.appendChild(container);
|
117 |
+
|
118 |
+
const ui = {
|
119 |
+
modelsFieldset: document.getElementById(`modelsFieldset${idSuffix}`),
|
120 |
+
refreshModelsButton: document.getElementById(`refreshModelsButton${idSuffix}`),
|
121 |
+
temperatureEl: document.getElementById(`temperature${idSuffix}`),
|
122 |
+
temperatureValue: document.getElementById(`temperatureValue${idSuffix}`),
|
123 |
+
topPEl: document.getElementById(`topP${idSuffix}`),
|
124 |
+
topPValue: document.getElementById(`topPValue${idSuffix}`),
|
125 |
+
seedEl: document.getElementById(`seed${idSuffix}`),
|
126 |
+
seedValue: document.getElementById(`seedValue${idSuffix}`),
|
127 |
+
resetTemperatureBtn: document.getElementById(`resetTemperature${idSuffix}`),
|
128 |
+
resetTopPBtn: document.getElementById(`resetTopP${idSuffix}`),
|
129 |
+
resetSeedBtn: document.getElementById(`resetSeed${idSuffix}`),
|
130 |
+
};
|
131 |
+
|
132 |
+
// Set initial values
|
133 |
+
ui.temperatureEl.value = modelSettings.temperature ?? 0.5;
|
134 |
+
ui.topPEl.value = modelSettings.top_p ?? 1.0;
|
135 |
+
ui.seedEl.value = modelSettings.seed ?? 0;
|
136 |
+
|
137 |
+
const updateSliderValues = () => {
|
138 |
+
ui.temperatureValue.textContent = ui.temperatureEl.value;
|
139 |
+
ui.topPValue.textContent = ui.topPEl.value;
|
140 |
+
ui.seedValue.textContent = ui.seedEl.value === '0' ? 'off' : ui.seedEl.value;
|
141 |
+
};
|
142 |
+
|
143 |
+
updateSliderValues();
|
144 |
+
|
145 |
+
populateModelList(ui.modelsFieldset, idSuffix, modelSettings.model, this.configService);
|
146 |
+
|
147 |
+
// Event Listeners
|
148 |
+
const changeHandler = () => {
|
149 |
+
const updatedSettings = {
|
150 |
+
model: ui.modelsFieldset.querySelector(`input[name="model${idSuffix}"]:checked`)?.value,
|
151 |
+
temperature: parseFloat(ui.temperatureEl.value),
|
152 |
+
top_p: parseFloat(ui.topPEl.value),
|
153 |
+
seed: ui.seedEl.value,
|
154 |
+
};
|
155 |
+
hooks.onModelSettingsChanged.forEach(fn => fn(modelSettingsEl, updatedSettings, chatId, agentId));
|
156 |
+
};
|
157 |
+
|
158 |
+
ui.modelsFieldset.addEventListener('change', changeHandler);
|
159 |
+
ui.temperatureEl.addEventListener('input', () => { updateSliderValues(); changeHandler(); });
|
160 |
+
ui.topPEl.addEventListener('input', () => { updateSliderValues(); changeHandler(); });
|
161 |
+
ui.seedEl.addEventListener('input', () => { updateSliderValues(); changeHandler(); });
|
162 |
+
|
163 |
+
ui.resetTemperatureBtn.addEventListener('click', () => { ui.temperatureEl.value = 0.5; updateSliderValues(); changeHandler(); });
|
164 |
+
ui.resetTopPBtn.addEventListener('click', () => { ui.topPEl.value = 1.0; updateSliderValues(); changeHandler(); });
|
165 |
+
ui.resetSeedBtn.addEventListener('click', () => { ui.seedEl.value = 0; updateSliderValues(); changeHandler(); });
|
166 |
+
|
167 |
+
// The global settings panel has the main refresh button
|
168 |
+
if (!chatId && !agentId) {
|
169 |
+
ui.refreshModelsButton.addEventListener('click', () => this.app.loadModels());
|
170 |
+
} else {
|
171 |
+
ui.refreshModelsButton.style.display = 'none';
|
172 |
+
}
|
173 |
+
},
|
174 |
+
|
175 |
+
onModelSettings: (payload, modelSettings) => {
|
176 |
+
if (modelSettings.model) payload.model = modelSettings.model;
|
177 |
+
if (modelSettings.temperature != null) payload.temperature = modelSettings.temperature;
|
178 |
+
if (modelSettings.top_p != null) payload.top_p = modelSettings.top_p;
|
179 |
+
if (modelSettings.seed && modelSettings.seed !== '0') {
|
180 |
+
payload.seed = parseInt(modelSettings.seed, 10);
|
181 |
+
}
|
182 |
+
},
|
183 |
+
|
184 |
+
onGetModelSettings: function(settings) {
|
185 |
+
settings.model = this.configService.getItem('model', 1.0);
|
186 |
+
settings.temperature = parseFloat(this.configService.getItem('temperature', 0.5));
|
187 |
+
settings.top_p = parseFloat(this.configService.getItem('top_p', 1.0));
|
188 |
+
settings.seed = this.configService.getItem('seed', '0');
|
189 |
+
},
|
190 |
+
|
191 |
+
onUpdateModelSettings: function(key, value) {
|
192 |
+
if (key === 'model' || key === 'temperature' || key === 'top_p' || key === 'seed') {
|
193 |
+
this.configService.setItem(key, value);
|
194 |
+
}
|
195 |
+
},
|
196 |
+
|
197 |
+
onModelSettingsExport: (modelSettingsExport, modelSettings) => {
|
198 |
+
if (modelSettings.model) modelSettingsExport.model = modelSettings.model;
|
199 |
+
if (modelSettings.temperature != null) modelSettingsExport.temperature = modelSettings.temperature;
|
200 |
+
if (modelSettings.top_p != null) modelSettingsExport.top_p = modelSettings.top_p;
|
201 |
+
if (modelSettings.seed) modelSettingsExport.seed = modelSettings.seed;
|
202 |
+
},
|
203 |
+
|
204 |
+
onModelSettingsImport: (modelSettingsImport, modelSettings) => {
|
205 |
+
if (modelSettingsImport.model) modelSettings.model = modelSettingsImport.model;
|
206 |
+
if (modelSettingsImport.temperature != null) modelSettings.temperature = modelSettingsImport.temperature;
|
207 |
+
if (modelSettingsImport.top_p != null) modelSettings.top_p = modelSettingsImport.top_p;
|
208 |
+
if (modelSettingsImport.seed) modelSettings.seed = modelSettingsImport.seed;
|
209 |
+
},
|
210 |
+
|
211 |
+
onModelSettingsChanged: function(modelSettingsEl, updatedSettings, chatId, agentId) {
|
212 |
+
log(4, 'Model settings changed for', { chatId, agentId });
|
213 |
+
const storage = getStorage(this.configService, this.chatService, chatId, agentId);
|
214 |
+
|
215 |
+
if (chatId || agentId) {
|
216 |
+
// Chat or Agent scope: update properties directly on the object
|
217 |
+
Object.assign(storage, updatedSettings);
|
218 |
+
this.chatService.persistChats();
|
219 |
+
} else {
|
220 |
+
// Global scope: use the generic setItem on configService
|
221 |
+
Object.entries(updatedSettings).forEach(([key, value]) => {
|
222 |
+
if (value !== undefined) {
|
223 |
+
this.configService.setItem(key, value);
|
224 |
+
}
|
225 |
+
});
|
226 |
+
}
|
227 |
+
},
|
228 |
+
}
|
229 |
+
};
|
js/plugins/ui-controls.js
ADDED
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview A plugin for adding UI controls to messages.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { triggerError, log } from '../utils/logger.js';
|
8 |
+
import { createControlButton } from '../utils/ui.js';
|
9 |
+
import { resetEditing, addAlternativeToChat } from '../utils/chat.js';
|
10 |
+
import { hooks } from '../hooks.js';
|
11 |
+
|
12 |
+
/**
|
13 |
+
* @typedef {import('../components/chatbox.js').ChatBox} ChatBox
|
14 |
+
* @typedef {import('../components/chatlog.js').Chatlog} Chatlog
|
15 |
+
* @typedef {import('../components/chatlog.js').Message} Message
|
16 |
+
*/
|
17 |
+
|
18 |
+
/**
|
19 |
+
* Plugin to add navigation controls for alternative messages.
|
20 |
+
* @type {import('../hooks.js').Plugin}
|
21 |
+
*/
|
22 |
+
export const alternativeNavigationPlugin = {
|
23 |
+
name: 'alternativeNavigation',
|
24 |
+
hooks: {
|
25 |
+
/**
|
26 |
+
* Renders navigation controls for alternative messages.
|
27 |
+
* @param {HTMLElement} container - The container for the controls.
|
28 |
+
* @param {Message} message - The message object.
|
29 |
+
* @param {Chatlog} chatlog - The chatlog instance.
|
30 |
+
*/
|
31 |
+
onRenderMessageControls: function(container, message, chatlog) {
|
32 |
+
const alternatives = chatlog.findAlternativesForMessage(message);
|
33 |
+
if (!alternatives || alternatives.messages.length <= 1) return;
|
34 |
+
|
35 |
+
const prevBtn = createControlButton(
|
36 |
+
'Previous Message',
|
37 |
+
'<svg width="16" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 7.766c0-1.554-1.696-2.515-3.029-1.715l-7.056 4.234c-1.295.777-1.295 2.653 0 3.43l7.056 4.234c1.333.8 3.029-.16 3.029-1.715V7.766zM9.944 12L17 7.766v8.468L9.944 12zM6 6a1 1 0 0 1 1 1v10a1 1 0 1 1-2 0V7a1 1 0 0 1 1-1z" fill="currentColor"/></svg>',
|
38 |
+
() => chatlog.cycleAlternatives(message, 'prev')
|
39 |
+
);
|
40 |
+
|
41 |
+
const nextBtn = createControlButton(
|
42 |
+
'Next Message',
|
43 |
+
'<svg width="16" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 7.766c0-1.554 1.696-2.515 3.029-1.715l7.056 4.234c1.295.777 1.295 2.653 0 3.43L8.03 17.949c-1.333.8-3.029-.16-3.029-1.715V7.766zM14.056 12L7 7.766v8.468L14.056 12zM18 6a1 1 0 0 1 1 1v10a1 1 0 1 1-2 0V7a1 1 0 0 1 1-1z" fill="currentColor"/></svg>',
|
44 |
+
() => chatlog.cycleAlternatives(message, 'next')
|
45 |
+
);
|
46 |
+
|
47 |
+
const status = document.createElement('span');
|
48 |
+
status.innerHTML = ` ${alternatives.activeMessageIndex + 1}/${alternatives.messages.length} `;
|
49 |
+
|
50 |
+
const spacer = document.createElement('span');
|
51 |
+
spacer.innerHTML = ` `;
|
52 |
+
|
53 |
+
container.appendChild(spacer);
|
54 |
+
container.appendChild(prevBtn);
|
55 |
+
container.appendChild(status);
|
56 |
+
container.appendChild(nextBtn);
|
57 |
+
}
|
58 |
+
}
|
59 |
+
};
|
60 |
+
|
61 |
+
/**
|
62 |
+
* Plugin to add message modification controls (add, edit, delete).
|
63 |
+
* @type {import('../hooks.js').Plugin}
|
64 |
+
*/
|
65 |
+
export const messageModificationPlugin = {
|
66 |
+
name: 'messageModification',
|
67 |
+
hooks: {
|
68 |
+
/**
|
69 |
+
* Renders modification controls for a message.
|
70 |
+
* @param {HTMLElement} container - The container for the controls.
|
71 |
+
* @param {Message} message - The message object.
|
72 |
+
* @param {Chatlog} chatlog - The chatlog instance.
|
73 |
+
* @param {ChatBox} chatbox - The ChatBox instance.
|
74 |
+
*/
|
75 |
+
onRenderMessageControls: function(container, message, chatlog, chatbox) {
|
76 |
+
const store = chatbox.store;
|
77 |
+
const ui = store.get('ui');
|
78 |
+
const messageInput = ui.messageEl;
|
79 |
+
|
80 |
+
const addBtn = createControlButton(
|
81 |
+
'New Message',
|
82 |
+
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 4a1 1 0 0 1 1 1v6h6a1 1 0 1 1 0 2h-6v6a1 1 0 1 1-2 0v-6H5a1 1 0 1 1 0-2h6V5a1 1 0 0 1 1-1z" fill="currentColor"/></svg>',
|
83 |
+
() => {
|
84 |
+
log(4, 'Add button clicked for message', message);
|
85 |
+
resetEditing(store, chatlog, chatbox);
|
86 |
+
if (message.value.role === 'assistant') {
|
87 |
+
// Regenerate AI message
|
88 |
+
addAlternativeToChat(chatlog, message, { role: message.value.role, content: null });
|
89 |
+
hooks.onGenerateAIResponse.forEach(fn => fn({}, chatlog));
|
90 |
+
} else {
|
91 |
+
if (messageInput.value !== '' && messageInput.value !== message.value.content.trim()) {
|
92 |
+
triggerError("Chat input is not empty.");
|
93 |
+
return;
|
94 |
+
}
|
95 |
+
// Add a new editable alternative for user/system/tool messages with placeholder
|
96 |
+
const pos = chatlog.getMessagePos(message);
|
97 |
+
const originalContent = message.value.content;
|
98 |
+
addAlternativeToChat(chatlog, message, { role: message.value.role, content: null });
|
99 |
+
messageInput.value = originalContent ? originalContent.trim() : '';
|
100 |
+
messageInput.dispatchEvent(new Event('input', { bubbles: true }));
|
101 |
+
store.set('editingPos', pos);
|
102 |
+
const roleRadio = document.getElementById(message.value.role);
|
103 |
+
if (roleRadio) roleRadio.checked = true;
|
104 |
+
messageInput.focus();
|
105 |
+
}
|
106 |
+
}
|
107 |
+
);
|
108 |
+
|
109 |
+
const editBtn = createControlButton(
|
110 |
+
'Edit Message',
|
111 |
+
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" fill="currentColor"/></svg>',
|
112 |
+
() => {
|
113 |
+
log(4, 'Edit button clicked for message', message);
|
114 |
+
if (messageInput.value !== '' && messageInput.value !== message.value.content.trim()) {
|
115 |
+
triggerError("Chat input is not empty.");
|
116 |
+
return;
|
117 |
+
}
|
118 |
+
resetEditing(store, chatlog, chatbox);
|
119 |
+
messageInput.value = message.value.content.trim();
|
120 |
+
messageInput.dispatchEvent(new Event('input', { bubbles: true }));
|
121 |
+
|
122 |
+
const pos = chatlog.getMessagePos(message);
|
123 |
+
store.set('editingPos', pos);
|
124 |
+
|
125 |
+
const roleRadio = document.getElementById(message.value.role);
|
126 |
+
if (roleRadio) roleRadio.checked = true;
|
127 |
+
|
128 |
+
const alternatives = chatlog.findAlternativesForMessage(message);
|
129 |
+
message.cache = chatbox.formatMessage({ value: { role: message.value.role, content: '🤔...' } }, pos, alternatives.activeMessageIndex, alternatives.messages.length);
|
130 |
+
chatbox.update(false);
|
131 |
+
|
132 |
+
messageInput.focus();
|
133 |
+
}
|
134 |
+
);
|
135 |
+
|
136 |
+
const delBtn = createControlButton(
|
137 |
+
'Delete Message',
|
138 |
+
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 4a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v2h4a1 1 0 1 1 0 2h-1.069l-.867 12.142A2 2 0 0 1 17.069 22H6.93a2 2 0 0 1-1.995-1.858L4.07 8H3a1 1 0 0 1 0-2h4V4zm2 2h6V4H9v2zM6.074 8l.857 12H17.07l.857-12H6.074zM10 10a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-6a1 1 0 0 1 1-1zm4 0a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-6a1 1 0 0 1 1-1z" fill="currentColor"/></svg>',
|
139 |
+
() => {
|
140 |
+
log(4, 'Delete button clicked for message', message);
|
141 |
+
chatlog.deleteMessage(message);
|
142 |
+
}
|
143 |
+
);
|
144 |
+
|
145 |
+
const spacer = document.createElement('span');
|
146 |
+
spacer.innerHTML = ` `;
|
147 |
+
|
148 |
+
container.appendChild(spacer);
|
149 |
+
container.appendChild(addBtn);
|
150 |
+
container.appendChild(editBtn);
|
151 |
+
container.appendChild(delBtn);
|
152 |
+
}
|
153 |
+
}
|
154 |
+
};
|
js/services/api-service.js
ADDED
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview Service for handling all interactions with the OpenAI-compatible API.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { log, triggerError } from '../utils/logger.js';
|
8 |
+
|
9 |
+
/**
|
10 |
+
* @class ApiService
|
11 |
+
* Handles all interactions with the OpenAI-compatible API.
|
12 |
+
*/
|
13 |
+
class ApiService {
|
14 |
+
/**
|
15 |
+
* @param {import('../state/store.js').default} store - The application's state store.
|
16 |
+
*/
|
17 |
+
constructor(store) {
|
18 |
+
this.store = store;
|
19 |
+
}
|
20 |
+
|
21 |
+
/**
|
22 |
+
* Fetches the list of available models from the API.
|
23 |
+
* @param {string} endpoint - The API endpoint URL.
|
24 |
+
* @param {string} apiKey - The user's API key.
|
25 |
+
* @returns {Promise<Array<Object>>} A promise that resolves to an array of model objects.
|
26 |
+
*/
|
27 |
+
async getModels(endpoint, apiKey) {
|
28 |
+
log(3, 'ApiService: getModels called');
|
29 |
+
const modelsUrl = endpoint.replace(/\/chat\/completions$/, '/models');
|
30 |
+
try {
|
31 |
+
const headers = { 'Content-Type': 'application/json' };
|
32 |
+
if (apiKey) {
|
33 |
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
34 |
+
}
|
35 |
+
const resp = await fetch(modelsUrl, {
|
36 |
+
method: 'GET',
|
37 |
+
headers
|
38 |
+
});
|
39 |
+
if (!resp.ok) {
|
40 |
+
throw new Error(`Failed to fetch models: ${resp.statusText} (${resp.status})`);
|
41 |
+
}
|
42 |
+
const data = await resp.json();
|
43 |
+
const models = (data.data || []).sort((a, b) => a.id.localeCompare(b.id));
|
44 |
+
return models;
|
45 |
+
} catch (err) {
|
46 |
+
log(1, 'ApiService: Failed to load models', err);
|
47 |
+
triggerError(`Failed to load models: ${err.message}`);
|
48 |
+
throw err;
|
49 |
+
}
|
50 |
+
}
|
51 |
+
|
52 |
+
/**
|
53 |
+
* Streams the API response for a given payload.
|
54 |
+
* @param {Object} payload - The payload to send to the API.
|
55 |
+
* @param {string} endpoint - The API endpoint URL.
|
56 |
+
* @param {string} apiKey - The user's API key.
|
57 |
+
* @param {AbortSignal} abortSignal - The abort signal to cancel the request.
|
58 |
+
* @returns {Promise<ReadableStreamDefaultReader>} A promise that resolves to a stream reader.
|
59 |
+
*/
|
60 |
+
async streamAPIResponse(payload, endpoint, apiKey, abortSignal) {
|
61 |
+
log(4, 'ApiService: streamAPIResponse called with payload model', payload.model);
|
62 |
+
const headers = {
|
63 |
+
'Content-Type': 'application/json'
|
64 |
+
};
|
65 |
+
if (apiKey) {
|
66 |
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
67 |
+
}
|
68 |
+
|
69 |
+
const response = await fetch(endpoint, {
|
70 |
+
signal: abortSignal,
|
71 |
+
method: 'POST',
|
72 |
+
headers,
|
73 |
+
body: JSON.stringify(payload)
|
74 |
+
});
|
75 |
+
|
76 |
+
if (!response.ok) {
|
77 |
+
log(1, 'ApiService: API response not ok', response.status, response.statusText);
|
78 |
+
const errorBody = await response.text();
|
79 |
+
let errorMessage = `API error: ${response.statusText} (${response.status})`;
|
80 |
+
if (errorBody) {
|
81 |
+
try {
|
82 |
+
const errorJson = JSON.parse(errorBody);
|
83 |
+
if (errorJson.error && errorJson.error.message) {
|
84 |
+
errorMessage = errorJson.error.message;
|
85 |
+
}
|
86 |
+
} catch (e) {
|
87 |
+
// Not a json error, just use the text
|
88 |
+
errorMessage = errorBody;
|
89 |
+
}
|
90 |
+
}
|
91 |
+
if (response.status === 401) {
|
92 |
+
errorMessage = 'Invalid API key. Please check your settings.';
|
93 |
+
}
|
94 |
+
throw new Error(errorMessage);
|
95 |
+
}
|
96 |
+
|
97 |
+
return response.body.getReader();
|
98 |
+
}
|
99 |
+
}
|
100 |
+
|
101 |
+
export default ApiService;
|
js/services/chat-service.js
ADDED
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview Service for managing chat sessions.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { Chatlog, Alternatives } from '../components/chatlog.js';
|
8 |
+
import { firstPrompt } from '../config.js';
|
9 |
+
import { log, triggerError } from '../utils/logger.js';
|
10 |
+
import { getDatePrompt, resetEditing, addMessageToChat } from '../utils/chat.js';
|
11 |
+
import { hooks } from '../hooks.js';
|
12 |
+
|
13 |
+
/**
|
14 |
+
* @class ChatService
|
15 |
+
* Manages chat sessions, including creating, switching, loading, and persisting chats.
|
16 |
+
*/
|
17 |
+
class ChatService {
|
18 |
+
/**
|
19 |
+
* @param {import('../state/store.js').default} store - The application's state store.
|
20 |
+
*/
|
21 |
+
constructor(store, configService) {
|
22 |
+
this.store = store;
|
23 |
+
this.configService = configService;
|
24 |
+
this.chats = [];
|
25 |
+
this.currentChatId = null;
|
26 |
+
}
|
27 |
+
|
28 |
+
/**
|
29 |
+
* Initializes the chat service by loading chats from storage or creating a new one.
|
30 |
+
*/
|
31 |
+
init() {
|
32 |
+
this.loadChats();
|
33 |
+
const initialId = this.currentChatId || this.chats[0]?.id;
|
34 |
+
if (this.chats.length === 0) {
|
35 |
+
this.createNewChat();
|
36 |
+
} else {
|
37 |
+
this.switchChat(initialId);
|
38 |
+
}
|
39 |
+
|
40 |
+
this.store.subscribe('currentChat', (chat) => {
|
41 |
+
if (chat) {
|
42 |
+
const index = this.chats.findIndex(c => c.id === chat.id);
|
43 |
+
if (index !== -1) {
|
44 |
+
this.chats[index] = chat;
|
45 |
+
this.persistChats();
|
46 |
+
}
|
47 |
+
}
|
48 |
+
});
|
49 |
+
}
|
50 |
+
|
51 |
+
/**
|
52 |
+
* Creates a new chat session.
|
53 |
+
* @returns {Object} The new chat object.
|
54 |
+
*/
|
55 |
+
createNewChat() {
|
56 |
+
log(3, 'ChatService: createNewChat called');
|
57 |
+
const id = Date.now().toString();
|
58 |
+
const title = 'New Chat';
|
59 |
+
const chatlog = new Chatlog();
|
60 |
+
addMessageToChat(chatlog, { role: 'system', content: firstPrompt + getDatePrompt() });
|
61 |
+
const newChat = { id, title, chatlog, modelSettings: {}, agents: [], flow: { steps: [], connections: [] } };
|
62 |
+
this.chats.push(newChat);
|
63 |
+
this.store.set('chats', this.chats);
|
64 |
+
this.switchChat(id);
|
65 |
+
return newChat;
|
66 |
+
}
|
67 |
+
|
68 |
+
/**
|
69 |
+
* Switches the active chat session.
|
70 |
+
* @param {string} id - The ID of the chat to switch to.
|
71 |
+
*/
|
72 |
+
switchChat(id) {
|
73 |
+
log(3, 'ChatService: switchChat called for id', id);
|
74 |
+
if (this.currentChatId === id) return;
|
75 |
+
|
76 |
+
const ui = this.store.get('ui');
|
77 |
+
resetEditing(this.store, ui.chatBox.chatlog, ui.chatBox);
|
78 |
+
|
79 |
+
this.persistChats();
|
80 |
+
this.currentChatId = id;
|
81 |
+
const currentChat = this.chats.find(c => c.id === id);
|
82 |
+
this.store.set('currentChat', currentChat);
|
83 |
+
}
|
84 |
+
|
85 |
+
/**
|
86 |
+
* Deletes a chat session.
|
87 |
+
* @param {string} chatId - The ID of the chat to delete.
|
88 |
+
*/
|
89 |
+
deleteChat(chatId) {
|
90 |
+
log(4, 'ChatService: deleteChat called for', chatId);
|
91 |
+
this.chats = this.chats.filter(c => c.id !== chatId);
|
92 |
+
this.store.set('chats', this.chats);
|
93 |
+
|
94 |
+
if (this.currentChatId === chatId) {
|
95 |
+
if (this.chats.length > 0) {
|
96 |
+
this.switchChat(this.chats[0].id);
|
97 |
+
} else {
|
98 |
+
this.createNewChat();
|
99 |
+
}
|
100 |
+
}
|
101 |
+
this.persistChats();
|
102 |
+
}
|
103 |
+
|
104 |
+
/**
|
105 |
+
* Updates the title of a chat session.
|
106 |
+
* @param {string} chatId - The ID of the chat to update.
|
107 |
+
* @param {string} newTitle - The new title for the chat.
|
108 |
+
*/
|
109 |
+
updateChatTitle(chatId, newTitle) {
|
110 |
+
const chat = this.chats.find(c => c.id === chatId);
|
111 |
+
if (chat) {
|
112 |
+
chat.title = newTitle.trim() || 'Untitled Chat';
|
113 |
+
this.persistChats();
|
114 |
+
this.store.set('chats', [...this.chats]);
|
115 |
+
}
|
116 |
+
}
|
117 |
+
|
118 |
+
/**
|
119 |
+
* Persists all chat sessions to local storage.
|
120 |
+
*/
|
121 |
+
persistChats() {
|
122 |
+
log(5, 'ChatService: persistChats called');
|
123 |
+
const serializedChats = this.chats.map(c => {
|
124 |
+
const chatExport = {
|
125 |
+
id: c.id,
|
126 |
+
title: c.title,
|
127 |
+
data: c.chatlog.toJSON(),
|
128 |
+
modelSettings: {},
|
129 |
+
agents: (c.agents || []).map(agent => {
|
130 |
+
const agentExport = { ...agent, modelSettings: {} };
|
131 |
+
if (agent.modelSettings) {
|
132 |
+
hooks.onModelSettingsExport.forEach(fn => fn(agentExport.modelSettings, agent.modelSettings));
|
133 |
+
}
|
134 |
+
return agentExport;
|
135 |
+
}),
|
136 |
+
flow: c.flow || { steps: [], connections: [] },
|
137 |
+
};
|
138 |
+
if (c.modelSettings) {
|
139 |
+
hooks.onModelSettingsExport.forEach(fn => fn(chatExport.modelSettings, c.modelSettings));
|
140 |
+
}
|
141 |
+
return chatExport;
|
142 |
+
});
|
143 |
+
this.configService.setItem('chats', JSON.stringify(serializedChats));
|
144 |
+
this.configService.setItem('currentChatId', this.currentChatId);
|
145 |
+
}
|
146 |
+
|
147 |
+
/**
|
148 |
+
* Loads chat sessions from local storage.
|
149 |
+
*/
|
150 |
+
loadChats() {
|
151 |
+
log(3, 'ChatService: loadChats called');
|
152 |
+
const storedChats = this.configService.getItem('chats');
|
153 |
+
let migrated = false;
|
154 |
+
let legacyLoaded = false;
|
155 |
+
if (storedChats) {
|
156 |
+
const parsed = JSON.parse(storedChats);
|
157 |
+
this.chats = parsed.map(chatData => {
|
158 |
+
const chatlog = new Chatlog();
|
159 |
+
chatlog.load(chatData.data || null);
|
160 |
+
|
161 |
+
const modelSettings = {};
|
162 |
+
if (chatData.modelSettings) {
|
163 |
+
hooks.onModelSettingsImport.forEach(fn => fn(chatData.modelSettings, modelSettings));
|
164 |
+
}
|
165 |
+
|
166 |
+
const agents = (chatData.agents || []).map(agentData => {
|
167 |
+
const agentModelSettings = {};
|
168 |
+
if (agentData.modelSettings) {
|
169 |
+
hooks.onModelSettingsImport.forEach(fn => fn(agentData.modelSettings, agentModelSettings));
|
170 |
+
}
|
171 |
+
// Ensure useCustomModelSettings is carried over, default to false if not present
|
172 |
+
const useCustom = agentData.useCustomModelSettings || false;
|
173 |
+
return { ...agentData, modelSettings: agentModelSettings, useCustomModelSettings: useCustom };
|
174 |
+
});
|
175 |
+
|
176 |
+
const first = chatlog.getFirstMessage();
|
177 |
+
if (!first || first.value.role !== 'system') {
|
178 |
+
log(4, 'ChatService: Adding missing system prompt in loadChats');
|
179 |
+
const oldRoot = chatlog.rootAlternatives;
|
180 |
+
chatlog.rootAlternatives = new Alternatives();
|
181 |
+
const sysMsg = chatlog.rootAlternatives.addMessage({ role: 'system', content: firstPrompt + getDatePrompt() });
|
182 |
+
sysMsg.answerAlternatives = oldRoot;
|
183 |
+
}
|
184 |
+
const flow = chatData.flow || { steps: [], connections: [] };
|
185 |
+
if (!flow.connections) flow.connections = [];
|
186 |
+
return { id: chatData.id, title: chatData.title, chatlog, modelSettings, agents, flow };
|
187 |
+
});
|
188 |
+
} else {
|
189 |
+
const oldChatlog = this.configService.getItem('chatlog');
|
190 |
+
if (oldChatlog) {
|
191 |
+
log(3, 'ChatService: Loading legacy chatlog');
|
192 |
+
const parsed = JSON.parse(oldChatlog);
|
193 |
+
let rootData;
|
194 |
+
if (parsed.rootAlternatives) {
|
195 |
+
rootData = parsed.rootAlternatives;
|
196 |
+
} else {
|
197 |
+
const tempLog = new Chatlog();
|
198 |
+
parsed.forEach(msg => addMessageToChat(tempLog, msg));
|
199 |
+
rootData = tempLog.toJSON();
|
200 |
+
}
|
201 |
+
const chatlog = new Chatlog();
|
202 |
+
chatlog.load(rootData);
|
203 |
+
this.chats = [{ id: Date.now().toString(), title: 'Legacy Chat', chatlog }];
|
204 |
+
this.configService.removeItem('chatlog');
|
205 |
+
legacyLoaded = true;
|
206 |
+
} else {
|
207 |
+
this.chats = [];
|
208 |
+
}
|
209 |
+
}
|
210 |
+
if (migrated || legacyLoaded) {
|
211 |
+
log(3, 'ChatService: Persisting migrated/legacy chats');
|
212 |
+
this.persistChats();
|
213 |
+
}
|
214 |
+
this.currentChatId = this.configService.getItem('currentChatId');
|
215 |
+
// Set 'currentChat' in store if currentChatId is valid, to select the first chat on page load if available
|
216 |
+
let currentChat = null;
|
217 |
+
if (this.currentChatId) {
|
218 |
+
currentChat = this.chats.find(c => c.id === this.currentChatId);
|
219 |
+
}
|
220 |
+
if (currentChat) {
|
221 |
+
this.store.set('currentChat', currentChat);
|
222 |
+
} else {
|
223 |
+
// Invalid ID; clear it so init() can fall back to first chat
|
224 |
+
this.currentChatId = null;
|
225 |
+
}
|
226 |
+
this.store.set('chats', this.chats);
|
227 |
+
}
|
228 |
+
|
229 |
+
/**
|
230 |
+
* Imports a chat from a JSON file content.
|
231 |
+
* @param {string} fileContent - The content of the JSON file.
|
232 |
+
*/
|
233 |
+
importChat(fileContent) {
|
234 |
+
try {
|
235 |
+
let loaded = JSON.parse(fileContent);
|
236 |
+
let data = loaded.data;
|
237 |
+
if (!data && loaded.rootAlternatives) {
|
238 |
+
data = loaded.rootAlternatives;
|
239 |
+
} else if (!data && typeof loaded === 'object') {
|
240 |
+
data = loaded;
|
241 |
+
}
|
242 |
+
const chatlog = new Chatlog();
|
243 |
+
chatlog.load(data);
|
244 |
+
const id = Date.now().toString();
|
245 |
+
const title = loaded.title || 'Imported Chat';
|
246 |
+
|
247 |
+
const modelSettings = {};
|
248 |
+
if (loaded.modelSettings) {
|
249 |
+
hooks.onModelSettingsImport.forEach(fn => fn(loaded.modelSettings, modelSettings));
|
250 |
+
}
|
251 |
+
|
252 |
+
const agents = (loaded.agents || []).map(agentData => {
|
253 |
+
const agentModelSettings = {};
|
254 |
+
if (agentData.modelSettings) {
|
255 |
+
hooks.onModelSettingsImport.forEach(fn => fn(agentData.modelSettings, agentModelSettings));
|
256 |
+
}
|
257 |
+
const useCustom = agentData.useCustomModelSettings || false;
|
258 |
+
return { ...agentData, modelSettings: agentModelSettings, useCustomModelSettings: useCustom };
|
259 |
+
});
|
260 |
+
|
261 |
+
const flow = loaded.flow || { steps: [], connections: [] };
|
262 |
+
if (!flow.connections) flow.connections = [];
|
263 |
+
this.chats.push({ id, title, chatlog, modelSettings, agents, flow });
|
264 |
+
this.store.set('chats', this.chats);
|
265 |
+
this.switchChat(id);
|
266 |
+
this.persistChats();
|
267 |
+
} catch (error) {
|
268 |
+
log(1, 'ChatService: Invalid chatlog file', error);
|
269 |
+
triggerError('Invalid chatlog file. Failed to parse loaded chatlog:', error);
|
270 |
+
}
|
271 |
+
}
|
272 |
+
}
|
273 |
+
|
274 |
+
export default ChatService;
|
js/services/config-service.js
ADDED
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview Service for managing application configuration.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { defaultEndpoint } from '../config.js';
|
8 |
+
import { log } from '../utils/logger.js';
|
9 |
+
|
10 |
+
/**
|
11 |
+
* @class ConfigService
|
12 |
+
* Manages application configuration, such as API endpoint, key, and model.
|
13 |
+
*/
|
14 |
+
class ConfigService {
|
15 |
+
/**
|
16 |
+
* @param {import('../state/store.js').default} store - The application's state store.
|
17 |
+
*/
|
18 |
+
constructor(store) {
|
19 |
+
this.store = store;
|
20 |
+
}
|
21 |
+
|
22 |
+
/**
|
23 |
+
* Gets an item from localStorage.
|
24 |
+
* @param {string} key - The key of the item.
|
25 |
+
* @param {*} defaultValue - The default value if the item doesn't exist.
|
26 |
+
* @returns {*} The value from localStorage or the default value.
|
27 |
+
*/
|
28 |
+
getItem(key, defaultValue) {
|
29 |
+
const value = localStorage.getItem(`aiflow-chat_${key}`);
|
30 |
+
return value !== null ? value : defaultValue;
|
31 |
+
}
|
32 |
+
|
33 |
+
/**
|
34 |
+
* Sets an item in localStorage.
|
35 |
+
* @param {string} key - The key of the item.
|
36 |
+
* @param {*} value - The value to set.
|
37 |
+
*/
|
38 |
+
setItem(key, value) {
|
39 |
+
localStorage.setItem(`aiflow-chat_${key}`, value);
|
40 |
+
}
|
41 |
+
|
42 |
+
/**
|
43 |
+
* Removes an item from localStorage.
|
44 |
+
* @param {string} key - The key of the item to remove.
|
45 |
+
*/
|
46 |
+
removeItem(key) {
|
47 |
+
localStorage.removeItem(`aiflow-chat_${key}`);
|
48 |
+
}
|
49 |
+
|
50 |
+
/**
|
51 |
+
* Gets all model-related settings as an object by using hooks.
|
52 |
+
* @returns {Object} An object containing all model settings.
|
53 |
+
*/
|
54 |
+
getModelSettings() {
|
55 |
+
const settings = {};
|
56 |
+
hooks.onGetModelSettings.forEach(fn => fn(settings));
|
57 |
+
return settings;
|
58 |
+
}
|
59 |
+
|
60 |
+
/**
|
61 |
+
* Updates multiple model-related settings from an object by using hooks.
|
62 |
+
* @param {Object} settings - An object containing settings to update.
|
63 |
+
*/
|
64 |
+
updateModelSettings(settings) {
|
65 |
+
Object.entries(settings).forEach(([key, value]) => {
|
66 |
+
hooks.onUpdateModelSettings.forEach(fn => fn(key, value));
|
67 |
+
});
|
68 |
+
}
|
69 |
+
}
|
70 |
+
|
71 |
+
export default ConfigService;
|
js/state/store.js
ADDED
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview A simple reactive state store with a pub-sub mechanism.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { hooks } from '../hooks.js';
|
8 |
+
import { log } from '../utils/logger.js';
|
9 |
+
|
10 |
+
/**
|
11 |
+
* @class Store
|
12 |
+
* A simple reactive state store with a pub-sub mechanism for observing changes.
|
13 |
+
*/
|
14 |
+
class Store {
|
15 |
+
/**
|
16 |
+
* @param {Object} initialState - The initial state of the store.
|
17 |
+
*/
|
18 |
+
constructor(initialState) {
|
19 |
+
log(5, 'Store: Constructor called with initialState', initialState);
|
20 |
+
this.state = initialState;
|
21 |
+
this.subscribers = {};
|
22 |
+
}
|
23 |
+
|
24 |
+
/**
|
25 |
+
* Gets a value from the store by key.
|
26 |
+
* @param {string} key - The key of the value to get.
|
27 |
+
* @returns {*} The value associated with the key.
|
28 |
+
*/
|
29 |
+
get(key) {
|
30 |
+
log(5, 'Store: get called for key', key);
|
31 |
+
return this.state[key];
|
32 |
+
}
|
33 |
+
|
34 |
+
/**
|
35 |
+
* Gets a shallow copy of the entire state object.
|
36 |
+
* @returns {Object} A copy of the state.
|
37 |
+
*/
|
38 |
+
getState() {
|
39 |
+
log(5, 'Store: getState called');
|
40 |
+
return { ...this.state };
|
41 |
+
}
|
42 |
+
|
43 |
+
/**
|
44 |
+
* Sets a value in the store and notifies subscribers.
|
45 |
+
* @param {string} key - The key of the value to set.
|
46 |
+
* @param {*} value - The value to set.
|
47 |
+
*/
|
48 |
+
set(key, value) {
|
49 |
+
log(5, 'Store: set called for key', key, 'value', value);
|
50 |
+
this.state[key] = value;
|
51 |
+
if (this.subscribers[key]) {
|
52 |
+
this.subscribers[key].forEach(cb => cb(value));
|
53 |
+
}
|
54 |
+
hooks.onStateChange.forEach(fn => fn(key, value));
|
55 |
+
}
|
56 |
+
|
57 |
+
/**
|
58 |
+
* Subscribes to changes for a specific key.
|
59 |
+
* @param {string} key - The key to subscribe to.
|
60 |
+
* @param {Function} cb - The callback to execute when the value changes.
|
61 |
+
*/
|
62 |
+
subscribe(key, cb) {
|
63 |
+
log(5, 'Store: subscribe called for key', key);
|
64 |
+
if (!this.subscribers[key]) this.subscribers[key] = [];
|
65 |
+
this.subscribers[key].push(cb);
|
66 |
+
}
|
67 |
+
|
68 |
+
/**
|
69 |
+
* Unsubscribes from changes for a specific key.
|
70 |
+
* @param {string} key - The key to unsubscribe from.
|
71 |
+
* @param {Function} cb - The callback to remove.
|
72 |
+
*/
|
73 |
+
unsubscribe(key, cb) {
|
74 |
+
log(5, 'Store: unsubscribe called for key', key);
|
75 |
+
if (!this.subscribers[key]) return;
|
76 |
+
this.subscribers[key] = this.subscribers[key].filter(subCb => subCb !== cb);
|
77 |
+
if (this.subscribers[key].length === 0) delete this.subscribers[key];
|
78 |
+
}
|
79 |
+
}
|
80 |
+
|
81 |
+
export default Store;
|
js/utils/chat.js
ADDED
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview Utility functions for chat management.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { log } from './logger.js';
|
8 |
+
import { Message } from '../components/chatlog.js';
|
9 |
+
|
10 |
+
/**
|
11 |
+
* Generates a string with the current date and time prompt.
|
12 |
+
* @returns {string} The formatted date and time prompt.
|
13 |
+
*/
|
14 |
+
export function getDatePrompt() {
|
15 |
+
const now = new Date();
|
16 |
+
return `\n\nKnowledge cutoff: none\nCurrent date: ${now.toISOString().slice(0, 10)}\nCurrent time: ${now.toTimeString().slice(0, 5)}`;
|
17 |
+
}
|
18 |
+
|
19 |
+
/**
|
20 |
+
* Resets the editing state of a message in the chat.
|
21 |
+
* If a new message was being composed, it's discarded.
|
22 |
+
* If an existing message was being edited, it's restored.
|
23 |
+
* @param {import('../state/store.js').default} store - The application's state store.
|
24 |
+
* @param {import('../components/chatlog.js').Chatlog} chatlog - The chatlog instance.
|
25 |
+
* @param {import('../components/chatbox.js').Chatbox} chatbox - The chatbox instance.
|
26 |
+
*/
|
27 |
+
export function resetEditing(store, chatlog, chatbox) {
|
28 |
+
const currentEditingPos = store.get('editingPos');
|
29 |
+
if (currentEditingPos !== null) {
|
30 |
+
const prevMsg = chatlog.getNthMessage(currentEditingPos);
|
31 |
+
if (prevMsg) {
|
32 |
+
if (prevMsg.value.content === null) {
|
33 |
+
chatlog.deleteMessage(prevMsg); // Discard uncommitted new alternative
|
34 |
+
} else {
|
35 |
+
prevMsg.cache = null; // Restore original for previous edit
|
36 |
+
}
|
37 |
+
}
|
38 |
+
store.set('editingPos', null);
|
39 |
+
chatbox.update(false);
|
40 |
+
}
|
41 |
+
}
|
42 |
+
|
43 |
+
/**
|
44 |
+
* Adds a new message to the chat log.
|
45 |
+
* This is the preferred way to add a message to the chat log.
|
46 |
+
* @param {import('../components/chatlog.js').Chatlog} chatlog - The chatlog to add the message to.
|
47 |
+
* @param {Object} value - The value of the message to add.
|
48 |
+
* @returns {Message} The newly added message.
|
49 |
+
*/
|
50 |
+
export function addMessageToChat(chatlog, value) {
|
51 |
+
log(4, 'addMessageToChat called with value', value);
|
52 |
+
const message = chatlog.addMessage(value);
|
53 |
+
chatlog.notify();
|
54 |
+
return message;
|
55 |
+
}
|
56 |
+
|
57 |
+
/**
|
58 |
+
* Adds a new alternative to an existing message.
|
59 |
+
* This is the preferred way to add an alternative message.
|
60 |
+
* @param {import('../components/chatlog.js').Chatlog} chatlog - The chatlog instance.
|
61 |
+
* @param {Message | null} existingMessage - The message to add an alternative to. If null, the last message is used.
|
62 |
+
* @param {Object} newValue - The value for the new alternative message.
|
63 |
+
* @returns {Message | null} The newly created alternative message, or null if no target message was found.
|
64 |
+
*/
|
65 |
+
export function addAlternativeToChat(chatlog, existingMessage, newValue) {
|
66 |
+
log(4, 'addAlternativeToChat called for', existingMessage);
|
67 |
+
const targetMessage = existingMessage || chatlog.getLastMessage();
|
68 |
+
if (!targetMessage) {
|
69 |
+
log(2, "addAlternativeToChat: Cannot add alternative, no message found.");
|
70 |
+
return null;
|
71 |
+
}
|
72 |
+
const newMessage = chatlog.addAlternative(targetMessage, newValue);
|
73 |
+
chatlog.notify();
|
74 |
+
return newMessage;
|
75 |
+
}
|
js/utils/logger.js
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview Logging and error handling utilities.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { hooks } from '../hooks.js';
|
8 |
+
import { DEBUG_LEVEL } from '../config.js';
|
9 |
+
|
10 |
+
/**
|
11 |
+
* Logs a message to the console if the level is high enough.
|
12 |
+
* @param {number} level - The log level.
|
13 |
+
* @param {...*} args - The arguments to log.
|
14 |
+
*/
|
15 |
+
export function log(level, ...args) {
|
16 |
+
if (DEBUG_LEVEL < level) return;
|
17 |
+
const prefixes = ['', 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE', 'FULLTRACE'];
|
18 |
+
const prefix = `[${prefixes[level]}]`;
|
19 |
+
const consoles = [console.log, console.error, console.warn, console.info, console.log, console.log, console.log];
|
20 |
+
consoles[level](prefix, ...args);
|
21 |
+
}
|
22 |
+
|
23 |
+
/**
|
24 |
+
* Triggers an error by logging it and notifying error listeners.
|
25 |
+
* @param {...*} args - The arguments to include in the error.
|
26 |
+
*/
|
27 |
+
export function triggerError(...args) {
|
28 |
+
log(1, ...args);
|
29 |
+
hooks.onError.forEach(fn => fn(...args));
|
30 |
+
}
|
js/utils/parsers.js
ADDED
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
'use strict';
|
2 |
+
|
3 |
+
import { log } from './logger.js';
|
4 |
+
|
5 |
+
/**
|
6 |
+
* Parses <dma:tool_call> tags and extracts tool calls.
|
7 |
+
* @param {string} content - The content to parse.
|
8 |
+
* @returns {{toolCalls: Object[], positions: Object[], isSelfClosings: boolean[]}} The parsed tool calls, their positions, and self-closing flags.
|
9 |
+
*/
|
10 |
+
export function parseFunctionCalls(content) {
|
11 |
+
log(5, 'parseFunctionCalls called');
|
12 |
+
const toolCalls = [];
|
13 |
+
const positions = [];
|
14 |
+
const isSelfClosings = [];
|
15 |
+
const functionCallRegex = /<dma:tool_call\s+([^>]+?)\/>|<dma:tool_call\s+([^>]*?)>([\s\S]*?)<\/dma:tool_call\s*>/gi;
|
16 |
+
const nameRegex = /name="([^"]*)"/;
|
17 |
+
const paramsRegex = /<parameter\s+name="([^"]*)">([\s\S]*?)<\/parameter>/g;
|
18 |
+
|
19 |
+
for (const match of content.matchAll(functionCallRegex)) {
|
20 |
+
const startIndex = match.index;
|
21 |
+
const endIndex = startIndex + match[0].length;
|
22 |
+
|
23 |
+
const [, selfAttrs, openAttrs, innerContent] = match;
|
24 |
+
|
25 |
+
const isSelfClosing = innerContent === undefined;
|
26 |
+
const attributes = isSelfClosing ? selfAttrs : openAttrs;
|
27 |
+
const contentInner = isSelfClosing ? '' : innerContent;
|
28 |
+
|
29 |
+
const nameMatch = nameRegex.exec(attributes);
|
30 |
+
if (!nameMatch) continue;
|
31 |
+
|
32 |
+
const [, name] = nameMatch;
|
33 |
+
const params = {};
|
34 |
+
|
35 |
+
if (!isSelfClosing) {
|
36 |
+
let paramMatch;
|
37 |
+
while ((paramMatch = paramsRegex.exec(contentInner)) !== null) {
|
38 |
+
const [, paramName, paramValue] = paramMatch;
|
39 |
+
let value = paramValue.trim();
|
40 |
+
value = value.replace(/<\\\/dma:tool_call>/g, '</dma:tool_call>').replace(/<\\\/parameter>/g, '</parameter>');
|
41 |
+
params[paramName] = value;
|
42 |
+
log(5, "mcpPlugin: parseFunctionCalls value", value);
|
43 |
+
}
|
44 |
+
}
|
45 |
+
|
46 |
+
toolCalls.push({ name, params });
|
47 |
+
positions.push({ start: startIndex, end: endIndex });
|
48 |
+
isSelfClosings.push(isSelfClosing);
|
49 |
+
}
|
50 |
+
|
51 |
+
log(4, 'mcpPlugin: Parsed tool calls', toolCalls.length);
|
52 |
+
return { toolCalls, positions, isSelfClosings };
|
53 |
+
}
|
54 |
+
|
55 |
+
/**
|
56 |
+
* Escapes XML special characters.
|
57 |
+
* @param {string} unsafe - The string to escape.
|
58 |
+
* @returns {string} The escaped string.
|
59 |
+
*/
|
60 |
+
export function escapeXml(unsafe) {
|
61 |
+
return unsafe.replace(/[<>&'"\\]/g, c => ({
|
62 |
+
'<':'<',
|
63 |
+
'>':'>',
|
64 |
+
'&':'&',
|
65 |
+
'\'':''',
|
66 |
+
'"':'"',
|
67 |
+
'\\':'\'
|
68 |
+
})[c]);
|
69 |
+
}
|
js/utils/shared.js
ADDED
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview Shared utility functions for the application.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
import { log, triggerError } from './logger.js';
|
8 |
+
import { parseFunctionCalls, escapeXml } from './parsers.js';
|
9 |
+
import { hooks } from '../hooks.js';
|
10 |
+
|
11 |
+
/**
|
12 |
+
* Processes tool calls found in a message, filters them, executes them,
|
13 |
+
* and adds the results back to the chat.
|
14 |
+
*
|
15 |
+
* @param {import('../components/chatlog.js').Message} message - The message containing the tool calls.
|
16 |
+
* @param {import('../components/chatlog.js').Chatlog} chatlog - The chatlog instance.
|
17 |
+
* @param {import('../components/chatbox.js').ChatBox} chatbox - The chatbox instance.
|
18 |
+
* @param {function(object): boolean} filterCallback - A function to filter which tool calls to process.
|
19 |
+
* @param {function(object): Promise<object>} executeCallback - An async function to execute a tool call and return the result.
|
20 |
+
* @param {object} context - Additional context to pass to the callbacks.
|
21 |
+
*/
|
22 |
+
export async function processToolCalls(message, chatlog, chatbox, filterCallback, executeCallback, context) {
|
23 |
+
if (message.value.role !== 'assistant') return;
|
24 |
+
|
25 |
+
const { toolCalls, positions, isSelfClosings } = parseFunctionCalls(message.value.content);
|
26 |
+
if (toolCalls.length === 0) return;
|
27 |
+
|
28 |
+
const applicableCalls = toolCalls.filter(filterCallback);
|
29 |
+
if (applicableCalls.length === 0) return;
|
30 |
+
|
31 |
+
// Assign unique IDs to each applicable call for tracking.
|
32 |
+
applicableCalls.forEach(call => {
|
33 |
+
call.id = `tool_call_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
34 |
+
});
|
35 |
+
|
36 |
+
const toolResults = await Promise.all(
|
37 |
+
applicableCalls.map(call => executeCallback(call, context))
|
38 |
+
);
|
39 |
+
|
40 |
+
// Add/override tool_call_id attributes (in reverse to avoid index shifts).
|
41 |
+
let content = message.value.content;
|
42 |
+
for (let i = positions.length - 1; i >= 0; i--) {
|
43 |
+
const pos = positions[i];
|
44 |
+
const gtIndex = content.indexOf('>', pos.start);
|
45 |
+
let startTag = content.slice(pos.start, gtIndex + 1);
|
46 |
+
// Remove existing tool_call_id attributes
|
47 |
+
startTag = startTag.replace(/\s+tool_call_id\s*=\s*["'][^"']*["']/g, '');
|
48 |
+
// Insert new tool_call_id
|
49 |
+
const insert = ` tool_call_id="${toolCalls[i].id}"`;
|
50 |
+
const endSlice = isSelfClosings[i] ? -2 : -1;
|
51 |
+
const endTag = isSelfClosings[i] ? '/>' : '>';
|
52 |
+
startTag = startTag.slice(0, endSlice) + insert + endTag;
|
53 |
+
content = content.slice(0, pos.start) + startTag + content.slice(gtIndex + 1);
|
54 |
+
}
|
55 |
+
message.value.content = content;
|
56 |
+
message.cache = null;
|
57 |
+
chatbox.update(false);
|
58 |
+
|
59 |
+
let toolContents = '';
|
60 |
+
toolResults.forEach((tr, i) => {
|
61 |
+
const inner = tr.error
|
62 |
+
? `<error>\n${tr.error}\n</error>`
|
63 |
+
: `<content>\n${tr.content}\n</content>`;
|
64 |
+
toolContents += `<dma:tool_response name="${applicableCalls[i].name}" tool_call_id="${tr.id}">\n${inner}\n</dma:tool_response>\n`;
|
65 |
+
});
|
66 |
+
|
67 |
+
if (toolContents) {
|
68 |
+
chatlog.addMessage({ role: 'tool', content: toolContents });
|
69 |
+
chatlog.addMessage(null);
|
70 |
+
chatbox.update();
|
71 |
+
hooks.onGenerateAIResponse.forEach(fn => fn({}, chatlog));
|
72 |
+
}
|
73 |
+
}
|
74 |
+
|
75 |
+
/**
|
76 |
+
* Creates a JSON file from the given data and triggers a download.
|
77 |
+
*
|
78 |
+
* @param {object|Array} data - The JSON data to export.
|
79 |
+
* @param {string} filenameBase - The base name for the downloaded file.
|
80 |
+
*/
|
81 |
+
export function exportJson(data, filenameBase) {
|
82 |
+
if (!data) {
|
83 |
+
triggerError('No data to export.');
|
84 |
+
return;
|
85 |
+
}
|
86 |
+
|
87 |
+
try {
|
88 |
+
const jsonData = JSON.stringify(data, null, 2);
|
89 |
+
const blob = new Blob([jsonData], { type: 'application/json' });
|
90 |
+
const url = URL.createObjectURL(blob);
|
91 |
+
const a = document.createElement('a');
|
92 |
+
a.href = url;
|
93 |
+
const date = new Date().toISOString().slice(0, 10);
|
94 |
+
a.download = `${filenameBase}_${date}.json`;
|
95 |
+
document.body.appendChild(a);
|
96 |
+
a.click();
|
97 |
+
document.body.removeChild(a);
|
98 |
+
URL.revokeObjectURL(url);
|
99 |
+
} catch (error) {
|
100 |
+
triggerError(`Failed to export data: ${error.message}`);
|
101 |
+
log(1, 'Export failed', error);
|
102 |
+
}
|
103 |
+
}
|
104 |
+
|
105 |
+
/**
|
106 |
+
* Creates a file input to import a JSON file and processes its content.
|
107 |
+
*
|
108 |
+
* @param {string} accept - The accept attribute for the file input (e.g., 'application/json').
|
109 |
+
* @param {function(object): void} onParsedData - The callback function to handle the parsed JSON data.
|
110 |
+
*/
|
111 |
+
export function importJson(accept, onParsedData) {
|
112 |
+
const input = document.createElement('input');
|
113 |
+
input.type = 'file';
|
114 |
+
input.accept = accept;
|
115 |
+
input.addEventListener('change', (e) => {
|
116 |
+
const file = e.target.files[0];
|
117 |
+
if (!file) return;
|
118 |
+
|
119 |
+
const reader = new FileReader();
|
120 |
+
reader.onload = (event) => {
|
121 |
+
try {
|
122 |
+
const parsedData = JSON.parse(event.target.result);
|
123 |
+
onParsedData(parsedData);
|
124 |
+
} catch (error) {
|
125 |
+
triggerError(`Failed to import file: ${error.message}`);
|
126 |
+
log(1, 'Import failed', error);
|
127 |
+
}
|
128 |
+
};
|
129 |
+
reader.readAsText(file);
|
130 |
+
});
|
131 |
+
input.click();
|
132 |
+
}
|
js/utils/ui.js
ADDED
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @fileoverview UI utility functions for showing and hiding elements.
|
3 |
+
*/
|
4 |
+
|
5 |
+
'use strict';
|
6 |
+
|
7 |
+
/**
|
8 |
+
* Displays the login section and hides the logout section.
|
9 |
+
*/
|
10 |
+
export function showLogin() {
|
11 |
+
document.getElementById('session-login').style.display = 'block';
|
12 |
+
document.getElementById('session-logout').style.display = 'none';
|
13 |
+
}
|
14 |
+
|
15 |
+
/**
|
16 |
+
* Displays the logout section and hides the login section.
|
17 |
+
*/
|
18 |
+
export function showLogout() {
|
19 |
+
document.getElementById('session-login').style.display = 'none';
|
20 |
+
document.getElementById('session-logout').style.display = 'block';
|
21 |
+
}
|
22 |
+
|
23 |
+
/**
|
24 |
+
* Helper function to create a tool button.
|
25 |
+
* @param {string} title - The button's title (tooltip).
|
26 |
+
* @param {string} svgHtml - The SVG icon for the button.
|
27 |
+
* @param {function} onClick - The click event handler.
|
28 |
+
* @returns {HTMLButtonElement} The created button element.
|
29 |
+
*/
|
30 |
+
export function createControlButton(title, svgHtml, onClick) {
|
31 |
+
const button = document.createElement('button');
|
32 |
+
button.title = title;
|
33 |
+
button.classList.add('toolButton', 'small');
|
34 |
+
button.innerHTML = svgHtml;
|
35 |
+
if (!onClick) return button;
|
36 |
+
button.addEventListener('click', (e) => {
|
37 |
+
e.stopPropagation();
|
38 |
+
onClick(e);
|
39 |
+
});
|
40 |
+
return button;
|
41 |
+
}
|
main.py
ADDED
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
This script serves the static files for the chat application and runs an MCP proxy.
|
3 |
+
"""
|
4 |
+
|
5 |
+
import os
|
6 |
+
import json
|
7 |
+
import http.server
|
8 |
+
import socketserver
|
9 |
+
import threading
|
10 |
+
import webbrowser
|
11 |
+
import logging
|
12 |
+
import argparse
|
13 |
+
import signal
|
14 |
+
import sys
|
15 |
+
from fastmcp import FastMCP
|
16 |
+
from starlette.middleware import Middleware
|
17 |
+
from starlette.middleware.cors import CORSMiddleware
|
18 |
+
|
19 |
+
|
20 |
+
class CustomHandler(http.server.SimpleHTTPRequestHandler):
|
21 |
+
"""
|
22 |
+
Custom HTTP request handler to serve static files and the API configuration.
|
23 |
+
"""
|
24 |
+
|
25 |
+
def log_message(self, format, *args):
|
26 |
+
"""Logs an HTTP request."""
|
27 |
+
logging.info(f"WEB: {format % args}")
|
28 |
+
|
29 |
+
def do_GET(self):
|
30 |
+
"""Handles GET requests."""
|
31 |
+
if self.path == "/api/config":
|
32 |
+
self.send_response(200)
|
33 |
+
self.send_header("Content-type", "application/json")
|
34 |
+
self.end_headers()
|
35 |
+
config = {"mcp_endpoint": "http://127.0.0.1:3000/mcp"}
|
36 |
+
self.wfile.write(json.dumps(config).encode("utf-8"))
|
37 |
+
else:
|
38 |
+
super().do_GET()
|
39 |
+
|
40 |
+
|
41 |
+
def run_file_server():
|
42 |
+
"""
|
43 |
+
Runs the static file server.
|
44 |
+
"""
|
45 |
+
|
46 |
+
class ReuseTCPServer(socketserver.TCPServer):
|
47 |
+
"""
|
48 |
+
A TCP server that allows address reuse.
|
49 |
+
"""
|
50 |
+
|
51 |
+
allow_reuse_address = True
|
52 |
+
|
53 |
+
with ReuseTCPServer(("", 8000), CustomHandler) as server:
|
54 |
+
logging.info("WEB: Serving static files at http://localhost:8000")
|
55 |
+
server.serve_forever()
|
56 |
+
|
57 |
+
|
58 |
+
def load_config():
|
59 |
+
"""
|
60 |
+
Loads the MCP configuration from a JSON file.
|
61 |
+
"""
|
62 |
+
path = os.getenv("MCP_CONFIG", "mcp_config.json")
|
63 |
+
if not os.path.exists(path):
|
64 |
+
logging.warning(f"{path} not found, using empty config")
|
65 |
+
return {}
|
66 |
+
try:
|
67 |
+
with open(path) as f:
|
68 |
+
return json.load(f)
|
69 |
+
except json.JSONDecodeError as e:
|
70 |
+
logging.error(f"JSON decode error in {path}: {e}")
|
71 |
+
return {}
|
72 |
+
except Exception as e:
|
73 |
+
logging.error(f"Error loading {path}: {e}")
|
74 |
+
return {}
|
75 |
+
|
76 |
+
|
77 |
+
def setup_proxy(mcp_servers):
|
78 |
+
"""
|
79 |
+
Sets up the MCP proxy.
|
80 |
+
"""
|
81 |
+
if not mcp_servers:
|
82 |
+
return None
|
83 |
+
proxy = FastMCP.as_proxy({"mcpServers": mcp_servers}, name="Composite Proxy")
|
84 |
+
cors = [
|
85 |
+
Middleware(
|
86 |
+
CORSMiddleware,
|
87 |
+
allow_origins=["*"],
|
88 |
+
allow_credentials=True,
|
89 |
+
allow_methods=["*"],
|
90 |
+
allow_headers=["*"],
|
91 |
+
expose_headers=["MCP-Session-ID", "X-MCP-Session-ID"],
|
92 |
+
)
|
93 |
+
]
|
94 |
+
return proxy, cors
|
95 |
+
|
96 |
+
|
97 |
+
def shutdown(sig, frame):
|
98 |
+
"""
|
99 |
+
Shuts down the application gracefully.
|
100 |
+
"""
|
101 |
+
logging.info("Shutting down gracefully")
|
102 |
+
sys.exit(0)
|
103 |
+
|
104 |
+
|
105 |
+
def main():
|
106 |
+
"""
|
107 |
+
Main function to run the application.
|
108 |
+
"""
|
109 |
+
parser = argparse.ArgumentParser(description="Run MCP proxy and web server")
|
110 |
+
parser.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging")
|
111 |
+
args = parser.parse_args()
|
112 |
+
|
113 |
+
level = logging.DEBUG if args.verbose else logging.INFO
|
114 |
+
logging.basicConfig(level=level, format="%(asctime)s - %(levelname)s - %(message)s")
|
115 |
+
|
116 |
+
signal.signal(signal.SIGINT, shutdown)
|
117 |
+
signal.signal(signal.SIGTERM, shutdown)
|
118 |
+
|
119 |
+
mcp_servers = load_config()
|
120 |
+
proxy_info = setup_proxy(mcp_servers)
|
121 |
+
|
122 |
+
threading.Thread(target=run_file_server, daemon=True).start()
|
123 |
+
webbrowser.open("http://localhost:8000")
|
124 |
+
|
125 |
+
if proxy_info:
|
126 |
+
proxy, cors = proxy_info
|
127 |
+
logging.info("MCP: Starting proxy at http://127.0.0.1:3000/mcp")
|
128 |
+
proxy.run(transport="http", host="127.0.0.1", port=3000, middleware=cors)
|
129 |
+
|
130 |
+
|
131 |
+
if __name__ == "__main__":
|
132 |
+
main()
|
mcp_config-examples.json
ADDED
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"datetime": {
|
3 |
+
"command": "uvx",
|
4 |
+
"args": [
|
5 |
+
"fastmcp",
|
6 |
+
"run",
|
7 |
+
"./_mcp_servers/datetime-mcp/datetime-mcp.py"
|
8 |
+
]
|
9 |
+
},
|
10 |
+
"fetch": {
|
11 |
+
"command": "uvx",
|
12 |
+
"args": [
|
13 |
+
"fastmcp",
|
14 |
+
"run",
|
15 |
+
"./_mcp_servers/fetch-mcp/fetch-mcp.py"
|
16 |
+
]
|
17 |
+
},
|
18 |
+
"filesystem-read": {
|
19 |
+
"command": "uvx",
|
20 |
+
"args": [
|
21 |
+
"fastmcp",
|
22 |
+
"run",
|
23 |
+
"./_mcp_servers/filesystem_read-mcp/filesystem_read-mcp.py",
|
24 |
+
"--",
|
25 |
+
"./_mcp_filesystem/read_write"
|
26 |
+
]
|
27 |
+
},
|
28 |
+
"filesystem-write": {
|
29 |
+
"command": "uvx",
|
30 |
+
"args": [
|
31 |
+
"fastmcp",
|
32 |
+
"run",
|
33 |
+
"./_mcp_servers/filesystem_write-mcp/filesystem_write-mcp.py",
|
34 |
+
"--",
|
35 |
+
"./_mcp_filesystem/read_write"
|
36 |
+
]
|
37 |
+
},
|
38 |
+
"watcher": {
|
39 |
+
"command": "uvx",
|
40 |
+
"args": [
|
41 |
+
"fastmcp",
|
42 |
+
"run",
|
43 |
+
"./_mcp_servers/file_watcher-mcp/file_watcher-mcp.py",
|
44 |
+
"--",
|
45 |
+
"./_mcp_filesystem/watcher"
|
46 |
+
]
|
47 |
+
}
|
48 |
+
}
|
mcp_config.json
ADDED
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"datetime": {
|
3 |
+
"command": "uvx",
|
4 |
+
"args": [
|
5 |
+
"fastmcp",
|
6 |
+
"run",
|
7 |
+
"./_mcp_servers/datetime-mcp/datetime-mcp.py"
|
8 |
+
]
|
9 |
+
},
|
10 |
+
"fetch": {
|
11 |
+
"command": "uvx",
|
12 |
+
"args": [
|
13 |
+
"fastmcp",
|
14 |
+
"run",
|
15 |
+
"./_mcp_servers/fetch-mcp/fetch-mcp.py"
|
16 |
+
]
|
17 |
+
},
|
18 |
+
"filesystem-read": {
|
19 |
+
"command": "uvx",
|
20 |
+
"args": [
|
21 |
+
"fastmcp",
|
22 |
+
"run",
|
23 |
+
"./_mcp_servers/filesystem_read-mcp/filesystem_read-mcp.py",
|
24 |
+
"--",
|
25 |
+
"./_mcp_filesystem/read_write"
|
26 |
+
]
|
27 |
+
},
|
28 |
+
"watcher": {
|
29 |
+
"command": "uvx",
|
30 |
+
"args": [
|
31 |
+
"fastmcp",
|
32 |
+
"run",
|
33 |
+
"./_mcp_servers/file_watcher-mcp/file_watcher-mcp.py",
|
34 |
+
"--",
|
35 |
+
"./_mcp_filesystem/watcher"
|
36 |
+
]
|
37 |
+
}
|
38 |
+
}
|
penguin.svg
ADDED
|
pyproject.toml
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
[project]
|
2 |
+
name = "aiflow-chat"
|
3 |
+
version = "0.1.0"
|
4 |
+
description = "Chat application with flows, agents and MCP support"
|
5 |
+
readme = "README.md"
|
6 |
+
requires-python = ">=3.13"
|
7 |
+
dependencies = [
|
8 |
+
"fastmcp>=2.0.0",
|
9 |
+
"fastapi>=0.116.1",
|
10 |
+
]
|