dma123 commited on
Commit
4a46a6e
·
verified ·
1 Parent(s): b5329b1

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. .gitignore +8 -0
  3. LICENSE +661 -0
  4. README.md +38 -1
  5. _mcp_filesystem/read_write/guess_number.py +35 -0
  6. _mcp_filesystem/read_write/poem.md +9 -0
  7. _mcp_servers/datetime-mcp/datetime-mcp.py +22 -0
  8. _mcp_servers/fetch-mcp/fetch-mcp.py +26 -0
  9. _mcp_servers/file_watcher-mcp/file_watcher-mcp.py +74 -0
  10. _mcp_servers/filesystem_read-mcp/filesystem_read-mcp.py +265 -0
  11. _mcp_servers/filesystem_write-mcp/filesystem_write-mcp.py +213 -0
  12. css/3rdparty/hljs_androidstudio.min.css +1 -0
  13. css/agents.css +382 -0
  14. css/main.css +564 -0
  15. example_chat_with_flow_and_agents.json +223 -0
  16. index.html +140 -19
  17. js/3rdparty/auto-render.min.js +1 -0
  18. js/3rdparty/highlight.min.js +0 -0
  19. js/3rdparty/katex.min.js +0 -0
  20. js/3rdparty/markdown-it.min.js +0 -0
  21. js/app.js +500 -0
  22. js/components/chatbox.js +185 -0
  23. js/components/chatlist-view.js +110 -0
  24. js/components/chatlog.js +530 -0
  25. js/components/settings-panel.js +128 -0
  26. js/config.js +57 -0
  27. js/hooks.js +81 -0
  28. js/main.js +12 -0
  29. js/plugins/agents.js +1110 -0
  30. js/plugins/avatars.js +120 -0
  31. js/plugins/error-bubble.js +88 -0
  32. js/plugins/formatting.js +521 -0
  33. js/plugins/maximize-view.js +28 -0
  34. js/plugins/mcp.js +453 -0
  35. js/plugins/model-params.js +229 -0
  36. js/plugins/ui-controls.js +154 -0
  37. js/services/api-service.js +101 -0
  38. js/services/chat-service.js +274 -0
  39. js/services/config-service.js +71 -0
  40. js/state/store.js +81 -0
  41. js/utils/chat.js +75 -0
  42. js/utils/logger.js +30 -0
  43. js/utils/parsers.js +69 -0
  44. js/utils/shared.js +132 -0
  45. js/utils/ui.js +41 -0
  46. main.py +132 -0
  47. mcp_config-examples.json +48 -0
  48. mcp_config.json +38 -0
  49. penguin.svg +27 -0
  50. 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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ![screenshot.png](screenshot.png)
_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
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &lt;shift&gt;+&lt;enter&gt;, or &lt;ctrl&gt;+&lt;enter&gt;, 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 &#9662;</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(/&lt;think&gt;([\s\S]*?)&lt;\/think&gt;/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(/&lt;think&gt;([\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>&nbsp;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&nbsp;<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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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 = `&nbsp;${alternatives.activeMessageIndex + 1}/${alternatives.messages.length}&nbsp;`;
49
+
50
+ const spacer = document.createElement('span');
51
+ spacer.innerHTML = `&nbsp;&nbsp;&nbsp;`;
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 = `&nbsp;&nbsp;&nbsp;`;
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
+ '<':'&lt;',
63
+ '>':'&gt;',
64
+ '&':'&amp;',
65
+ '\'':'&apos;',
66
+ '"':'&quot;',
67
+ '\\':'&bsol;'
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
+ ]