Compare commits
659 Commits
feat/messa
...
fix/react-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca44133e90 | ||
|
|
b4810bb487 | ||
|
|
dc0f9c5f08 | ||
|
|
595fd878a6 | ||
|
|
9d45991181 | ||
|
|
cf2f2fd707 | ||
|
|
d4b1db0407 | ||
|
|
8470e252d6 | ||
|
|
131444ac52 | ||
|
|
ab3083f943 | ||
|
|
1e1d5c4a14 | ||
|
|
c8ab0b9428 | ||
|
|
33ce41704d | ||
|
|
4eb3aa31ee | ||
|
|
d1a9dfa3e6 | ||
|
|
0e5ebcfd00 | ||
|
|
c4e0a6acfe | ||
|
|
2243bb2862 | ||
|
|
1f7d2fa93f | ||
|
|
fb680ce764 | ||
|
|
dc5bc64040 | ||
|
|
1c2ce7e0aa | ||
|
|
a290ee7f39 | ||
|
|
79c697c34d | ||
|
|
76271cbf77 | ||
|
|
51dcdf94fb | ||
|
|
254051cf62 | ||
|
|
24d2e6e6ce | ||
|
|
cf9bfce43c | ||
|
|
9e0ee24fd7 | ||
|
|
5eb2772d53 | ||
|
|
f943f05cb1 | ||
|
|
96ce645064 | ||
|
|
1a972ac0e0 | ||
|
|
2e173631a0 | ||
|
|
c457d4a868 | ||
|
|
b74655651d | ||
|
|
f27a481c3c | ||
|
|
4028b26c1d | ||
|
|
011b6f2df1 | ||
|
|
7b3b73d390 | ||
|
|
004d6d8201 | ||
|
|
7cf57adceb | ||
|
|
76bf78b810 | ||
|
|
f4441e2a55 | ||
|
|
84f590ec7b | ||
|
|
a5865cfd01 | ||
|
|
4e7c714ea2 | ||
|
|
d2c4231458 | ||
|
|
b5004e2a51 | ||
|
|
e0c334b5ed | ||
|
|
d482e661fb | ||
|
|
3ac1caca69 | ||
|
|
94c112c066 | ||
|
|
2e694a87f8 | ||
|
|
4ae30db53a | ||
|
|
f4a6dd91cf | ||
|
|
c08a570c27 | ||
|
|
9c318c9526 | ||
|
|
4cee09870a | ||
|
|
866e8e8734 | ||
|
|
80e1784777 | ||
|
|
0d760ffa2e | ||
|
|
88f7e6a854 | ||
|
|
de37e2355d | ||
|
|
f27b04c5b0 | ||
|
|
a02b8f4609 | ||
|
|
7b90dfb46c | ||
|
|
26a9dba01a | ||
|
|
a176814ad1 | ||
|
|
ea51439aac | ||
|
|
162e33f478 | ||
|
|
ee4c310725 | ||
|
|
a000ff2a1a | ||
|
|
2f9576b2ae | ||
|
|
92554dd398 | ||
|
|
9473ddc762 | ||
|
|
5f469a71f3 | ||
|
|
87bac60afc | ||
|
|
704339e835 | ||
|
|
c8ab7180ba | ||
|
|
11757546c3 | ||
|
|
420b9ec2f2 | ||
|
|
1c73271e33 | ||
|
|
acdbe6b9ed | ||
|
|
6c201228d9 | ||
|
|
73b2a375ad | ||
|
|
89bb830b60 | ||
|
|
2399db4944 | ||
|
|
62774b34d3 | ||
|
|
654f19eaa9 | ||
|
|
ce642f17d9 | ||
|
|
d7bcd5a20e | ||
|
|
27903e7d9d | ||
|
|
a8c0d0a684 | ||
|
|
5e33c89fe7 | ||
|
|
42849e4586 | ||
|
|
6a8544fb0e | ||
|
|
37f7042f0f | ||
|
|
65d066cbef | ||
|
|
504531d4d5 | ||
|
|
d4b3428160 | ||
|
|
cd881ceb34 | ||
|
|
68b37e66e9 | ||
|
|
d6e7ed81ee | ||
|
|
a9843b4128 | ||
|
|
d4c6131fa3 | ||
|
|
d2d5064eed | ||
|
|
8bec7640fa | ||
|
|
fcf53f06ef | ||
|
|
2048f210e7 | ||
|
|
78eacccf6e | ||
|
|
a436ab1d78 | ||
|
|
2aedbf5702 | ||
|
|
b7e7174f3d | ||
|
|
e7e5c0456f | ||
|
|
53e38ed1aa | ||
|
|
f91e7da0a1 | ||
|
|
74db4c4646 | ||
|
|
1e4902b267 | ||
|
|
932b1d529a | ||
|
|
53046460ec | ||
|
|
38ac42af8c | ||
|
|
538291c03f | ||
|
|
142ad9e41e | ||
|
|
7250ce3514 | ||
|
|
02cf012671 | ||
|
|
d11a2cd95c | ||
|
|
65ac3181a8 | ||
|
|
998e54246f | ||
|
|
fcd8f7a26e | ||
|
|
b991afd69a | ||
|
|
d9d8bae2d6 | ||
|
|
422ba52093 | ||
|
|
51630f95fd | ||
|
|
ac1cab60a3 | ||
|
|
23f61b0d62 | ||
|
|
759f8518b2 | ||
|
|
7bd6c92f43 | ||
|
|
ff705d99b3 | ||
|
|
7ec17dc771 | ||
|
|
35883e8601 | ||
|
|
48b7bdb9ba | ||
|
|
d2d5b4370c | ||
|
|
27c31d6e0c | ||
|
|
961ee22327 | ||
|
|
37b3c08baa | ||
|
|
d8c3f601df | ||
|
|
cff9068359 | ||
|
|
cc871b7a72 | ||
|
|
5b98ef5b3d | ||
|
|
3428d15299 | ||
|
|
9ea3f0842c | ||
|
|
90242e2285 | ||
|
|
1616345261 | ||
|
|
0b818477ac | ||
|
|
027d6ea2b2 | ||
|
|
c7d2588f1a | ||
|
|
8712e26c74 | ||
|
|
0c652e0ac4 | ||
|
|
3a3a5e6c8b | ||
|
|
06ab2822be | ||
|
|
1b7596ebe1 | ||
|
|
e95219f2ec | ||
|
|
bb0ec0a3ec | ||
|
|
483b4e090e | ||
|
|
4975c2d9e8 | ||
|
|
965d7d3008 | ||
|
|
5365fddec9 | ||
|
|
128385bfe0 | ||
|
|
cfdeb124b9 | ||
|
|
8deaa6e4f6 | ||
|
|
e401685449 | ||
|
|
e195ad4a8f | ||
|
|
b8a84f62ac | ||
|
|
20f5271682 | ||
|
|
5524571c80 | ||
|
|
cd3031479c | ||
|
|
1df6e8c732 | ||
|
|
ed2e01491e | ||
|
|
228ed474ce | ||
|
|
6829a03437 | ||
|
|
cb922b67ad | ||
|
|
b0213742f4 | ||
|
|
90264f6ec9 | ||
|
|
1ef6de1869 | ||
|
|
e4ad5084cf | ||
|
|
0ef2725dfd | ||
|
|
4bd492f498 | ||
|
|
ef7433c823 | ||
|
|
b6765b48b5 | ||
|
|
7c45e42602 | ||
|
|
57a40f84b9 | ||
|
|
e737f71932 | ||
|
|
9ec6e5f771 | ||
|
|
4647688613 | ||
|
|
bc0f283278 | ||
|
|
aadadf8353 | ||
|
|
d0a0685fc1 | ||
|
|
ba7d5f53e5 | ||
|
|
86dde5dc0f | ||
|
|
a5ceceeca3 | ||
|
|
fcfda90d5a | ||
|
|
2ccfde1ba4 | ||
|
|
ae1c1409e1 | ||
|
|
b46237296e | ||
|
|
8c06d2f706 | ||
|
|
1b705edb06 | ||
|
|
42435e8f76 | ||
|
|
3111979bb4 | ||
|
|
56580e3fac | ||
|
|
7084b8d429 | ||
|
|
8b0e8506c2 | ||
|
|
4d133d59ea | ||
|
|
35b885798b | ||
|
|
ae9e12b276 | ||
|
|
8018ac1a97 | ||
|
|
f429e3fc01 | ||
|
|
6c63146556 | ||
|
|
29242154d0 | ||
|
|
ccc5e830d7 | ||
|
|
adf10f6ea1 | ||
|
|
26a6ff871f | ||
|
|
d1e85f964d | ||
|
|
8d041438fd | ||
|
|
c6dc1810e9 | ||
|
|
dabfb8dc0e | ||
|
|
4aa9c9f225 | ||
|
|
fa394576bb | ||
|
|
5c1ac376e6 | ||
|
|
88e77aa116 | ||
|
|
4e9340f551 | ||
|
|
0648a1f567 | ||
|
|
4b1f7db506 | ||
|
|
0be2177937 | ||
|
|
c52cc5a94f | ||
|
|
947695fdc7 | ||
|
|
75296babe3 | ||
|
|
c9381d672e | ||
|
|
67fa5df611 | ||
|
|
122e4a10d0 | ||
|
|
b82b16b5f6 | ||
|
|
ebdd90b235 | ||
|
|
5c8e06ed94 | ||
|
|
f4e4586fbc | ||
|
|
fab1d29c83 | ||
|
|
de9cb2fbdb | ||
|
|
a419aed404 | ||
|
|
cb47e8decd | ||
|
|
3c4bb72a82 | ||
|
|
cab79ef185 | ||
|
|
a87c06aab8 | ||
|
|
c19659daa5 | ||
|
|
bafde1c518 | ||
|
|
45961d2eda | ||
|
|
e1a0dd6810 | ||
|
|
a1d14b9292 | ||
|
|
a7d6065b08 | ||
|
|
5dbd38721f | ||
|
|
39fcc04d78 | ||
|
|
5c7784622e | ||
|
|
3088887e57 | ||
|
|
14f14b75b0 | ||
|
|
77351b7691 | ||
|
|
b28fadd02f | ||
|
|
63fa70863c | ||
|
|
11a76ae90f | ||
|
|
1973e4d290 | ||
|
|
7a169c424d | ||
|
|
69bcb0e13e | ||
|
|
54386bf624 | ||
|
|
fe6e65f263 | ||
|
|
f05b884646 | ||
|
|
8e163b8f17 | ||
|
|
caebaf5d46 | ||
|
|
6950b6f1e7 | ||
|
|
0e35224787 | ||
|
|
e5a84a2e84 | ||
|
|
09da7113a0 | ||
|
|
e6e43dbcca | ||
|
|
e02f826707 | ||
|
|
781b01ee17 | ||
|
|
1f1086ed7b | ||
|
|
0a80fc5517 | ||
|
|
6d8edc95d9 | ||
|
|
a54b49cc30 | ||
|
|
d1b339f71d | ||
|
|
a3c638946e | ||
|
|
a0193451a9 | ||
|
|
ede2b75cd0 | ||
|
|
34ab01e0a1 | ||
|
|
b493172090 | ||
|
|
6bcd941cc6 | ||
|
|
98ebfd12b3 | ||
|
|
305a454ffd | ||
|
|
dfc593f2e1 | ||
|
|
b50203f85d | ||
|
|
e2a0792e2d | ||
|
|
b7d97cca69 | ||
|
|
7cdc80c3e2 | ||
|
|
59b6cbc23c | ||
|
|
21c436d900 | ||
|
|
87f3628b49 | ||
|
|
27b315bcca | ||
|
|
1ec81f9e75 | ||
|
|
087e757f9f | ||
|
|
ce955e3ee9 | ||
|
|
4ddada4de8 | ||
|
|
164386a337 | ||
|
|
d4d2510834 | ||
|
|
46a5ea88f3 | ||
|
|
7ca9dcd2fb | ||
|
|
9c679ede20 | ||
|
|
60c85b651f | ||
|
|
73895b5f4b | ||
|
|
3e2acde9e2 | ||
|
|
a1d8f3eb0f | ||
|
|
0aba7bad31 | ||
|
|
75660766db | ||
|
|
53a6c70eca | ||
|
|
da18ff3d48 | ||
|
|
4a671a9bc2 | ||
|
|
ae1839ac33 | ||
|
|
56dbe6b050 | ||
|
|
f5a41e9c78 | ||
|
|
f65149af19 | ||
|
|
affef443b6 | ||
|
|
e40e1d0b36 | ||
|
|
14638b7470 | ||
|
|
b4a92cecc8 | ||
|
|
49e4667410 | ||
|
|
5d26bf15a3 | ||
|
|
7631d9d730 | ||
|
|
91b4d806cd | ||
|
|
1b8bb568b1 | ||
|
|
18da9a19fd | ||
|
|
7fdae0173c | ||
|
|
c872707791 | ||
|
|
a0cab3341e | ||
|
|
035001f841 | ||
|
|
f533c1a2ca | ||
|
|
e5aa58722c | ||
|
|
8645fe4ab1 | ||
|
|
15f216b050 | ||
|
|
b4df5bbb13 | ||
|
|
a17a198912 | ||
|
|
939782ac4e | ||
|
|
7e369bef00 | ||
|
|
c3adcf663f | ||
|
|
373b2fcd78 | ||
|
|
c9d1e30f8b | ||
|
|
761b57a834 | ||
|
|
632871b2f8 | ||
|
|
52f00f08f2 | ||
|
|
1d5761b1fd | ||
|
|
42dbc6555c | ||
|
|
b237d9d38d | ||
|
|
1e9a811065 | ||
|
|
3c12f9052e | ||
|
|
9425437480 | ||
|
|
2385fba695 | ||
|
|
634c478e18 | ||
|
|
335bf47dbd | ||
|
|
23c4117d6f | ||
|
|
4a5d3b31ab | ||
|
|
17a27f0d55 | ||
|
|
8fbb93b0bf | ||
|
|
e09cd6b6d7 | ||
|
|
26ac9e3c2e | ||
|
|
958edc0017 | ||
|
|
efa54f3435 | ||
|
|
88a2cd6659 | ||
|
|
4e26291a61 | ||
|
|
f25142e597 | ||
|
|
462fc84240 | ||
|
|
c97ad627d1 | ||
|
|
36307abc30 | ||
|
|
8d3dbcb5f8 | ||
|
|
9d9ae7ba4e | ||
|
|
d682045655 | ||
|
|
381397ed31 | ||
|
|
4484f39525 | ||
|
|
82c08128b6 | ||
|
|
8cd40a471e | ||
|
|
bd6428d473 | ||
|
|
d7960140dc | ||
|
|
d7052b547f | ||
|
|
6aaef9b7be | ||
|
|
b246676257 | ||
|
|
0e4b1820e7 | ||
|
|
1833092998 | ||
|
|
00717126e5 | ||
|
|
3816076464 | ||
|
|
710592b053 | ||
|
|
828c22310d | ||
|
|
f45b744318 | ||
|
|
f49d3791b6 | ||
|
|
ea62294bd8 | ||
|
|
bfe2e87f59 | ||
|
|
6f6944d003 | ||
|
|
4216ffd0da | ||
|
|
a32fad06a0 | ||
|
|
1a49972583 | ||
|
|
a09c52424f | ||
|
|
b869869e26 | ||
|
|
c3b2af5a15 | ||
|
|
01ffd4c4ca | ||
|
|
a5d4a01ad8 | ||
|
|
4d266fddb1 | ||
|
|
20dd4794b0 | ||
|
|
7b96900726 | ||
|
|
043a4fb5ca | ||
|
|
82e144be4c | ||
|
|
9b22e1671f | ||
|
|
7999149901 | ||
|
|
c70a5d63aa | ||
|
|
d1067bb6b3 | ||
|
|
b43b4b581e | ||
|
|
55645a75cc | ||
|
|
86a2780e2c | ||
|
|
36f86ff2b9 | ||
|
|
d960a42d6e | ||
|
|
3ae1b3d4cb | ||
|
|
1c045231c8 | ||
|
|
282aa6e81a | ||
|
|
117e390cf1 | ||
|
|
34b05a138b | ||
|
|
6c233fef9f | ||
|
|
1c813aa6c3 | ||
|
|
dd5592ddbb | ||
|
|
6e9d8a1747 | ||
|
|
cee78c6610 | ||
|
|
0b2dfbb88f | ||
|
|
1fd44a68b0 | ||
|
|
fcacc50fdc | ||
|
|
009b58c9c3 | ||
|
|
77c64cf868 | ||
|
|
f5acddbfeb | ||
|
|
ae35d689ec | ||
|
|
825b5e1be4 | ||
|
|
17df1db120 | ||
|
|
d56521260c | ||
|
|
8efafc6ba9 | ||
|
|
f35987a9a9 | ||
|
|
c7ec55c69a | ||
|
|
c77d7dff78 | ||
|
|
b282e4d729 | ||
|
|
c426876d0d | ||
|
|
027ef17a2e | ||
|
|
f0ac74dccf | ||
|
|
d6468f33c5 | ||
|
|
1515f511a1 | ||
|
|
1c2211aefb | ||
|
|
49f9dff9da | ||
|
|
92ba1e4fc3 | ||
|
|
7060aab33d | ||
|
|
0cce8220ce | ||
|
|
1722d9f435 | ||
|
|
078fd57eb5 | ||
|
|
4bd6087dc0 | ||
|
|
e45231376c | ||
|
|
01c7e509fd | ||
|
|
5ddf9683b4 | ||
|
|
d91df12dbc | ||
|
|
64e3de9ada | ||
|
|
2cf2f04a70 | ||
|
|
73380d76df | ||
|
|
38076babcf | ||
|
|
00cc410dcc | ||
|
|
7fc676bbc3 | ||
|
|
798126d39c | ||
|
|
874d74cf6e | ||
|
|
d73834e7f6 | ||
|
|
cb3afaceab | ||
|
|
fc0ba5d0d5 | ||
|
|
7ce4fc50ea | ||
|
|
b5ef8a93ca | ||
|
|
8ead4e9c0f | ||
|
|
432d84cda5 | ||
|
|
d3378dcf78 | ||
|
|
f0724af2aa | ||
|
|
f127150ea1 | ||
|
|
b3ef6d4534 | ||
|
|
1ce791d517 | ||
|
|
3d561ad8e3 | ||
|
|
14509d1077 | ||
|
|
a424e3a039 | ||
|
|
eaa5ec5545 | ||
|
|
5850e5da66 | ||
|
|
eb3ff6f570 | ||
|
|
ae9c78e643 | ||
|
|
445528aff7 | ||
|
|
d13c25444c | ||
|
|
5386716ebe | ||
|
|
da3cd62486 | ||
|
|
d8b47e30c4 | ||
|
|
1c19e529ac | ||
|
|
514b60f704 | ||
|
|
df1d4cd62b | ||
|
|
4839b91cef | ||
|
|
5048d6987d | ||
|
|
809736dd33 | ||
|
|
369cc37071 | ||
|
|
d0b64dabc2 | ||
|
|
02d2838424 | ||
|
|
4c4039283f | ||
|
|
77df6fd58e | ||
|
|
100801821f | ||
|
|
2201ebbb88 | ||
|
|
9810f01330 | ||
|
|
7b428be93d | ||
|
|
a4c2ed5328 | ||
|
|
934cc0dd33 | ||
|
|
da61500e34 | ||
|
|
db2042800b | ||
|
|
08772741e6 | ||
|
|
f5f542911f | ||
|
|
3b5b1986e6 | ||
|
|
3b0995c8ef | ||
|
|
34c95ca787 | ||
|
|
a4c2a1d435 | ||
|
|
e4f0743e2f | ||
|
|
7632efda88 | ||
|
|
ab90eb2aab | ||
|
|
4c5bed0b1f | ||
|
|
302331043a | ||
|
|
09f5e7af8c | ||
|
|
664304241a | ||
|
|
27f98b02a6 | ||
|
|
af6a3c87d6 | ||
|
|
d1819274bb | ||
|
|
8058ed21b3 | ||
|
|
eaf302bb40 | ||
|
|
3405b7e429 | ||
|
|
2fc1df8793 | ||
|
|
ec82eb2881 | ||
|
|
1c978e0684 | ||
|
|
e938e1572c | ||
|
|
9a7681c5c8 | ||
|
|
259f2157f6 | ||
|
|
21ce139df0 | ||
|
|
71536d6ef5 | ||
|
|
ef1a035701 | ||
|
|
2b76c326ee | ||
|
|
64ee5c528b | ||
|
|
136d343c18 | ||
|
|
0b1b9a913f | ||
|
|
cb0833a915 | ||
|
|
984c28d4be | ||
|
|
49add96dc0 | ||
|
|
db58762a13 | ||
|
|
6e89d0037f | ||
|
|
e1ab17387c | ||
|
|
54de2341bd | ||
|
|
b131f0c48c | ||
|
|
9e4b792fc3 | ||
|
|
7abd5da57d | ||
|
|
be7399b3c4 | ||
|
|
8d92b515ab | ||
|
|
524098d6d3 | ||
|
|
42fa2d94be | ||
|
|
a65b30f3a1 | ||
|
|
f1991b356b | ||
|
|
352c23180a | ||
|
|
825c376c5c | ||
|
|
231a923c9d | ||
|
|
dbf01652f8 | ||
|
|
842a6cb178 | ||
|
|
d56c526709 | ||
|
|
70a68bef27 | ||
|
|
f9b49ffde6 | ||
|
|
a264fd42e4 | ||
|
|
219844cb74 | ||
|
|
230205d210 | ||
|
|
f9fb0f9125 | ||
|
|
0f777e357d | ||
|
|
5c578c191b | ||
|
|
7a4952f773 | ||
|
|
71a1daddef | ||
|
|
0a82955e91 | ||
|
|
62d2da3815 | ||
|
|
84aab66aa6 | ||
|
|
2d0d599ac8 | ||
|
|
dca6be45b0 | ||
|
|
5a71807cc9 | ||
|
|
0d0ab4dcf5 | ||
|
|
ac3da51890 | ||
|
|
9ea361f7e8 | ||
|
|
cc6160892a | ||
|
|
49eed449c3 | ||
|
|
8ada7ffaf6 | ||
|
|
e7c37231e0 | ||
|
|
c196a02c95 | ||
|
|
d1ff8591a6 | ||
|
|
219d162e1a | ||
|
|
669f60273c | ||
|
|
697f7d1946 | ||
|
|
e4d04f8346 | ||
|
|
c37af25525 | ||
|
|
ea90c6c9cb | ||
|
|
58dbb514e0 | ||
|
|
a8e2df6bed | ||
|
|
2f74becb31 | ||
|
|
b31ac74f96 | ||
|
|
54b4e6a80b | ||
|
|
079d2c3cb3 | ||
|
|
911f9d8bc9 | ||
|
|
f90bda861f | ||
|
|
71ed94de31 | ||
|
|
dc16cf2aa7 | ||
|
|
be12898b7b | ||
|
|
3fc92e093b | ||
|
|
b6187ad637 | ||
|
|
ca8ac9911e | ||
|
|
95a1e210b6 | ||
|
|
b55f419a95 | ||
|
|
8953961a51 | ||
|
|
8836663c35 | ||
|
|
aaba77c360 | ||
|
|
532bad8eb7 | ||
|
|
e5b43c8176 | ||
|
|
5f9c2d7f6a | ||
|
|
568257e7b6 | ||
|
|
df31629c5f | ||
|
|
e6dc8619d9 | ||
|
|
9a71a01b66 | ||
|
|
941a6666e6 | ||
|
|
1bf63865a8 | ||
|
|
ba41d8021f | ||
|
|
1e919a908f | ||
|
|
702612e3f9 | ||
|
|
fa380619ce | ||
|
|
a30b2e2cb2 | ||
|
|
ae0cee9ef4 | ||
|
|
dc9fb381f7 | ||
|
|
943fccf655 | ||
|
|
e0d2d44f35 | ||
|
|
5a6413f356 | ||
|
|
f3ef4c77f5 | ||
|
|
751e391db6 | ||
|
|
3e04c9493f | ||
|
|
6b0a1a42ad | ||
|
|
ee82b23886 | ||
|
|
0d2dc2c257 | ||
|
|
c785be82dd | ||
|
|
a4bb82a02d | ||
|
|
e8c94f3584 | ||
|
|
d123eec476 | ||
|
|
002a443281 | ||
|
|
64f3d08d4e | ||
|
|
9c956a30ea | ||
|
|
5eaa90a7a2 | ||
|
|
e3f5033bc4 | ||
|
|
2ec3b20b23 | ||
|
|
d26d02babc | ||
|
|
675671688b | ||
|
|
bcdd48615d | ||
|
|
1f974558f8 | ||
|
|
0f1ad59e58 |
6
.github/workflows/auto-i18n.yml
vendored
6
.github/workflows/auto-i18n.yml
vendored
@@ -2,8 +2,8 @@ name: Auto I18N
|
||||
|
||||
env:
|
||||
API_KEY: ${{ secrets.TRANSLATE_API_KEY }}
|
||||
MODEL: ${{ vars.MODEL || 'deepseek/deepseek-v3.1'}}
|
||||
BASE_URL: ${{ vars.BASE_URL || 'https://api.ppinfra.com/openai'}}
|
||||
MODEL: ${{ vars.AUTO_I18N_MODEL || 'deepseek/deepseek-v3.1'}}
|
||||
BASE_URL: ${{ vars.AUTO_I18N_BASE_URL || 'https://api.ppinfra.com/openai'}}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
|
||||
- name: 📦 Setting Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
||||
2
.github/workflows/claude-code-review.yml
vendored
2
.github/workflows/claude-code-review.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
17
.github/workflows/claude-translator.yml
vendored
17
.github/workflows/claude-translator.yml
vendored
@@ -16,10 +16,13 @@ on:
|
||||
jobs:
|
||||
translate:
|
||||
if: |
|
||||
(github.event_name == 'issues') ||
|
||||
(github.event_name == 'issue_comment' && github.event.sender.type != 'Bot') ||
|
||||
(github.event_name == 'pull_request_review' && github.event.sender.type != 'Bot') ||
|
||||
(github.event_name == 'pull_request_review_comment' && github.event.sender.type != 'Bot')
|
||||
(github.event_name == 'issues')
|
||||
|| (github.event_name == 'issue_comment' && github.event.sender.type != 'Bot')
|
||||
|| (
|
||||
(github.event_name == 'pull_request_review' || github.event_name == 'pull_request_review_comment')
|
||||
&& github.event.sender.type != 'Bot'
|
||||
&& github.event.pull_request.head.repo.fork == false
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -29,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -42,7 +45,7 @@ jobs:
|
||||
# See: https://github.com/anthropics/claude-code-action/blob/main/docs/security.md
|
||||
github_token: ${{ secrets.TOKEN_GITHUB_WRITE }}
|
||||
allowed_non_write_users: "*"
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
anthropic_api_key: ${{ secrets.CLAUDE_TRANSLATOR_APIKEY }}
|
||||
claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*),Bash(gh api:repos/*/pulls/*/reviews/*),Bash(gh api:repos/*/pulls/comments/*)"
|
||||
prompt: |
|
||||
你是一个多语言翻译助手。你需要响应 GitHub Webhooks 中的以下四种事件:
|
||||
@@ -105,3 +108,5 @@ jobs:
|
||||
|
||||
使用以下命令获取完整信息:
|
||||
gh issue view ${{ github.event.issue.number }} --json title,body,comments
|
||||
env:
|
||||
ANTHROPIC_BASE_URL: ${{ secrets.CLAUDE_TRANSLATOR_BASEURL }}
|
||||
|
||||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
2
.github/workflows/delete-branch.yml
vendored
2
.github/workflows/delete-branch.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
if: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository
|
||||
steps:
|
||||
- name: Delete merged branch
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
github.rest.git.deleteRef({
|
||||
|
||||
26
.github/workflows/nightly-build.yml
vendored
26
.github/workflows/nightly-build.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
@@ -99,9 +99,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
@@ -110,15 +110,15 @@ jobs:
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ vars.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -128,9 +128,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Rename artifacts with nightly format
|
||||
shell: bash
|
||||
|
||||
2
.github/workflows/pr-ci.yml
vendored
2
.github/workflows/pr-ci.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
||||
26
.github/workflows/release.yml
vendored
26
.github/workflows/release.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
@@ -86,9 +86,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
@@ -98,15 +98,15 @@ jobs:
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ vars.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -116,9 +116,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }}
|
||||
RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }}
|
||||
RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }}
|
||||
|
||||
- name: Release
|
||||
uses: ncipollo/release-action@v1
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -71,3 +71,5 @@ playwright-report
|
||||
test-results
|
||||
|
||||
YOUR_MEMORY_FILE_PATH
|
||||
|
||||
.sessions/
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
"no-unused-expressions": "off", // this rule disallow us to use expression to call function, like `condition && fn()`
|
||||
"no-unused-labels": "error",
|
||||
"no-unused-private-class-members": "error",
|
||||
"no-unused-vars": ["error", { "caughtErrors": "none" }],
|
||||
"no-unused-vars": ["warn", { "caughtErrors": "none" }],
|
||||
"no-useless-backreference": "error",
|
||||
"no-useless-catch": "error",
|
||||
"no-useless-escape": "error",
|
||||
|
||||
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@@ -34,10 +34,10 @@
|
||||
"*.css": "tailwindcss"
|
||||
},
|
||||
"files.eol": "\n",
|
||||
"i18n-ally.displayLanguage": "zh-cn",
|
||||
// "i18n-ally.displayLanguage": "zh-cn", // 界面显示语言
|
||||
"i18n-ally.enabledFrameworks": ["react-i18next", "i18next"],
|
||||
"i18n-ally.enabledParsers": ["ts", "js", "json"], // 解析语言
|
||||
"i18n-ally.fullReloadOnChanged": true, // 界面显示语言
|
||||
"i18n-ally.fullReloadOnChanged": true,
|
||||
"i18n-ally.keystyle": "nested", // 翻译路径格式
|
||||
"i18n-ally.localesPaths": ["src/renderer/src/i18n/locales"],
|
||||
// "i18n-ally.namespace": true, // 开启命名空间
|
||||
@@ -47,5 +47,9 @@
|
||||
"search.exclude": {
|
||||
"**/dist/**": true,
|
||||
".yarn/releases/**": true
|
||||
}
|
||||
},
|
||||
"tailwindCSS.classAttributes": [
|
||||
"className",
|
||||
"classNames",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..91d0f336b318833c6cee9599fe91370c0ff75323 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -447,7 +447,10 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||
}
|
||||
|
||||
// src/get-model-path.ts
|
||||
-function getModelPath(modelId) {
|
||||
+function getModelPath(modelId, baseURL) {
|
||||
+ if (baseURL?.includes('cherryin')) {
|
||||
+ return `models/${modelId}`;
|
||||
+ }
|
||||
return modelId.includes("/") ? modelId : `models/${modelId}`;
|
||||
}
|
||||
|
||||
@@ -856,7 +859,8 @@ var GoogleGenerativeAILanguageModel = class {
|
||||
rawValue: rawResponse
|
||||
} = await postJsonToApi2({
|
||||
url: `${this.config.baseURL}/${getModelPath(
|
||||
- this.modelId
|
||||
+ this.modelId,
|
||||
+ this.config.baseURL
|
||||
)}:generateContent`,
|
||||
headers: mergedHeaders,
|
||||
body: args,
|
||||
@@ -962,7 +966,8 @@ var GoogleGenerativeAILanguageModel = class {
|
||||
);
|
||||
const { responseHeaders, value: response } = await postJsonToApi2({
|
||||
url: `${this.config.baseURL}/${getModelPath(
|
||||
- this.modelId
|
||||
+ this.modelId,
|
||||
+ this.config.baseURL
|
||||
)}:streamGenerateContent?alt=sse`,
|
||||
headers,
|
||||
body: args,
|
||||
13
.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch
vendored
Normal file
13
.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 69ab1599c76801dc1167551b6fa283dded123466..f0af43bba7ad1196fe05338817e65b4ebda40955 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
|
||||
|
||||
// src/get-model-path.ts
|
||||
function getModelPath(modelId) {
|
||||
- return modelId.includes("/") ? modelId : `models/${modelId}`;
|
||||
+ return modelId?.includes("models/") ? modelId : `models/${modelId}`;
|
||||
}
|
||||
|
||||
// src/google-generative-ai-options.ts
|
||||
31
.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch
vendored
Normal file
31
.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
diff --git a/sdk.mjs b/sdk.mjs
|
||||
index 461e9a2ba246778261108a682762ffcf26f7224e..44bd667d9f591969d36a105ba5eb8b478c738dd8 100644
|
||||
--- a/sdk.mjs
|
||||
+++ b/sdk.mjs
|
||||
@@ -6215,7 +6215,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) {
|
||||
}
|
||||
|
||||
// ../src/transport/ProcessTransport.ts
|
||||
-import { spawn } from "child_process";
|
||||
+import { fork } from "child_process";
|
||||
import { createInterface } from "readline";
|
||||
|
||||
// ../src/utils/fsOperations.ts
|
||||
@@ -6473,14 +6473,11 @@ class ProcessTransport {
|
||||
const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`;
|
||||
throw new ReferenceError(errorMessage);
|
||||
}
|
||||
- const isNative = isNativeBinary(pathToClaudeCodeExecutable);
|
||||
- const spawnCommand = isNative ? pathToClaudeCodeExecutable : executable;
|
||||
- const spawnArgs = isNative ? args : [...executableArgs, pathToClaudeCodeExecutable, ...args];
|
||||
- this.logForDebugging(isNative ? `Spawning Claude Code native binary: ${pathToClaudeCodeExecutable} ${args.join(" ")}` : `Spawning Claude Code process: ${executable} ${[...executableArgs, pathToClaudeCodeExecutable, ...args].join(" ")}`);
|
||||
+ this.logForDebugging(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`);
|
||||
const stderrMode = env.DEBUG || stderr ? "pipe" : "ignore";
|
||||
- this.child = spawn(spawnCommand, spawnArgs, {
|
||||
+ this.child = fork(pathToClaudeCodeExecutable, args, {
|
||||
cwd,
|
||||
- stdio: ["pipe", "pipe", stderrMode],
|
||||
+ stdio: stderrMode === "pipe" ? ["pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "ignore", "ipc"],
|
||||
signal: this.abortController.signal,
|
||||
env
|
||||
});
|
||||
44
.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch
vendored
Normal file
44
.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 53f411e55a4c9a06fd29bb4ab8161c4ad15980cd..71b91f196c8b886ed90dd237dec5625d79d5677e 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -12676,10 +12676,13 @@ var OpenAIResponsesLanguageModel = class {
|
||||
}
|
||||
});
|
||||
} else if (value.item.type === "message") {
|
||||
- controller.enqueue({
|
||||
- type: "text-end",
|
||||
- id: value.item.id
|
||||
- });
|
||||
+ // Fix for gpt-5-codex: use currentTextId to ensure text-end matches text-start
|
||||
+ if (currentTextId) {
|
||||
+ controller.enqueue({
|
||||
+ type: "text-end",
|
||||
+ id: currentTextId
|
||||
+ });
|
||||
+ }
|
||||
currentTextId = null;
|
||||
} else if (isResponseOutputItemDoneReasoningChunk(value)) {
|
||||
const activeReasoningPart = activeReasoning[value.item.id];
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 7719264da3c49a66c2626082f6ccaae6e3ef5e89..090fd8cf142674192a826148428ed6a0c4a54e35 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -12670,10 +12670,13 @@ var OpenAIResponsesLanguageModel = class {
|
||||
}
|
||||
});
|
||||
} else if (value.item.type === "message") {
|
||||
- controller.enqueue({
|
||||
- type: "text-end",
|
||||
- id: value.item.id
|
||||
- });
|
||||
+ // Fix for gpt-5-codex: use currentTextId to ensure text-end matches text-start
|
||||
+ if (currentTextId) {
|
||||
+ controller.enqueue({
|
||||
+ type: "text-end",
|
||||
+ id: currentTextId
|
||||
+ });
|
||||
+ }
|
||||
currentTextId = null;
|
||||
} else if (isResponseOutputItemDoneReasoningChunk(value)) {
|
||||
const activeReasoningPart = activeReasoning[value.item.id];
|
||||
152
CLAUDE.md
152
CLAUDE.md
@@ -1,127 +1,51 @@
|
||||
# CLAUDE.md
|
||||
# AI Assistant Guide
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
This file provides guidance to AI coding assistants when working with code in this repository. Adherence to these guidelines is crucial for maintaining code quality and consistency.
|
||||
|
||||
## Guiding Principles (MUST FOLLOW)
|
||||
|
||||
- **Keep it clear**: Write code that is easy to read, maintain, and explain.
|
||||
- **Match the house style**: Reuse existing patterns, naming, and conventions.
|
||||
- **Search smart**: Prefer `ast-grep` for semantic queries; fall back to `rg`/`grep` when needed.
|
||||
- **Build with HeroUI**: Use HeroUI for every new UI component; never add `antd` or `styled-components`.
|
||||
- **Log centrally**: Route all logging through `loggerService` with the right context—no `console.log`.
|
||||
- **Research via subagent**: Lean on `subagent` for external docs, APIs, news, and references.
|
||||
- **Seek review**: Ask a human developer to review substantial changes before merging.
|
||||
- **Commit in rhythm**: Keep commits small, conventional, and emoji-tagged.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Environment Setup
|
||||
- **Install**: `yarn install` - Install all project dependencies
|
||||
- **Development**: `yarn dev` - Runs Electron app in development mode with hot reload
|
||||
- **Debug**: `yarn debug` - Starts with debugging enabled, use `chrome://inspect` to attach debugger
|
||||
- **Build Check**: `yarn build:check` - **REQUIRED** before commits (lint + test + typecheck)
|
||||
- If having i18n sort issues, run `yarn sync:i18n` first to sync template
|
||||
- If having formatting issues, run `yarn format` first
|
||||
- **Test**: `yarn test` - Run all tests (Vitest) across main and renderer processes
|
||||
- **Single Test**:
|
||||
- `yarn test:main` - Run tests for main process only
|
||||
- `yarn test:renderer` - Run tests for renderer process only
|
||||
- **Lint**: `yarn lint` - Fix linting issues and run TypeScript type checking
|
||||
- **Format**: `yarn format` - Auto-format code using Biome
|
||||
|
||||
- **Prerequisites**: Node.js v22.x.x or higher, Yarn 4.9.1
|
||||
- **Setup Yarn**: `corepack enable && corepack prepare yarn@4.9.1 --activate`
|
||||
- **Install Dependencies**: `yarn install`
|
||||
- **Add New Dependencies**: `yarn add -D` for renderer-specific dependencies, `yarn add` for others.
|
||||
## Project Architecture
|
||||
|
||||
### Development
|
||||
### Electron Structure
|
||||
- **Main Process** (`src/main/`): Node.js backend with services (MCP, Knowledge, Storage, etc.)
|
||||
- **Renderer Process** (`src/renderer/`): React UI with Redux state management
|
||||
- **Preload Scripts** (`src/preload/`): Secure IPC bridge
|
||||
|
||||
- **Start Development**: `yarn dev` - Runs Electron app in development mode
|
||||
- **Debug Mode**: `yarn debug` - Starts with debugging enabled, use chrome://inspect
|
||||
|
||||
### Testing & Quality
|
||||
|
||||
- **Run Tests**: `yarn test` - Runs all tests (Vitest)
|
||||
- **Run E2E Tests**: `yarn test:e2e` - Playwright end-to-end tests
|
||||
- **Type Check**: `yarn typecheck` - Checks TypeScript for both node and web
|
||||
- **Lint**: `yarn lint` - ESLint with auto-fix
|
||||
- **Format**: `yarn format` - Biome formatting
|
||||
|
||||
### Build & Release
|
||||
|
||||
- **Build**: `yarn build` - Builds for production (includes typecheck)
|
||||
- **Platform-specific builds**:
|
||||
- Windows: `yarn build:win`
|
||||
- macOS: `yarn build:mac`
|
||||
- Linux: `yarn build:linux`
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Electron Multi-Process Architecture
|
||||
|
||||
- **Main Process** (`src/main/`): Node.js backend handling system integration, file operations, and services
|
||||
- **Renderer Process** (`src/renderer/`): React-based UI running in Chromium
|
||||
- **Preload Scripts** (`src/preload/`): Secure bridge between main and renderer processes
|
||||
|
||||
### Key Architectural Components
|
||||
|
||||
#### Main Process Services (`src/main/services/`)
|
||||
|
||||
- **MCPService**: Model Context Protocol server management
|
||||
- **KnowledgeService**: Document processing and knowledge base management
|
||||
- **FileStorage/S3Storage/WebDav**: Multiple storage backends
|
||||
- **WindowService**: Multi-window management (main, mini, selection windows)
|
||||
- **ProxyManager**: Network proxy handling
|
||||
- **SearchService**: Full-text search capabilities
|
||||
|
||||
#### AI Core (`src/renderer/src/aiCore/`)
|
||||
|
||||
- **Middleware System**: Composable pipeline for AI request processing
|
||||
- **Client Factory**: Supports multiple AI providers (OpenAI, Anthropic, Gemini, etc.)
|
||||
- **Stream Processing**: Real-time response handling
|
||||
|
||||
#### State Management (`src/renderer/src/store/`)
|
||||
|
||||
- **Redux Toolkit**: Centralized state management
|
||||
- **Persistent Storage**: Redux-persist for data persistence
|
||||
- **Thunks**: Async actions for complex operations
|
||||
|
||||
#### Knowledge Management
|
||||
|
||||
- **Embeddings**: Vector search with multiple providers (OpenAI, Voyage, etc.)
|
||||
- **OCR**: Document text extraction (system OCR, Doc2x, Mineru)
|
||||
- **Preprocessing**: Document preparation pipeline
|
||||
- **Loaders**: Support for various file formats (PDF, DOCX, EPUB, etc.)
|
||||
|
||||
### Build System
|
||||
|
||||
- **Electron-Vite**: Development and build tooling (v4.0.0)
|
||||
- **Rolldown-Vite**: Using experimental rolldown-vite instead of standard vite
|
||||
- **Workspaces**: Monorepo structure with `packages/` directory
|
||||
- **Multiple Entry Points**: Main app, mini window, selection toolbar
|
||||
- **Styled Components**: CSS-in-JS styling with SWC optimization
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
- **Vitest**: Unit and integration testing
|
||||
- **Playwright**: End-to-end testing
|
||||
- **Component Testing**: React Testing Library
|
||||
- **Coverage**: Available via `yarn test:coverage`
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **IPC Communication**: Secure main-renderer communication via preload scripts
|
||||
- **Service Layer**: Clear separation between UI and business logic
|
||||
- **Plugin Architecture**: Extensible via MCP servers and middleware
|
||||
- **Multi-language Support**: i18n with dynamic loading
|
||||
- **Theme System**: Light/dark themes with custom CSS variables
|
||||
|
||||
### UI Design
|
||||
|
||||
The project is in the process of migrating from antd & styled-components to HeroUI. Please use HeroUI to build UI components. The use of antd and styled-components is prohibited.
|
||||
|
||||
HeroUI Docs: https://www.heroui.com/docs/guide/introduction
|
||||
|
||||
## Logging Standards
|
||||
|
||||
### Usage
|
||||
### Key Components
|
||||
- **AI Core** (`src/renderer/src/aiCore/`): Middleware pipeline for multiple AI providers.
|
||||
- **Services** (`src/main/services/`): MCPService, KnowledgeService, WindowService, etc.
|
||||
- **Build System**: Electron-Vite with experimental rolldown-vite, yarn workspaces.
|
||||
- **State Management**: Redux Toolkit (`src/renderer/src/store/`) for predictable state.
|
||||
- **UI Components**: HeroUI (`@heroui/*`) for all new UI elements.
|
||||
|
||||
### Logging
|
||||
```typescript
|
||||
// Main process
|
||||
import { loggerService } from '@logger'
|
||||
const logger = loggerService.withContext('moduleName')
|
||||
|
||||
// Renderer process (set window source first)
|
||||
loggerService.initWindowSource('windowName')
|
||||
const logger = loggerService.withContext('moduleName')
|
||||
|
||||
// Logging
|
||||
// Renderer: loggerService.initWindowSource('windowName') first
|
||||
logger.info('message', CONTEXT)
|
||||
logger.error('message', new Error('error'), CONTEXT)
|
||||
```
|
||||
|
||||
### Log Levels (highest to lowest)
|
||||
|
||||
- `error` - Critical errors causing crash/unusable functionality
|
||||
- `warn` - Potential issues that don't affect core functionality
|
||||
- `info` - Application lifecycle and key user actions
|
||||
- `verbose` - Detailed flow information for feature tracing
|
||||
- `debug` - Development diagnostic info (not for production)
|
||||
- `silly` - Extreme debugging, low-level information
|
||||
|
||||
@@ -126,58 +126,60 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
<!--LANG:en-->
|
||||
🚀 New Features:
|
||||
- Refactored AI core engine for more efficient and stable content generation
|
||||
- Added support for multiple AI model providers: CherryIN, AiOnly
|
||||
- Added API server functionality for external application integration
|
||||
- Added PaddleOCR document recognition for enhanced document processing
|
||||
- Added Anthropic OAuth authentication support
|
||||
- Added data storage space limit notifications
|
||||
- Added font settings for global and code fonts customization
|
||||
- Added auto-copy feature after translation completion
|
||||
- Added keyboard shortcuts: rename topic, edit last message, etc.
|
||||
- Added text attachment preview for viewing file contents in messages
|
||||
- Added custom window control buttons (minimize, maximize, close)
|
||||
- Support for Qwen long-text (qwen-long) and document analysis (qwen-doc) models with native file uploads
|
||||
- Support for Qwen image recognition models (Qwen-Image)
|
||||
- Added iFlow CLI support
|
||||
- Converted knowledge base and web search to tool-calling approach for better flexibility
|
||||
What's New in v1.7.0-beta.2
|
||||
|
||||
New Features:
|
||||
- Session Settings: Manage session-specific settings and model configurations independently
|
||||
- Notes Full-Text Search: Search across all notes with match highlighting
|
||||
- Built-in DiDi MCP Server: Integration with DiDi ride-hailing services (China only)
|
||||
- Intel OV OCR: Hardware-accelerated OCR using Intel NPU
|
||||
- Auto-start API Server: Automatically starts when agents exist
|
||||
|
||||
Improvements:
|
||||
- Agent model selection now requires explicit user choice
|
||||
- Added Mistral AI provider support
|
||||
- Added NewAPI generic provider support
|
||||
- Improved navbar layout consistency across different modes
|
||||
- Enhanced chat component responsiveness
|
||||
- Better code block display on small screens
|
||||
- Updated OVMS to 2025.3 official release
|
||||
- Added Greek language support
|
||||
|
||||
Bug Fixes:
|
||||
- Fixed GitHub Copilot gpt-5-codex streaming issues
|
||||
- Fixed assistant creation failures
|
||||
- Fixed translate auto-copy functionality
|
||||
- Fixed miniapps external link opening
|
||||
- Fixed message layout and overflow issues
|
||||
- Fixed API key parsing to preserve spaces
|
||||
- Fixed agent display in different navbar layouts
|
||||
|
||||
🎨 UI Improvements & Bug Fixes:
|
||||
- Integrated HeroUI and Tailwind CSS framework
|
||||
- Optimized message notification styles with unified toast component
|
||||
- Moved free models to bottom with fixed position for easier access
|
||||
- Refactored quick panel and input bar tools for smoother operation
|
||||
- Optimized responsive design for navbar and sidebar
|
||||
- Improved scrollbar component with horizontal scrolling support
|
||||
- Fixed multiple translation issues: paste handling, file processing, state management
|
||||
- Various UI optimizations and bug fixes
|
||||
<!--LANG:zh-CN-->
|
||||
🚀 新功能:
|
||||
- 重构 AI 核心引擎,提供更高效稳定的内容生成
|
||||
- 新增多个 AI 模型提供商支持:CherryIN、AiOnly
|
||||
- 新增 API 服务器功能,支持外部应用集成
|
||||
- 新增 PaddleOCR 文档识别,增强文档处理能力
|
||||
- 新增 Anthropic OAuth 认证支持
|
||||
- 新增数据存储空间限制提醒
|
||||
- 新增字体设置,支持全局字体和代码字体自定义
|
||||
- 新增翻译完成后自动复制功能
|
||||
- 新增键盘快捷键:重命名主题、编辑最后一条消息等
|
||||
- 新增文本附件预览,可查看消息中的文件内容
|
||||
- 新增自定义窗口控制按钮(最小化、最大化、关闭)
|
||||
- 支持通义千问长文本(qwen-long)和文档分析(qwen-doc)模型,原生文件上传
|
||||
- 支持通义千问图像识别模型(Qwen-Image)
|
||||
- 新增 iFlow CLI 支持
|
||||
- 知识库和网页搜索转换为工具调用方式,提升灵活性
|
||||
v1.7.0-beta.2 新特性
|
||||
|
||||
🎨 界面改进与问题修复:
|
||||
- 集成 HeroUI 和 Tailwind CSS 框架
|
||||
- 优化消息通知样式,统一 toast 组件
|
||||
- 免费模型移至底部固定位置,便于访问
|
||||
- 重构快捷面板和输入栏工具,操作更流畅
|
||||
- 优化导航栏和侧边栏响应式设计
|
||||
- 改进滚动条组件,支持水平滚动
|
||||
- 修复多个翻译问题:粘贴处理、文件处理、状态管理
|
||||
- 各种界面优化和问题修复
|
||||
新功能:
|
||||
- 会话设置:独立管理会话特定的设置和模型配置
|
||||
- 笔记全文搜索:跨所有笔记搜索并高亮匹配内容
|
||||
- 内置滴滴 MCP 服务器:集成滴滴打车服务(仅限中国地区)
|
||||
- Intel OV OCR:使用 Intel NPU 的硬件加速 OCR
|
||||
- 自动启动 API 服务器:当存在 Agent 时自动启动
|
||||
|
||||
改进:
|
||||
- Agent 模型选择现在需要用户显式选择
|
||||
- 添加 Mistral AI 提供商支持
|
||||
- 添加 NewAPI 通用提供商支持
|
||||
- 改进不同模式下的导航栏布局一致性
|
||||
- 增强聊天组件响应式设计
|
||||
- 优化小屏幕代码块显示
|
||||
- 更新 OVMS 至 2025.3 正式版
|
||||
- 添加希腊语支持
|
||||
|
||||
问题修复:
|
||||
- 修复 GitHub Copilot gpt-5-codex 流式传输问题
|
||||
- 修复助手创建失败
|
||||
- 修复翻译自动复制功能
|
||||
- 修复小程序外部链接打开
|
||||
- 修复消息布局和溢出问题
|
||||
- 修复 API 密钥解析以保留空格
|
||||
- 修复不同导航栏布局中的 Agent 显示
|
||||
<!--LANG:END-->
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ export default defineConfig({
|
||||
output: {
|
||||
manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包
|
||||
inlineDynamicImports: true // 内联所有动态导入,这是关键配置
|
||||
},
|
||||
onwarn(warning, warn) {
|
||||
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
|
||||
warn(warning)
|
||||
}
|
||||
},
|
||||
sourcemap: isDev
|
||||
@@ -84,6 +88,7 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src'),
|
||||
'@shared': resolve('packages/shared'),
|
||||
'@types': resolve('src/renderer/src/types'),
|
||||
'@logger': resolve('src/renderer/src/services/LoggerService'),
|
||||
'@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'),
|
||||
'@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web'),
|
||||
@@ -111,6 +116,10 @@ export default defineConfig({
|
||||
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
|
||||
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
|
||||
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html')
|
||||
},
|
||||
onwarn(warning, warn) {
|
||||
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
|
||||
warn(warning)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import tseslint from '@electron-toolkit/eslint-config-ts'
|
||||
import eslint from '@eslint/js'
|
||||
import eslintReact from '@eslint-react/eslint-plugin'
|
||||
import { defineConfig } from 'eslint/config'
|
||||
import importZod from 'eslint-plugin-import-zod'
|
||||
import oxlint from 'eslint-plugin-oxlint'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
||||
@@ -11,11 +12,12 @@ export default defineConfig([
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
eslintReact.configs['recommended-typescript'],
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactHooks.configs.flat.recommended,
|
||||
{
|
||||
plugins: {
|
||||
'simple-import-sort': simpleImportSort,
|
||||
'unused-imports': unusedImports
|
||||
'unused-imports': unusedImports,
|
||||
'import-zod': importZod
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
@@ -25,6 +27,7 @@ export default defineConfig([
|
||||
'simple-import-sort/exports': 'error',
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'@eslint-react/no-prop-types': 'error',
|
||||
'import-zod/prefer-zod-namespace': 'error'
|
||||
}
|
||||
},
|
||||
// Configuration for ensuring compatibility with the original ESLint(8.x) rules
|
||||
|
||||
49
package.json
49
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.6.1",
|
||||
"version": "1.7.0-beta.2",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -43,15 +43,18 @@
|
||||
"release": "node scripts/version.js",
|
||||
"publish": "yarn build:check && yarn release patch push",
|
||||
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
|
||||
"generate:agents": "yarn workspace @cherry-studio/database agents",
|
||||
"agents:generate": "NODE_ENV='development' drizzle-kit generate --config src/main/services/agents/drizzle.config.ts",
|
||||
"agents:push": "NODE_ENV='development' drizzle-kit push --config src/main/services/agents/drizzle.config.ts",
|
||||
"agents:studio": "NODE_ENV='development' drizzle-kit studio --config src/main/services/agents/drizzle.config.ts",
|
||||
"agents:drop": "NODE_ENV='development' drizzle-kit drop --config src/main/services/agents/drizzle.config.ts",
|
||||
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
|
||||
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
|
||||
"analyze:main": "VISUALIZER_MAIN=true yarn build",
|
||||
"typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"",
|
||||
"typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false",
|
||||
"check:i18n": "tsx scripts/check-i18n.ts",
|
||||
"sync:i18n": "tsx scripts/sync-i18n.ts",
|
||||
"check:i18n": "dotenv -e .env -- tsx scripts/check-i18n.ts",
|
||||
"sync:i18n": "dotenv -e .env -- tsx scripts/sync-i18n.ts",
|
||||
"update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts",
|
||||
"auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts",
|
||||
"update:languages": "tsx scripts/update-languages.ts",
|
||||
@@ -65,7 +68,7 @@
|
||||
"test:e2e": "yarn playwright test",
|
||||
"test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --cache",
|
||||
"test:scripts": "vitest scripts",
|
||||
"lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn check:i18n",
|
||||
"lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn check:i18n && yarn format:check",
|
||||
"format": "biome format --write && biome lint --write",
|
||||
"format:check": "biome format && biome lint",
|
||||
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky",
|
||||
@@ -75,6 +78,7 @@
|
||||
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.1#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.1-d937b73fed.patch",
|
||||
"@libsql/client": "0.14.0",
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
||||
@@ -97,10 +101,10 @@
|
||||
"@agentic/exa": "^7.3.3",
|
||||
"@agentic/searxng": "^7.3.3",
|
||||
"@agentic/tavily": "^7.3.3",
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.21",
|
||||
"@ai-sdk/google-vertex": "^3.0.27",
|
||||
"@ai-sdk/mistral": "^2.0.14",
|
||||
"@ai-sdk/perplexity": "^2.0.9",
|
||||
"@ai-sdk/amazon-bedrock": "^3.0.35",
|
||||
"@ai-sdk/google-vertex": "^3.0.40",
|
||||
"@ai-sdk/mistral": "^2.0.19",
|
||||
"@ai-sdk/perplexity": "^2.0.13",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@anthropic-ai/sdk": "^0.41.0",
|
||||
"@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch",
|
||||
@@ -150,7 +154,9 @@
|
||||
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
||||
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
||||
"@opeoginni/github-copilot-openai-compatible": "patch:@opeoginni/github-copilot-openai-compatible@npm%3A0.1.18#~/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@shikijs/markdown-it": "^3.12.0",
|
||||
"@swc/plugin-styled-components": "^8.0.4",
|
||||
@@ -202,6 +208,7 @@
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"@types/tinycolor2": "^1",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/word-extractor": "^1",
|
||||
"@typescript/native-preview": "latest",
|
||||
"@uiw/codemirror-extensions-langs": "^4.25.1",
|
||||
@@ -215,7 +222,7 @@
|
||||
"@viz-js/lang-dot": "^1.0.5",
|
||||
"@viz-js/viz": "^3.14.0",
|
||||
"@xyflow/react": "^12.4.4",
|
||||
"ai": "^5.0.44",
|
||||
"ai": "^5.0.68",
|
||||
"antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch",
|
||||
"archiver": "^7.0.1",
|
||||
"async-mutex": "^0.5.0",
|
||||
@@ -238,9 +245,12 @@
|
||||
"docx": "^9.0.2",
|
||||
"dompurify": "^3.2.6",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"electron": "37.4.0",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"electron": "37.6.0",
|
||||
"electron-builder": "26.0.15",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-reload": "^2.0.0-alpha.1",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "6.6.4",
|
||||
"electron-vite": "4.0.0",
|
||||
@@ -249,10 +259,12 @@
|
||||
"emoji-picker-element": "^1.22.1",
|
||||
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-plugin-import-zod": "^1.2.0",
|
||||
"eslint-plugin-oxlint": "^1.15.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"express-validator": "^7.2.1",
|
||||
"fast-diff": "^1.3.0",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"fetch-socks": "1.3.2",
|
||||
@@ -285,15 +297,15 @@
|
||||
"notion-helper": "^1.3.22",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"openai": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
|
||||
"oxlint": "^1.15.0",
|
||||
"oxlint": "^1.22.0",
|
||||
"oxlint-tsgolint": "^0.2.0",
|
||||
"p-queue": "^8.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"playwright": "^1.52.0",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
"react-i18next": "^14.1.2",
|
||||
@@ -325,6 +337,7 @@
|
||||
"string-width": "^7.2.0",
|
||||
"striptags": "^3.2.0",
|
||||
"styled-components": "^6.1.11",
|
||||
"swr": "^2.3.6",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tar": "^7.4.3",
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
@@ -335,7 +348,7 @@
|
||||
"typescript": "~5.8.2",
|
||||
"undici": "6.21.2",
|
||||
"unified": "^11.0.5",
|
||||
"uuid": "^10.0.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"vitest": "^3.2.4",
|
||||
"webdav": "^5.8.0",
|
||||
@@ -359,6 +372,7 @@
|
||||
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
|
||||
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
|
||||
"atomically@npm:^1.7.0": "patch:atomically@npm%3A1.7.0#~/.yarn/patches/atomically-npm-1.7.0-e742e5293b.patch",
|
||||
"esbuild": "^0.25.0",
|
||||
"file-stream-rotator@npm:^0.6.1": "patch:file-stream-rotator@npm%3A0.6.1#~/.yarn/patches/file-stream-rotator-npm-0.6.1-eab45fb13d.patch",
|
||||
"libsql@npm:^0.4.4": "patch:libsql@npm%3A0.4.7#~/.yarn/patches/libsql-npm-0.4.7-444e260fb1.patch",
|
||||
"node-abi": "4.12.0",
|
||||
@@ -366,10 +380,11 @@
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch",
|
||||
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",
|
||||
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
|
||||
"tar-fs": "^2.1.4",
|
||||
"undici": "6.21.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
|
||||
"@ai-sdk/google@npm:2.0.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
|
||||
"@ai-sdk/google@npm:2.0.20": "patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cherrystudio/ai-core",
|
||||
"version": "1.0.0-alpha.18",
|
||||
"version": "1.0.1",
|
||||
"description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
@@ -36,15 +36,14 @@
|
||||
"ai": "^5.0.26"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.17",
|
||||
"@ai-sdk/azure": "^2.0.30",
|
||||
"@ai-sdk/deepseek": "^1.0.17",
|
||||
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch",
|
||||
"@ai-sdk/openai": "^2.0.30",
|
||||
"@ai-sdk/openai-compatible": "^1.0.17",
|
||||
"@ai-sdk/anthropic": "^2.0.27",
|
||||
"@ai-sdk/azure": "^2.0.49",
|
||||
"@ai-sdk/deepseek": "^1.0.23",
|
||||
"@ai-sdk/openai": "^2.0.48",
|
||||
"@ai-sdk/openai-compatible": "^1.0.22",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@ai-sdk/provider-utils": "^3.0.9",
|
||||
"@ai-sdk/xai": "^2.0.18",
|
||||
"@ai-sdk/provider-utils": "^3.0.12",
|
||||
"@ai-sdk/xai": "^2.0.26",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -261,22 +261,39 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
|
||||
return params
|
||||
}
|
||||
|
||||
context.mcpTools = params.tools
|
||||
// 分离 provider-defined 和其他类型的工具
|
||||
const providerDefinedTools: ToolSet = {}
|
||||
const promptTools: ToolSet = {}
|
||||
|
||||
// 构建系统提示符
|
||||
for (const [toolName, tool] of Object.entries(params.tools as ToolSet)) {
|
||||
if (tool.type === 'provider-defined') {
|
||||
// provider-defined 类型的工具保留在 tools 参数中
|
||||
providerDefinedTools[toolName] = tool
|
||||
} else {
|
||||
// 其他工具转换为 prompt 模式
|
||||
promptTools[toolName] = tool
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当有非 provider-defined 工具时才保存到 context
|
||||
if (Object.keys(promptTools).length > 0) {
|
||||
context.mcpTools = promptTools
|
||||
}
|
||||
|
||||
// 构建系统提示符(只包含非 provider-defined 工具)
|
||||
const userSystemPrompt = typeof params.system === 'string' ? params.system : ''
|
||||
const systemPrompt = buildSystemPrompt(userSystemPrompt, params.tools)
|
||||
const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools)
|
||||
let systemMessage: string | null = systemPrompt
|
||||
if (config.createSystemMessage) {
|
||||
// 🎯 如果用户提供了自定义处理函数,使用它
|
||||
systemMessage = config.createSystemMessage(systemPrompt, params, context)
|
||||
}
|
||||
|
||||
// 移除 tools,改为 prompt 模式
|
||||
// 保留 provider-defined tools,移除其他 tools
|
||||
const transformedParams = {
|
||||
...params,
|
||||
...(systemMessage ? { system: systemMessage } : {}),
|
||||
tools: undefined
|
||||
tools: Object.keys(providerDefinedTools).length > 0 ? providerDefinedTools : undefined
|
||||
}
|
||||
context.originalParams = transformedParams
|
||||
return transformedParams
|
||||
@@ -285,8 +302,9 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => {
|
||||
let textBuffer = ''
|
||||
// let stepId = ''
|
||||
|
||||
// 如果没有需要 prompt 模式处理的工具,直接返回原始流
|
||||
if (!context.mcpTools) {
|
||||
throw new Error('No tools available')
|
||||
return new TransformStream()
|
||||
}
|
||||
|
||||
// 从 context 中获取或初始化 usage 累加器
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { anthropic } from '@ai-sdk/anthropic'
|
||||
import { google } from '@ai-sdk/google'
|
||||
import { openai } from '@ai-sdk/openai'
|
||||
import { InferToolInput, InferToolOutput, type Tool } from 'ai'
|
||||
|
||||
import { ProviderOptionsMap } from '../../../options/types'
|
||||
import { OpenRouterSearchConfig } from './openrouter'
|
||||
@@ -14,6 +15,13 @@ export type AnthropicSearchConfig = NonNullable<Parameters<typeof anthropic.tool
|
||||
export type GoogleSearchConfig = NonNullable<Parameters<typeof google.tools.googleSearch>[0]>
|
||||
export type XAISearchConfig = NonNullable<ProviderOptionsMap['xai']['searchParameters']>
|
||||
|
||||
type NormalizeTool<T> = T extends Tool<infer INPUT, infer OUTPUT> ? Tool<INPUT, OUTPUT> : Tool<any, any>
|
||||
|
||||
type AnthropicWebSearchTool = NormalizeTool<ReturnType<typeof anthropic.tools.webSearch_20250305>>
|
||||
type OpenAIWebSearchTool = NormalizeTool<ReturnType<typeof openai.tools.webSearch>>
|
||||
type OpenAIChatWebSearchTool = NormalizeTool<ReturnType<typeof openai.tools.webSearchPreview>>
|
||||
type GoogleWebSearchTool = NormalizeTool<ReturnType<typeof google.tools.googleSearch>>
|
||||
|
||||
/**
|
||||
* 插件初始化时接收的完整配置对象
|
||||
*
|
||||
@@ -58,24 +66,31 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = {
|
||||
|
||||
export type WebSearchToolOutputSchema = {
|
||||
// Anthropic 工具 - 手动定义
|
||||
anthropicWebSearch: Array<{
|
||||
url: string
|
||||
title: string
|
||||
pageAge: string | null
|
||||
encryptedContent: string
|
||||
type: string
|
||||
}>
|
||||
anthropic: InferToolOutput<AnthropicWebSearchTool>
|
||||
|
||||
// OpenAI 工具 - 基于实际输出
|
||||
openaiWebSearch: {
|
||||
// TODO: 上游定义不规范,是unknown
|
||||
// openai: InferToolOutput<ReturnType<typeof openai.tools.webSearch>>
|
||||
openai: {
|
||||
status: 'completed' | 'failed'
|
||||
}
|
||||
'openai-chat': {
|
||||
status: 'completed' | 'failed'
|
||||
}
|
||||
|
||||
// Google 工具
|
||||
googleSearch: {
|
||||
// TODO: 上游定义不规范,是unknown
|
||||
// google: InferToolOutput<ReturnType<typeof google.tools.googleSearch>>
|
||||
google: {
|
||||
webSearchQueries?: string[]
|
||||
groundingChunks?: Array<{
|
||||
web?: { uri: string; title: string }
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export type WebSearchToolInputSchema = {
|
||||
anthropic: InferToolInput<AnthropicWebSearchTool>
|
||||
openai: InferToolInput<OpenAIWebSearchTool>
|
||||
google: InferToolInput<GoogleWebSearchTool>
|
||||
'openai-chat': InferToolInput<OpenAIChatWebSearchTool>
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { LanguageModelV2 } from '@ai-sdk/provider'
|
||||
import { createXai } from '@ai-sdk/xai'
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
||||
import { customProvider, Provider } from 'ai'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
|
||||
/**
|
||||
* 基础 Provider IDs
|
||||
|
||||
@@ -5,8 +5,8 @@ export enum IpcChannel {
|
||||
App_SetLanguage = 'app:set-language',
|
||||
App_SetEnableSpellCheck = 'app:set-enable-spell-check',
|
||||
App_SetSpellCheckLanguages = 'app:set-spell-check-languages',
|
||||
App_ShowUpdateDialog = 'app:show-update-dialog',
|
||||
App_CheckForUpdate = 'app:check-for-update',
|
||||
App_QuitAndInstall = 'app:quit-and-install',
|
||||
App_Reload = 'app:reload',
|
||||
App_Quit = 'app:quit',
|
||||
App_Info = 'app:info',
|
||||
@@ -34,6 +34,7 @@ export enum IpcChannel {
|
||||
App_GetBinaryPath = 'app:get-binary-path',
|
||||
App_InstallUvBinary = 'app:install-uv-binary',
|
||||
App_InstallBunBinary = 'app:install-bun-binary',
|
||||
App_InstallOvmsBinary = 'app:install-ovms-binary',
|
||||
App_LogToMain = 'app:log-to-main',
|
||||
App_SaveData = 'app:save-data',
|
||||
App_GetDiskInfo = 'app:get-disk-info',
|
||||
@@ -52,6 +53,7 @@ export enum IpcChannel {
|
||||
|
||||
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
|
||||
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
|
||||
Webview_SearchHotkey = 'webview:search-hotkey',
|
||||
|
||||
// Open
|
||||
Open_Path = 'open:path',
|
||||
@@ -90,6 +92,10 @@ export enum IpcChannel {
|
||||
// Python
|
||||
Python_Execute = 'python:execute',
|
||||
|
||||
// agent messages
|
||||
AgentMessage_PersistExchange = 'agent-message:persist-exchange',
|
||||
AgentMessage_GetHistory = 'agent-message:get-history',
|
||||
|
||||
//copilot
|
||||
Copilot_GetAuthMessage = 'copilot:get-auth-message',
|
||||
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
|
||||
@@ -183,6 +189,7 @@ export enum IpcChannel {
|
||||
File_ValidateNotesDirectory = 'file:validateNotesDirectory',
|
||||
File_StartWatcher = 'file:startWatcher',
|
||||
File_StopWatcher = 'file:stopWatcher',
|
||||
File_ShowInFolder = 'file:showInFolder',
|
||||
|
||||
// file service
|
||||
FileService_Upload = 'file-service:upload',
|
||||
@@ -220,6 +227,7 @@ export enum IpcChannel {
|
||||
// system
|
||||
System_GetDeviceType = 'system:getDeviceType',
|
||||
System_GetHostname = 'system:getHostname',
|
||||
System_GetCpuName = 'system:getCpuName',
|
||||
|
||||
// DevTools
|
||||
System_ToggleDevTools = 'system:toggleDevTools',
|
||||
@@ -227,7 +235,6 @@ export enum IpcChannel {
|
||||
// events
|
||||
BackupProgress = 'backup-progress',
|
||||
ThemeUpdated = 'theme:updated',
|
||||
UpdateDownloadedCancelled = 'update-downloaded-cancelled',
|
||||
RestoreProgress = 'restore-progress',
|
||||
UpdateError = 'update-error',
|
||||
UpdateAvailable = 'update-available',
|
||||
@@ -310,6 +317,7 @@ export enum IpcChannel {
|
||||
ApiServer_Stop = 'api-server:stop',
|
||||
ApiServer_Restart = 'api-server:restart',
|
||||
ApiServer_GetStatus = 'api-server:get-status',
|
||||
// NOTE: This api is not be used.
|
||||
ApiServer_GetConfig = 'api-server:get-config',
|
||||
|
||||
// Anthropic OAuth
|
||||
@@ -329,6 +337,16 @@ export enum IpcChannel {
|
||||
|
||||
// OCR
|
||||
OCR_ocr = 'ocr:ocr',
|
||||
OCR_ListProviders = 'ocr:list-providers',
|
||||
|
||||
// OVMS
|
||||
Ovms_AddModel = 'ovms:add-model',
|
||||
Ovms_StopAddModel = 'ovms:stop-addmodel',
|
||||
Ovms_GetModels = 'ovms:get-models',
|
||||
Ovms_IsRunning = 'ovms:is-running',
|
||||
Ovms_GetStatus = 'ovms:get-status',
|
||||
Ovms_RunOVMS = 'ovms:run-ovms',
|
||||
Ovms_StopOVMS = 'ovms:stop-ovms',
|
||||
|
||||
// CherryAI
|
||||
Cherryai_GetSignature = 'cherryai:get-signature'
|
||||
|
||||
12
packages/shared/agents/claudecode/types.ts
Normal file
12
packages/shared/agents/claudecode/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages'
|
||||
|
||||
export type ClaudeCodeRawValue =
|
||||
| {
|
||||
type: string
|
||||
session_id: string
|
||||
slash_commands: string[]
|
||||
tools: string[]
|
||||
raw: Extract<SDKMessage, { type: 'system' }>
|
||||
}
|
||||
| ContentBlockParam
|
||||
170
packages/shared/anthropic/index.ts
Normal file
170
packages/shared/anthropic/index.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* @fileoverview Shared Anthropic AI client utilities for Cherry Studio
|
||||
*
|
||||
* This module provides functions for creating Anthropic SDK clients with different
|
||||
* authentication methods (OAuth, API key) and building Claude Code system messages.
|
||||
* It supports both standard Anthropic API and Anthropic Vertex AI endpoints.
|
||||
*
|
||||
* This shared module can be used by both main and renderer processes.
|
||||
*/
|
||||
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { TextBlockParam } from '@anthropic-ai/sdk/resources'
|
||||
import { loggerService } from '@logger'
|
||||
import { Provider } from '@types'
|
||||
import type { ModelMessage } from 'ai'
|
||||
|
||||
const logger = loggerService.withContext('anthropic-sdk')
|
||||
|
||||
const defaultClaudeCodeSystemPrompt = `You are Claude Code, Anthropic's official CLI for Claude.`
|
||||
|
||||
const defaultClaudeCodeSystem: Array<TextBlockParam> = [
|
||||
{
|
||||
type: 'text',
|
||||
text: defaultClaudeCodeSystemPrompt
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Creates and configures an Anthropic SDK client based on the provider configuration.
|
||||
*
|
||||
* This function supports two authentication methods:
|
||||
* 1. OAuth: Uses OAuth tokens passed as parameter
|
||||
* 2. API Key: Uses traditional API key authentication
|
||||
*
|
||||
* For OAuth authentication, it includes Claude Code specific headers and beta features.
|
||||
* For API key authentication, it uses the provider's configuration with custom headers.
|
||||
*
|
||||
* @param provider - The provider configuration containing authentication details
|
||||
* @param oauthToken - Optional OAuth token for OAuth authentication
|
||||
* @returns An initialized Anthropic or AnthropicVertex client
|
||||
* @throws Error when OAuth token is not available for OAuth authentication
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // OAuth authentication
|
||||
* const oauthProvider = { authType: 'oauth' };
|
||||
* const oauthClient = getSdkClient(oauthProvider, 'oauth-token-here');
|
||||
*
|
||||
* // API key authentication
|
||||
* const apiKeyProvider = {
|
||||
* authType: 'apikey',
|
||||
* apiKey: 'your-api-key',
|
||||
* apiHost: 'https://api.anthropic.com'
|
||||
* };
|
||||
* const apiKeyClient = getSdkClient(apiKeyProvider);
|
||||
* ```
|
||||
*/
|
||||
export function getSdkClient(
|
||||
provider: Provider,
|
||||
oauthToken?: string | null,
|
||||
extraHeaders?: Record<string, string | string[]>
|
||||
): Anthropic {
|
||||
if (provider.authType === 'oauth') {
|
||||
if (!oauthToken) {
|
||||
throw new Error('OAuth token is not available')
|
||||
}
|
||||
return new Anthropic({
|
||||
authToken: oauthToken,
|
||||
baseURL: 'https://api.anthropic.com',
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta':
|
||||
'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14',
|
||||
'anthropic-dangerous-direct-browser-access': 'true',
|
||||
'user-agent': 'claude-cli/1.0.118 (external, sdk-ts)',
|
||||
'x-app': 'cli',
|
||||
'x-stainless-retry-count': '0',
|
||||
'x-stainless-timeout': '600',
|
||||
'x-stainless-lang': 'js',
|
||||
'x-stainless-package-version': '0.60.0',
|
||||
'x-stainless-os': 'MacOS',
|
||||
'x-stainless-arch': 'arm64',
|
||||
'x-stainless-runtime': 'node',
|
||||
'x-stainless-runtime-version': 'v22.18.0',
|
||||
...extraHeaders
|
||||
}
|
||||
})
|
||||
}
|
||||
const baseURL =
|
||||
provider.type === 'anthropic'
|
||||
? provider.apiHost
|
||||
: (provider.anthropicApiHost && provider.anthropicApiHost.trim()) || provider.apiHost
|
||||
|
||||
logger.debug('Anthropic API baseURL', { baseURL, providerId: provider.id })
|
||||
|
||||
if (provider.id === 'aihubmix') {
|
||||
return new Anthropic({
|
||||
apiKey: provider.apiKey,
|
||||
baseURL,
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders: {
|
||||
'anthropic-beta': 'output-128k-2025-02-19',
|
||||
'APP-Code': 'MLTG2087',
|
||||
...provider.extra_headers,
|
||||
...extraHeaders
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return new Anthropic({
|
||||
apiKey: provider.apiKey,
|
||||
authToken: provider.apiKey,
|
||||
baseURL,
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders: {
|
||||
'anthropic-beta': 'output-128k-2025-02-19',
|
||||
...provider.extra_headers
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and prepends the Claude Code system message to user-provided system messages.
|
||||
*
|
||||
* This function ensures that all interactions with Claude include the official Claude Code
|
||||
* system prompt, which identifies the assistant as "Claude Code, Anthropic's official CLI for Claude."
|
||||
*
|
||||
* The function handles three cases:
|
||||
* 1. No system message provided: Returns only the default Claude Code system message
|
||||
* 2. String system message: Converts to array format and prepends Claude Code message
|
||||
* 3. Array system message: Checks if Claude Code message exists and prepends if missing
|
||||
*
|
||||
* @param system - Optional user-provided system message (string or TextBlockParam array)
|
||||
* @returns Combined system message with Claude Code prompt prepended
|
||||
*
|
||||
* ```
|
||||
*/
|
||||
export function buildClaudeCodeSystemMessage(system?: string | Array<TextBlockParam>): Array<TextBlockParam> {
|
||||
if (!system) {
|
||||
return defaultClaudeCodeSystem
|
||||
}
|
||||
|
||||
if (typeof system === 'string') {
|
||||
if (system.trim() === defaultClaudeCodeSystemPrompt || system.trim() === '') {
|
||||
return defaultClaudeCodeSystem
|
||||
} else {
|
||||
return [...defaultClaudeCodeSystem, { type: 'text', text: system }]
|
||||
}
|
||||
}
|
||||
if (Array.isArray(system)) {
|
||||
const firstSystem = system[0]
|
||||
if (firstSystem.type === 'text' && firstSystem.text.trim() === defaultClaudeCodeSystemPrompt) {
|
||||
return system
|
||||
} else {
|
||||
return [...defaultClaudeCodeSystem, ...system]
|
||||
}
|
||||
}
|
||||
|
||||
return defaultClaudeCodeSystem
|
||||
}
|
||||
|
||||
export function buildClaudeCodeSystemModelMessage(system?: string | Array<TextBlockParam>): Array<ModelMessage> {
|
||||
const textBlocks = buildClaudeCodeSystemMessage(system)
|
||||
return textBlocks.map((block) => ({
|
||||
role: 'system',
|
||||
content: block.text
|
||||
}))
|
||||
}
|
||||
@@ -217,7 +217,8 @@ export enum codeTools {
|
||||
claudeCode = 'claude-code',
|
||||
geminiCli = 'gemini-cli',
|
||||
openaiCodex = 'openai-codex',
|
||||
iFlowCli = 'iflow-cli'
|
||||
iFlowCli = 'iflow-cli',
|
||||
githubCopilotCli = 'github-copilot-cli'
|
||||
}
|
||||
|
||||
export enum terminalApps {
|
||||
|
||||
@@ -22,3 +22,12 @@ export type MCPProgressEvent = {
|
||||
callId: string
|
||||
progress: number // 0-1 range
|
||||
}
|
||||
|
||||
export type WebviewKeyEvent = {
|
||||
webviewId: number
|
||||
key: string
|
||||
control: boolean
|
||||
meta: boolean
|
||||
shift: boolean
|
||||
alt: boolean
|
||||
}
|
||||
|
||||
53
resources/database/drizzle/0000_confused_wendigo.sql
Normal file
53
resources/database/drizzle/0000_confused_wendigo.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `migrations` (
|
||||
`version` integer PRIMARY KEY NOT NULL,
|
||||
`tag` text NOT NULL,
|
||||
`executed_at` integer NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE `agents` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`description` text,
|
||||
`accessible_paths` text,
|
||||
`instructions` text,
|
||||
`model` text NOT NULL,
|
||||
`plan_model` text,
|
||||
`small_model` text,
|
||||
`mcps` text,
|
||||
`allowed_tools` text,
|
||||
`configuration` text,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL
|
||||
);
|
||||
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`agent_type` text NOT NULL,
|
||||
`agent_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`description` text,
|
||||
`accessible_paths` text,
|
||||
`instructions` text,
|
||||
`model` text NOT NULL,
|
||||
`plan_model` text,
|
||||
`small_model` text,
|
||||
`mcps` text,
|
||||
`allowed_tools` text,
|
||||
`configuration` text,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL
|
||||
);
|
||||
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `session_messages` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`session_id` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`metadata` text,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL
|
||||
);
|
||||
1
resources/database/drizzle/0001_woozy_captain_flint.sql
Normal file
1
resources/database/drizzle/0001_woozy_captain_flint.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `session_messages` ADD `agent_session_id` text DEFAULT '';
|
||||
331
resources/database/drizzle/meta/0000_snapshot.json
Normal file
331
resources/database/drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,331 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "35efb412-0230-4767-9c76-7b7c4d40369f",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"agents": {
|
||||
"name": "agents",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accessible_paths": {
|
||||
"name": "accessible_paths",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"instructions": {
|
||||
"name": "instructions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plan_model": {
|
||||
"name": "plan_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"small_model": {
|
||||
"name": "small_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mcps": {
|
||||
"name": "mcps",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"allowed_tools": {
|
||||
"name": "allowed_tools",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"configuration": {
|
||||
"name": "configuration",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"session_messages": {
|
||||
"name": "session_messages",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"migrations": {
|
||||
"name": "migrations",
|
||||
"columns": {
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tag": {
|
||||
"name": "tag",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"executed_at": {
|
||||
"name": "executed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_type": {
|
||||
"name": "agent_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accessible_paths": {
|
||||
"name": "accessible_paths",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"instructions": {
|
||||
"name": "instructions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plan_model": {
|
||||
"name": "plan_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"small_model": {
|
||||
"name": "small_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mcps": {
|
||||
"name": "mcps",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"allowed_tools": {
|
||||
"name": "allowed_tools",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"configuration": {
|
||||
"name": "configuration",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
339
resources/database/drizzle/meta/0001_snapshot.json
Normal file
339
resources/database/drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,339 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "dabab6db-a2cd-4e96-b06e-6cb87d445a87",
|
||||
"prevId": "35efb412-0230-4767-9c76-7b7c4d40369f",
|
||||
"tables": {
|
||||
"agents": {
|
||||
"name": "agents",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accessible_paths": {
|
||||
"name": "accessible_paths",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"instructions": {
|
||||
"name": "instructions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plan_model": {
|
||||
"name": "plan_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"small_model": {
|
||||
"name": "small_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mcps": {
|
||||
"name": "mcps",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"allowed_tools": {
|
||||
"name": "allowed_tools",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"configuration": {
|
||||
"name": "configuration",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"session_messages": {
|
||||
"name": "session_messages",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_session_id": {
|
||||
"name": "agent_session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"migrations": {
|
||||
"name": "migrations",
|
||||
"columns": {
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tag": {
|
||||
"name": "tag",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"executed_at": {
|
||||
"name": "executed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_type": {
|
||||
"name": "agent_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accessible_paths": {
|
||||
"name": "accessible_paths",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"instructions": {
|
||||
"name": "instructions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plan_model": {
|
||||
"name": "plan_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"small_model": {
|
||||
"name": "small_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mcps": {
|
||||
"name": "mcps",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"allowed_tools": {
|
||||
"name": "allowed_tools",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"configuration": {
|
||||
"name": "configuration",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
20
resources/database/drizzle/meta/_journal.json
Normal file
20
resources/database/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1758091173882,
|
||||
"tag": "0000_confused_wendigo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1758187378775,
|
||||
"tag": "0001_woozy_captain_flint",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
const https = require('https')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execSync } = require('child_process')
|
||||
|
||||
/**
|
||||
* Downloads a file from a URL with redirect handling
|
||||
@@ -32,4 +34,39 @@ async function downloadWithRedirects(url, destinationPath) {
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { downloadWithRedirects }
|
||||
/**
|
||||
* Downloads a file using PowerShell Invoke-WebRequest command
|
||||
* @param {string} url The URL to download from
|
||||
* @param {string} destinationPath The path to save the file to
|
||||
* @returns {Promise<boolean>} Promise that resolves to true if download succeeds
|
||||
*/
|
||||
async function downloadWithPowerShell(url, destinationPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Only support windows platform for PowerShell download
|
||||
if (process.platform !== 'win32') {
|
||||
return reject(new Error('PowerShell download is only supported on Windows'))
|
||||
}
|
||||
|
||||
const outputDir = path.dirname(destinationPath)
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
|
||||
// PowerShell command to download the file with progress disabled for faster download
|
||||
const psCommand = `powershell -Command "$ProgressPreference = 'SilentlyContinue'; Invoke-WebRequest '${url}' -OutFile '${destinationPath}'"`
|
||||
|
||||
console.log(`Downloading with PowerShell: ${url}`)
|
||||
execSync(psCommand, { stdio: 'inherit' })
|
||||
|
||||
if (fs.existsSync(destinationPath)) {
|
||||
console.log(`Download completed: ${destinationPath}`)
|
||||
resolve(true)
|
||||
} else {
|
||||
reject(new Error('Download failed: File not found after download'))
|
||||
}
|
||||
} catch (error) {
|
||||
reject(new Error(`PowerShell download failed: ${error.message}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { downloadWithRedirects, downloadWithPowerShell }
|
||||
|
||||
263
resources/scripts/install-ovms.js
Normal file
263
resources/scripts/install-ovms.js
Normal file
@@ -0,0 +1,263 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
const { execSync } = require('child_process')
|
||||
const { downloadWithPowerShell } = require('./download')
|
||||
|
||||
// Base URL for downloading OVMS binaries
|
||||
const OVMS_RELEASE_BASE_URL =
|
||||
'https://storage.openvinotoolkit.org/repositories/openvino_model_server/packages/2025.3.0/ovms_windows_python_on.zip'
|
||||
const OVMS_EX_URL = 'https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/ovms_25.3_ex.zip'
|
||||
|
||||
/**
|
||||
* error code:
|
||||
* 101: Unsupported CPU (not Intel Ultra)
|
||||
* 102: Unsupported platform (not Windows)
|
||||
* 103: Download failed
|
||||
* 104: Installation failed
|
||||
* 105: Failed to create ovdnd.exe
|
||||
* 106: Failed to create run.bat
|
||||
* 110: Cleanup of old installation failed
|
||||
*/
|
||||
|
||||
/**
|
||||
* Clean old OVMS installation if it exists
|
||||
*/
|
||||
function cleanOldOvmsInstallation() {
|
||||
console.log('Cleaning the existing OVMS installation...')
|
||||
const csDir = path.join(os.homedir(), '.cherrystudio')
|
||||
const csOvmsDir = path.join(csDir, 'ovms')
|
||||
if (fs.existsSync(csOvmsDir)) {
|
||||
try {
|
||||
fs.rmSync(csOvmsDir, { recursive: true })
|
||||
} catch (error) {
|
||||
console.warn(`Failed to clean up old OVMS installation: ${error.message}`)
|
||||
return 110
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Install OVMS Base package
|
||||
*/
|
||||
async function installOvmsBase() {
|
||||
// Download the base package
|
||||
const tempdir = os.tmpdir()
|
||||
const tempFilename = path.join(tempdir, 'ovms.zip')
|
||||
|
||||
try {
|
||||
console.log(`Downloading OVMS Base Package from ${OVMS_RELEASE_BASE_URL} to ${tempFilename}...`)
|
||||
|
||||
// Try PowerShell download first, fallback to Node.js download if it fails
|
||||
await downloadWithPowerShell(OVMS_RELEASE_BASE_URL, tempFilename)
|
||||
console.log(`Successfully downloaded from: ${OVMS_RELEASE_BASE_URL}`)
|
||||
} catch (error) {
|
||||
console.error(`Download OVMS Base failed: ${error.message}`)
|
||||
fs.unlinkSync(tempFilename)
|
||||
return 103
|
||||
}
|
||||
|
||||
// unzip the base package to the target directory
|
||||
const csDir = path.join(os.homedir(), '.cherrystudio')
|
||||
const csOvmsDir = path.join(csDir, 'ovms')
|
||||
fs.mkdirSync(csOvmsDir, { recursive: true })
|
||||
|
||||
try {
|
||||
// Use tar.exe to extract the ZIP file
|
||||
console.log(`Extracting OVMS Base to ${csOvmsDir}...`)
|
||||
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
|
||||
console.log(`OVMS extracted to ${csOvmsDir}`)
|
||||
|
||||
// Clean up temporary file
|
||||
fs.unlinkSync(tempFilename)
|
||||
console.log(`Installation directory: ${csDir}`)
|
||||
} catch (error) {
|
||||
console.error(`Error installing OVMS: ${error.message}`)
|
||||
fs.unlinkSync(tempFilename)
|
||||
return 104
|
||||
}
|
||||
|
||||
const csOvmsBinDir = path.join(csOvmsDir, 'ovms')
|
||||
// copy ovms.exe to ovdnd.exe
|
||||
try {
|
||||
fs.copyFileSync(path.join(csOvmsBinDir, 'ovms.exe'), path.join(csOvmsBinDir, 'ovdnd.exe'))
|
||||
console.log('Copied ovms.exe to ovdnd.exe')
|
||||
} catch (error) {
|
||||
console.error(`Error copying ovms.exe to ovdnd.exe: ${error.message}`)
|
||||
return 105
|
||||
}
|
||||
|
||||
// copy {csOvmsBinDir}/setupvars.bat to {csOvmsBinDir}/run.bat, and append the following lines to run.bat:
|
||||
// del %USERPROFILE%\.cherrystudio\ovms_log.log
|
||||
// ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\.cherrystudio\ovms_log.log
|
||||
const runBatPath = path.join(csOvmsBinDir, 'run.bat')
|
||||
try {
|
||||
fs.copyFileSync(path.join(csOvmsBinDir, 'setupvars.bat'), runBatPath)
|
||||
fs.appendFileSync(runBatPath, '\r\n')
|
||||
fs.appendFileSync(runBatPath, 'del %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n')
|
||||
fs.appendFileSync(
|
||||
runBatPath,
|
||||
'ovms.exe --config_path models/config.json --rest_port 8000 --log_level DEBUG --log_path %USERPROFILE%\\.cherrystudio\\ovms_log.log\r\n'
|
||||
)
|
||||
console.log(`Created run.bat at: ${runBatPath}`)
|
||||
} catch (error) {
|
||||
console.error(`Error creating run.bat: ${error.message}`)
|
||||
return 106
|
||||
}
|
||||
|
||||
// create {csOvmsBinDir}/models/config.json with content '{"model_config_list": []}'
|
||||
const configJsonPath = path.join(csOvmsBinDir, 'models', 'config.json')
|
||||
fs.mkdirSync(path.dirname(configJsonPath), { recursive: true })
|
||||
fs.writeFileSync(configJsonPath, '{"mediapipe_config_list":[],"model_config_list":[]}')
|
||||
console.log(`Created config file: ${configJsonPath}`)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Install OVMS Extra package
|
||||
*/
|
||||
async function installOvmsExtra() {
|
||||
// Download the extra package
|
||||
const tempdir = os.tmpdir()
|
||||
const tempFilename = path.join(tempdir, 'ovms_ex.zip')
|
||||
|
||||
try {
|
||||
console.log(`Downloading OVMS Extra Package from ${OVMS_EX_URL} to ${tempFilename}...`)
|
||||
|
||||
// Try PowerShell download first, fallback to Node.js download if it fails
|
||||
await downloadWithPowerShell(OVMS_EX_URL, tempFilename)
|
||||
console.log(`Successfully downloaded from: ${OVMS_EX_URL}`)
|
||||
} catch (error) {
|
||||
console.error(`Download OVMS Extra failed: ${error.message}`)
|
||||
fs.unlinkSync(tempFilename)
|
||||
return 103
|
||||
}
|
||||
|
||||
// unzip the extra package to the target directory
|
||||
const csDir = path.join(os.homedir(), '.cherrystudio')
|
||||
const csOvmsDir = path.join(csDir, 'ovms')
|
||||
|
||||
try {
|
||||
// Use tar.exe to extract the ZIP file
|
||||
console.log(`Extracting OVMS Extra to ${csOvmsDir}...`)
|
||||
execSync(`tar -xf ${tempFilename} -C ${csOvmsDir}`, { stdio: 'inherit' })
|
||||
console.log(`OVMS extracted to ${csOvmsDir}`)
|
||||
|
||||
// Clean up temporary file
|
||||
fs.unlinkSync(tempFilename)
|
||||
console.log(`Installation directory: ${csDir}`)
|
||||
} catch (error) {
|
||||
console.error(`Error installing OVMS Extra: ${error.message}`)
|
||||
fs.unlinkSync(tempFilename)
|
||||
return 104
|
||||
}
|
||||
|
||||
// apply ovms patch, copy all files in {csOvmsDir}/patch/ovms to {csOvmsDir}/ovms with overwrite mode
|
||||
const patchDir = path.join(csOvmsDir, 'patch', 'ovms')
|
||||
const csOvmsBinDir = path.join(csOvmsDir, 'ovms')
|
||||
try {
|
||||
const files = fs.readdirSync(patchDir)
|
||||
files.forEach((file) => {
|
||||
const srcPath = path.join(patchDir, file)
|
||||
const destPath = path.join(csOvmsBinDir, file)
|
||||
fs.copyFileSync(srcPath, destPath)
|
||||
console.log(`Applied patch file: ${file}`)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Error applying OVMS patch: ${error.message}`)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CPU Name and ID
|
||||
*/
|
||||
function getCpuInfo() {
|
||||
const cpuInfo = {
|
||||
name: '',
|
||||
id: ''
|
||||
}
|
||||
|
||||
// Use PowerShell to get CPU information
|
||||
try {
|
||||
const psCommand = `powershell -Command "Get-CimInstance -ClassName Win32_Processor | Select-Object Name, DeviceID | ConvertTo-Json"`
|
||||
const psOutput = execSync(psCommand).toString()
|
||||
const cpuData = JSON.parse(psOutput)
|
||||
|
||||
if (Array.isArray(cpuData)) {
|
||||
cpuInfo.name = cpuData[0].Name || ''
|
||||
cpuInfo.id = cpuData[0].DeviceID || ''
|
||||
} else {
|
||||
cpuInfo.name = cpuData.Name || ''
|
||||
cpuInfo.id = cpuData.DeviceID || ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to get CPU info: ${error.message}`)
|
||||
}
|
||||
|
||||
return cpuInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to install OVMS
|
||||
*/
|
||||
async function installOvms() {
|
||||
const platform = os.platform()
|
||||
console.log(`Detected platform: ${platform}`)
|
||||
|
||||
const cpuName = getCpuInfo().name
|
||||
console.log(`CPU Name: ${cpuName}`)
|
||||
|
||||
// Check if CPU name contains "Ultra"
|
||||
if (!cpuName.toLowerCase().includes('intel') || !cpuName.toLowerCase().includes('ultra')) {
|
||||
console.error('OVMS installation requires an Intel(R) Core(TM) Ultra CPU.')
|
||||
return 101
|
||||
}
|
||||
|
||||
// only support windows
|
||||
if (platform !== 'win32') {
|
||||
console.error('OVMS installation is only supported on Windows.')
|
||||
return 102
|
||||
}
|
||||
|
||||
// Clean old installation if it exists
|
||||
const cleanupCode = cleanOldOvmsInstallation()
|
||||
if (cleanupCode !== 0) {
|
||||
console.error(`OVMS cleanup failed with code: ${cleanupCode}`)
|
||||
return cleanupCode
|
||||
}
|
||||
|
||||
const installBaseCode = await installOvmsBase()
|
||||
if (installBaseCode !== 0) {
|
||||
console.error(`OVMS Base installation failed with code: ${installBaseCode}`)
|
||||
cleanOldOvmsInstallation()
|
||||
return installBaseCode
|
||||
}
|
||||
|
||||
const installExtraCode = await installOvmsExtra()
|
||||
if (installExtraCode !== 0) {
|
||||
console.error(`OVMS Extra installation failed with code: ${installExtraCode}`)
|
||||
return installExtraCode
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// Run the installation
|
||||
installOvms()
|
||||
.then((retcode) => {
|
||||
if (retcode === 0) {
|
||||
console.log('OVMS installation successful')
|
||||
} else {
|
||||
console.error('OVMS installation failed')
|
||||
}
|
||||
process.exit(retcode)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('OVMS installation failed:', error)
|
||||
process.exit(100)
|
||||
})
|
||||
@@ -9,8 +9,9 @@ import * as path from 'path'
|
||||
|
||||
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
||||
const translateDir = path.join(__dirname, '../src/renderer/src/i18n/translate')
|
||||
const baseLocale = 'zh-cn'
|
||||
const baseLocale = process.env.BASE_LOCALE ?? 'zh-cn'
|
||||
const baseFileName = `${baseLocale}.json`
|
||||
const baseLocalePath = path.join(__dirname, '../src/renderer/src/i18n/locales', baseFileName)
|
||||
|
||||
type I18NValue = string | { [key: string]: I18NValue }
|
||||
type I18N = { [key: string]: I18NValue }
|
||||
@@ -105,6 +106,9 @@ const translateRecursively = async (originObj: I18N, systemPrompt: string): Prom
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
if (!fs.existsSync(baseLocalePath)) {
|
||||
throw new Error(`${baseLocalePath} not found.`)
|
||||
}
|
||||
const localeFiles = fs
|
||||
.readdirSync(localesDir)
|
||||
.filter((file) => file.endsWith('.json') && file !== baseFileName)
|
||||
|
||||
@@ -35,6 +35,9 @@ const allX64 = {
|
||||
'@napi-rs/system-ocr-win32-x64-msvc': '1.0.2'
|
||||
}
|
||||
|
||||
const claudeCodeVenderPath = '@anthropic-ai/claude-agent-sdk/vendor'
|
||||
const claudeCodeVenders = ['arm64-darwin', 'arm64-linux', 'x64-darwin', 'x64-linux', 'x64-win32']
|
||||
|
||||
const platformToArch = {
|
||||
mac: 'darwin',
|
||||
windows: 'win32',
|
||||
@@ -46,9 +49,6 @@ exports.default = async function (context) {
|
||||
const archType = arch === Arch.arm64 ? 'arm64' : 'x64'
|
||||
const platform = context.packager.platform.name
|
||||
|
||||
const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**')
|
||||
const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*')
|
||||
|
||||
const downloadPackages = async (packages) => {
|
||||
console.log('downloading packages ......')
|
||||
const downloadPromises = []
|
||||
@@ -67,25 +67,39 @@ exports.default = async function (context) {
|
||||
await Promise.all(downloadPromises)
|
||||
}
|
||||
|
||||
const changeFilters = async (packages, filtersToExclude, filtersToInclude) => {
|
||||
await downloadPackages(packages)
|
||||
const changeFilters = async (filtersToExclude, filtersToInclude) => {
|
||||
// remove filters for the target architecture (allow inclusion)
|
||||
|
||||
let filters = context.packager.config.files[0].filter
|
||||
filters = filters.filter((filter) => !filtersToInclude.includes(filter))
|
||||
|
||||
// add filters for other architectures (exclude them)
|
||||
filters.push(...filtersToExclude)
|
||||
|
||||
context.packager.config.files[0].filter = filters
|
||||
}
|
||||
|
||||
if (arch === Arch.arm64) {
|
||||
await changeFilters(allArm64, x64Filters, arm64Filters)
|
||||
return
|
||||
}
|
||||
await downloadPackages(arch === Arch.arm64 ? allArm64 : allX64)
|
||||
|
||||
if (arch === Arch.x64) {
|
||||
await changeFilters(allX64, arm64Filters, x64Filters)
|
||||
return
|
||||
const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**')
|
||||
const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*')
|
||||
const excludeClaudeCodeRipgrepFilters = claudeCodeVenders
|
||||
.filter((f) => f !== `${archType}-${platformToArch[platform]}`)
|
||||
.map((f) => '!node_modules/' + claudeCodeVenderPath + '/ripgrep/' + f + '/**')
|
||||
const excludeClaudeCodeJBPlutins = ['!node_modules/' + claudeCodeVenderPath + '/' + 'claude-code-jetbrains-plugin']
|
||||
|
||||
const includeClaudeCodeFilters = [
|
||||
'!node_modules/' + claudeCodeVenderPath + '/ripgrep/' + `${archType}-${platformToArch[platform]}/**`
|
||||
]
|
||||
|
||||
if (arch === Arch.arm64) {
|
||||
await changeFilters(
|
||||
[...x64Filters, ...excludeClaudeCodeRipgrepFilters, ...excludeClaudeCodeJBPlutins],
|
||||
[...arm64Filters, ...includeClaudeCodeFilters]
|
||||
)
|
||||
} else {
|
||||
await changeFilters(
|
||||
[...arm64Filters, ...excludeClaudeCodeRipgrepFilters, ...excludeClaudeCodeJBPlutins],
|
||||
[...x64Filters, ...includeClaudeCodeFilters]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as path from 'path'
|
||||
import { sortedObjectByKeys } from './sort'
|
||||
|
||||
const translationsDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
||||
const baseLocale = 'zh-cn'
|
||||
const baseLocale = process.env.BASE_LOCALE ?? 'zh-cn'
|
||||
const baseFileName = `${baseLocale}.json`
|
||||
const baseFilePath = path.join(translationsDir, baseFileName)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { sortedObjectByKeys } from './sort'
|
||||
|
||||
const localesDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
||||
const translateDir = path.join(__dirname, '../src/renderer/src/i18n/translate')
|
||||
const baseLocale = 'zh-cn'
|
||||
const baseLocale = process.env.BASE_LOCALE ?? 'zh-cn'
|
||||
const baseFileName = `${baseLocale}.json`
|
||||
const baseFilePath = path.join(localesDir, baseFileName)
|
||||
|
||||
|
||||
@@ -3,23 +3,42 @@ import cors from 'cors'
|
||||
import express from 'express'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { LONG_POLL_TIMEOUT_MS } from './config/timeouts'
|
||||
import { authMiddleware } from './middleware/auth'
|
||||
import { errorHandler } from './middleware/error'
|
||||
import { setupOpenAPIDocumentation } from './middleware/openapi'
|
||||
import { agentsRoutes } from './routes/agents'
|
||||
import { chatRoutes } from './routes/chat'
|
||||
import { mcpRoutes } from './routes/mcp'
|
||||
import { messagesProviderRoutes, messagesRoutes } from './routes/messages'
|
||||
import { modelsRoutes } from './routes/models'
|
||||
|
||||
const logger = loggerService.withContext('ApiServer')
|
||||
|
||||
const extendMessagesTimeout: express.RequestHandler = (req, res, next) => {
|
||||
req.setTimeout(LONG_POLL_TIMEOUT_MS)
|
||||
res.setTimeout(LONG_POLL_TIMEOUT_MS)
|
||||
next()
|
||||
}
|
||||
|
||||
const app = express()
|
||||
app.use(
|
||||
express.json({
|
||||
limit: '50mb'
|
||||
})
|
||||
)
|
||||
|
||||
// Global middleware
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now()
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start
|
||||
logger.info(`${req.method} ${req.path} - ${res.statusCode} - ${duration}ms`)
|
||||
logger.info('API request completed', {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
statusCode: res.statusCode,
|
||||
durationMs: duration
|
||||
})
|
||||
})
|
||||
next()
|
||||
})
|
||||
@@ -101,27 +120,28 @@ app.get('/', (_req, res) => {
|
||||
name: 'Cherry Studio API',
|
||||
version: '1.0.0',
|
||||
endpoints: {
|
||||
health: 'GET /health',
|
||||
models: 'GET /v1/models',
|
||||
chat: 'POST /v1/chat/completions',
|
||||
mcp: 'GET /v1/mcps'
|
||||
health: 'GET /health'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Setup OpenAPI documentation before protected routes so docs remain public
|
||||
setupOpenAPIDocumentation(app)
|
||||
|
||||
// Provider-specific messages route requires authentication
|
||||
app.use('/:provider/v1/messages', authMiddleware, extendMessagesTimeout, messagesProviderRoutes)
|
||||
|
||||
// API v1 routes with auth
|
||||
const apiRouter = express.Router()
|
||||
apiRouter.use(authMiddleware)
|
||||
apiRouter.use(express.json())
|
||||
// Mount routes
|
||||
apiRouter.use('/chat', chatRoutes)
|
||||
apiRouter.use('/mcps', mcpRoutes)
|
||||
apiRouter.use('/messages', extendMessagesTimeout, messagesRoutes)
|
||||
apiRouter.use('/models', modelsRoutes)
|
||||
apiRouter.use('/agents', agentsRoutes)
|
||||
app.use('/v1', apiRouter)
|
||||
|
||||
// Setup OpenAPI documentation
|
||||
setupOpenAPIDocumentation(app)
|
||||
|
||||
// Error handling (must be last)
|
||||
app.use(errorHandler)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class ConfigManager {
|
||||
}
|
||||
return this._config
|
||||
} catch (error: any) {
|
||||
logger.warn('Failed to load config from Redux, using defaults:', error)
|
||||
logger.warn('Failed to load config from Redux, using defaults', { error })
|
||||
this._config = {
|
||||
enabled: false,
|
||||
port: defaultPort,
|
||||
|
||||
3
src/main/apiServer/config/timeouts.ts
Normal file
3
src/main/apiServer/config/timeouts.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const LONG_POLL_TIMEOUT_MS = 120 * 60_000 // 120 minutes
|
||||
|
||||
export const MESSAGE_STREAM_TIMEOUT_MS = LONG_POLL_TIMEOUT_MS
|
||||
368
src/main/apiServer/middleware/__tests__/auth.test.ts
Normal file
368
src/main/apiServer/middleware/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { config } from '../../config'
|
||||
import { authMiddleware } from '../auth'
|
||||
|
||||
// Mock the config module
|
||||
vi.mock('../../config', () => ({
|
||||
config: {
|
||||
get: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('@logger', () => ({
|
||||
loggerService: {
|
||||
withContext: vi.fn(() => ({
|
||||
debug: vi.fn()
|
||||
}))
|
||||
}
|
||||
}))
|
||||
|
||||
const mockConfig = config as any
|
||||
|
||||
describe('authMiddleware', () => {
|
||||
let req: Partial<Request>
|
||||
let res: Partial<Response>
|
||||
let next: NextFunction
|
||||
let jsonMock: ReturnType<typeof vi.fn>
|
||||
let statusMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
jsonMock = vi.fn()
|
||||
statusMock = vi.fn(() => ({ json: jsonMock }))
|
||||
|
||||
req = {
|
||||
header: vi.fn()
|
||||
}
|
||||
res = {
|
||||
status: statusMock
|
||||
}
|
||||
next = vi.fn()
|
||||
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Missing credentials', () => {
|
||||
it('should return 401 when both auth headers are missing', async () => {
|
||||
;(req.header as any).mockReturnValue('')
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: missing credentials' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 401 when both auth headers are empty strings', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return ''
|
||||
if (header === 'x-api-key') return ''
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: missing credentials' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Server configuration', () => {
|
||||
it('should return 403 when API key is not configured', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return 'some-key'
|
||||
return ''
|
||||
})
|
||||
|
||||
mockConfig.get.mockResolvedValue({ apiKey: '' })
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 403 when API key is null', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return 'some-key'
|
||||
return ''
|
||||
})
|
||||
|
||||
mockConfig.get.mockResolvedValue({ apiKey: null })
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API Key authentication (priority)', () => {
|
||||
const validApiKey = 'valid-api-key-123'
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig.get.mockResolvedValue({ apiKey: validApiKey })
|
||||
})
|
||||
|
||||
it('should authenticate successfully with valid API key', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return validApiKey
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 403 with invalid API key', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return 'invalid-key'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 401 with empty API key', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return ' '
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: empty x-api-key' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle API key with whitespace', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return ` ${validApiKey} `
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prioritize API key over Bearer token when both are present', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return validApiKey
|
||||
if (header === 'authorization') return 'Bearer invalid-token'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 403 when API key is invalid even if Bearer token is valid', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return 'invalid-key'
|
||||
if (header === 'authorization') return `Bearer ${validApiKey}`
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bearer token authentication (fallback)', () => {
|
||||
const validApiKey = 'valid-api-key-123'
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig.get.mockResolvedValue({ apiKey: validApiKey })
|
||||
})
|
||||
|
||||
it('should authenticate successfully with valid Bearer token when no API key', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return `Bearer ${validApiKey}`
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 403 with invalid Bearer token', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return 'Bearer invalid-token'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 401 with malformed authorization header', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return 'Basic sometoken'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: invalid authorization format' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 401 with Bearer without space', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return 'Bearer'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: invalid authorization format' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle Bearer token with only trailing spaces (edge case)', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return 'Bearer ' // This will be trimmed to "Bearer" and fail format check
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: invalid authorization format' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle Bearer token with case insensitive prefix', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return `bearer ${validApiKey}`
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle Bearer token with whitespace', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return ` Bearer ${validApiKey} `
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(next).toHaveBeenCalled()
|
||||
expect(statusMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
const validApiKey = 'valid-api-key-123'
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig.get.mockResolvedValue({ apiKey: validApiKey })
|
||||
})
|
||||
|
||||
it('should handle config.get() rejection', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return validApiKey
|
||||
return ''
|
||||
})
|
||||
|
||||
mockConfig.get.mockRejectedValue(new Error('Config error'))
|
||||
|
||||
await expect(authMiddleware(req as Request, res as Response, next)).rejects.toThrow('Config error')
|
||||
})
|
||||
|
||||
it('should use timing-safe comparison for different length tokens', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return 'short'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 401 when neither credential format is valid', async () => {
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return 'Invalid format'
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(401)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Unauthorized: invalid authorization format' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Timing attack protection', () => {
|
||||
const validApiKey = 'valid-api-key-123'
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig.get.mockResolvedValue({ apiKey: validApiKey })
|
||||
})
|
||||
|
||||
it('should handle similar length but different API keys securely', async () => {
|
||||
const similarKey = 'valid-api-key-124' // Same length, different last char
|
||||
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'x-api-key') return similarKey
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle similar length but different Bearer tokens securely', async () => {
|
||||
const similarKey = 'valid-api-key-124' // Same length, different last char
|
||||
|
||||
;(req.header as any).mockImplementation((header: string) => {
|
||||
if (header === 'authorization') return `Bearer ${similarKey}`
|
||||
return ''
|
||||
})
|
||||
|
||||
await authMiddleware(req as Request, res as Response, next)
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403)
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Forbidden' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,17 @@ import { NextFunction, Request, Response } from 'express'
|
||||
|
||||
import { config } from '../config'
|
||||
|
||||
const isValidToken = (token: string, apiKey: string): boolean => {
|
||||
if (token.length !== apiKey.length) {
|
||||
return false
|
||||
}
|
||||
const tokenBuf = Buffer.from(token)
|
||||
const keyBuf = Buffer.from(apiKey)
|
||||
return crypto.timingSafeEqual(tokenBuf, keyBuf)
|
||||
}
|
||||
|
||||
export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => {
|
||||
const auth = req.header('Authorization') || ''
|
||||
const auth = req.header('authorization') || ''
|
||||
const xApiKey = req.header('x-api-key') || ''
|
||||
|
||||
// Fast rejection if neither credential header provided
|
||||
@@ -12,51 +21,46 @@ export const authMiddleware = async (req: Request, res: Response, next: NextFunc
|
||||
return res.status(401).json({ error: 'Unauthorized: missing credentials' })
|
||||
}
|
||||
|
||||
let token: string | undefined
|
||||
|
||||
// Prefer Bearer if well‑formed
|
||||
if (auth) {
|
||||
const trimmed = auth.trim()
|
||||
const bearerPrefix = /^Bearer\s+/i
|
||||
if (bearerPrefix.test(trimmed)) {
|
||||
const candidate = trimmed.replace(bearerPrefix, '').trim()
|
||||
if (!candidate) {
|
||||
return res.status(401).json({ error: 'Unauthorized: empty bearer token' })
|
||||
}
|
||||
token = candidate
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to x-api-key if token still not resolved
|
||||
if (!token && xApiKey) {
|
||||
if (!xApiKey.trim()) {
|
||||
return res.status(401).json({ error: 'Unauthorized: empty x-api-key' })
|
||||
}
|
||||
token = xApiKey.trim()
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
// At this point we had at least one header, but none yielded a usable token
|
||||
return res.status(401).json({ error: 'Unauthorized: invalid credentials format' })
|
||||
}
|
||||
|
||||
const { apiKey } = await config.get()
|
||||
|
||||
if (!apiKey) {
|
||||
// If server not configured, treat as forbidden (or could be 500). Choose 403 to avoid leaking config state.
|
||||
return res.status(403).json({ error: 'Forbidden' })
|
||||
}
|
||||
|
||||
// Timing-safe compare when lengths match, else immediate forbidden
|
||||
if (token.length !== apiKey.length) {
|
||||
return res.status(403).json({ error: 'Forbidden' })
|
||||
// Check API key first (priority)
|
||||
if (xApiKey) {
|
||||
const trimmedApiKey = xApiKey.trim()
|
||||
if (!trimmedApiKey) {
|
||||
return res.status(401).json({ error: 'Unauthorized: empty x-api-key' })
|
||||
}
|
||||
|
||||
if (isValidToken(trimmedApiKey, apiKey)) {
|
||||
return next()
|
||||
} else {
|
||||
return res.status(403).json({ error: 'Forbidden' })
|
||||
}
|
||||
}
|
||||
|
||||
const tokenBuf = Buffer.from(token)
|
||||
const keyBuf = Buffer.from(apiKey)
|
||||
if (!crypto.timingSafeEqual(tokenBuf, keyBuf)) {
|
||||
return res.status(403).json({ error: 'Forbidden' })
|
||||
// Fallback to Bearer token
|
||||
if (auth) {
|
||||
const trimmed = auth.trim()
|
||||
const bearerPrefix = /^Bearer\s+/i
|
||||
|
||||
if (!bearerPrefix.test(trimmed)) {
|
||||
return res.status(401).json({ error: 'Unauthorized: invalid authorization format' })
|
||||
}
|
||||
|
||||
const token = trimmed.replace(bearerPrefix, '').trim()
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Unauthorized: empty bearer token' })
|
||||
}
|
||||
|
||||
if (isValidToken(token, apiKey)) {
|
||||
return next()
|
||||
} else {
|
||||
return res.status(403).json({ error: 'Forbidden' })
|
||||
}
|
||||
}
|
||||
|
||||
return next()
|
||||
return res.status(401).json({ error: 'Unauthorized: invalid credentials format' })
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ const logger = loggerService.withContext('ApiServerErrorHandler')
|
||||
|
||||
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||
logger.error('API Server Error:', err)
|
||||
logger.error('API server error', { error: err })
|
||||
|
||||
// Don't expose internal errors in production
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
|
||||
@@ -197,10 +197,11 @@ export function setupOpenAPIDocumentation(app: Express) {
|
||||
})
|
||||
)
|
||||
|
||||
logger.info('OpenAPI documentation setup complete')
|
||||
logger.info('Documentation available at /api-docs')
|
||||
logger.info('OpenAPI spec available at /api-docs.json')
|
||||
logger.info('OpenAPI documentation ready', {
|
||||
docsPath: '/api-docs',
|
||||
specPath: '/api-docs.json'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to setup OpenAPI documentation:', error as Error)
|
||||
logger.error('Failed to setup OpenAPI documentation', { error })
|
||||
}
|
||||
}
|
||||
|
||||
567
src/main/apiServer/routes/agents/handlers/agents.ts
Normal file
567
src/main/apiServer/routes/agents/handlers/agents.ts
Normal file
@@ -0,0 +1,567 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { AgentModelValidationError, agentService, sessionService } from '@main/services/agents'
|
||||
import { ListAgentsResponse, type ReplaceAgentRequest, type UpdateAgentRequest } from '@types'
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
import type { ValidationRequest } from '../validators/zodValidator'
|
||||
|
||||
const logger = loggerService.withContext('ApiServerAgentsHandlers')
|
||||
|
||||
const modelValidationErrorBody = (error: AgentModelValidationError) => ({
|
||||
error: {
|
||||
message: `Invalid ${error.context.field}: ${error.detail.message}`,
|
||||
type: 'invalid_request_error',
|
||||
code: error.detail.code
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /v1/agents:
|
||||
* post:
|
||||
* summary: Create a new agent
|
||||
* description: Creates a new autonomous agent with the specified configuration and automatically
|
||||
* provisions an initial session that mirrors the agent's settings.
|
||||
* tags: [Agents]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/CreateAgentRequest'
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Agent created successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/AgentEntity'
|
||||
* 400:
|
||||
* description: Validation error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
export const createAgent = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
logger.debug('Creating agent')
|
||||
logger.debug('Agent payload', { body: req.body })
|
||||
|
||||
const agent = await agentService.createAgent(req.body)
|
||||
|
||||
try {
|
||||
logger.info('Agent created', { agentId: agent.id })
|
||||
logger.debug('Creating default session for agent', { agentId: agent.id })
|
||||
|
||||
await sessionService.createSession(agent.id, {})
|
||||
|
||||
logger.info('Default session created for agent', { agentId: agent.id })
|
||||
return res.status(201).json(agent)
|
||||
} catch (sessionError: any) {
|
||||
logger.error('Failed to create default session for new agent, rolling back agent creation', {
|
||||
agentId: agent.id,
|
||||
error: sessionError
|
||||
})
|
||||
|
||||
try {
|
||||
await agentService.deleteAgent(agent.id)
|
||||
} catch (rollbackError: any) {
|
||||
logger.error('Failed to roll back agent after session creation failure', {
|
||||
agentId: agent.id,
|
||||
error: rollbackError
|
||||
})
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: `Failed to create default session for agent: ${sessionError.message}`,
|
||||
type: 'internal_error',
|
||||
code: 'agent_session_creation_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error instanceof AgentModelValidationError) {
|
||||
logger.warn('Agent model validation error during create', {
|
||||
agentType: error.context.agentType,
|
||||
field: error.context.field,
|
||||
model: error.context.model,
|
||||
detail: error.detail
|
||||
})
|
||||
return res.status(400).json(modelValidationErrorBody(error))
|
||||
}
|
||||
|
||||
logger.error('Error creating agent', { error })
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: `Failed to create agent: ${error.message}`,
|
||||
type: 'internal_error',
|
||||
code: 'agent_creation_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /v1/agents:
|
||||
* get:
|
||||
* summary: List all agents
|
||||
* description: Retrieves a paginated list of all agents
|
||||
* tags: [Agents]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 20
|
||||
* description: Number of agents to return
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* default: 0
|
||||
* description: Number of agents to skip
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of agents
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AgentEntity'
|
||||
* total:
|
||||
* type: integer
|
||||
* description: Total number of agents
|
||||
* limit:
|
||||
* type: integer
|
||||
* description: Number of agents returned
|
||||
* offset:
|
||||
* type: integer
|
||||
* description: Number of agents skipped
|
||||
* 400:
|
||||
* description: Validation error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
export const listAgents = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20
|
||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0
|
||||
|
||||
logger.debug('Listing agents', { limit, offset })
|
||||
|
||||
const result = await agentService.listAgents({ limit, offset })
|
||||
|
||||
logger.info('Agents listed', {
|
||||
returned: result.agents.length,
|
||||
total: result.total,
|
||||
limit,
|
||||
offset
|
||||
})
|
||||
return res.json({
|
||||
data: result.agents,
|
||||
total: result.total,
|
||||
limit,
|
||||
offset
|
||||
} satisfies ListAgentsResponse)
|
||||
} catch (error: any) {
|
||||
logger.error('Error listing agents', { error })
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to list agents',
|
||||
type: 'internal_error',
|
||||
code: 'agent_list_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /v1/agents/{agentId}:
|
||||
* get:
|
||||
* summary: Get agent by ID
|
||||
* description: Retrieves a specific agent by its ID
|
||||
* tags: [Agents]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Agent details
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/AgentEntity'
|
||||
* 404:
|
||||
* description: Agent not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
export const getAgent = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { agentId } = req.params
|
||||
logger.debug('Getting agent', { agentId })
|
||||
|
||||
const agent = await agentService.getAgent(agentId)
|
||||
|
||||
if (!agent) {
|
||||
logger.warn('Agent not found', { agentId })
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Agent not found',
|
||||
type: 'not_found',
|
||||
code: 'agent_not_found'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Agent retrieved', { agentId })
|
||||
return res.json(agent)
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting agent', { error, agentId: req.params.agentId })
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to get agent',
|
||||
type: 'internal_error',
|
||||
code: 'agent_get_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /v1/agents/{agentId}:
|
||||
* put:
|
||||
* summary: Update agent
|
||||
* description: Updates an existing agent with the provided data
|
||||
* tags: [Agents]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/CreateAgentRequest'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Agent updated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/AgentEntity'
|
||||
* 400:
|
||||
* description: Validation error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Agent not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
export const updateAgent = async (req: Request, res: Response): Promise<Response> => {
|
||||
const { agentId } = req.params
|
||||
try {
|
||||
logger.debug('Updating agent', { agentId })
|
||||
logger.debug('Replace payload', { body: req.body })
|
||||
|
||||
const { validatedBody } = req as ValidationRequest
|
||||
const replacePayload = (validatedBody ?? {}) as ReplaceAgentRequest
|
||||
|
||||
const agent = await agentService.updateAgent(agentId, replacePayload, { replace: true })
|
||||
|
||||
if (!agent) {
|
||||
logger.warn('Agent not found for update', { agentId })
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Agent not found',
|
||||
type: 'not_found',
|
||||
code: 'agent_not_found'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Agent updated', { agentId })
|
||||
return res.json(agent)
|
||||
} catch (error: any) {
|
||||
if (error instanceof AgentModelValidationError) {
|
||||
logger.warn('Agent model validation error during update', {
|
||||
agentId,
|
||||
agentType: error.context.agentType,
|
||||
field: error.context.field,
|
||||
model: error.context.model,
|
||||
detail: error.detail
|
||||
})
|
||||
return res.status(400).json(modelValidationErrorBody(error))
|
||||
}
|
||||
|
||||
logger.error('Error updating agent', { error, agentId })
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to update agent: ' + error.message,
|
||||
type: 'internal_error',
|
||||
code: 'agent_update_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /v1/agents/{agentId}:
|
||||
* patch:
|
||||
* summary: Partially update agent
|
||||
* description: Partially updates an existing agent with only the provided fields
|
||||
* tags: [Agents]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* description: Agent name
|
||||
* description:
|
||||
* type: string
|
||||
* description: Agent description
|
||||
* avatar:
|
||||
* type: string
|
||||
* description: Agent avatar URL
|
||||
* instructions:
|
||||
* type: string
|
||||
* description: System prompt/instructions
|
||||
* model:
|
||||
* type: string
|
||||
* description: Main model ID
|
||||
* plan_model:
|
||||
* type: string
|
||||
* description: Optional planning model ID
|
||||
* small_model:
|
||||
* type: string
|
||||
* description: Optional small/fast model ID
|
||||
* tools:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Tools
|
||||
* mcps:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: MCP tool IDs
|
||||
* knowledges:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Knowledge base IDs
|
||||
* configuration:
|
||||
* type: object
|
||||
* description: Extensible settings
|
||||
* accessible_paths:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Accessible directory paths
|
||||
* permission_mode:
|
||||
* type: string
|
||||
* enum: [readOnly, acceptEdits, bypassPermissions]
|
||||
* description: Permission mode
|
||||
* max_steps:
|
||||
* type: integer
|
||||
* description: Maximum steps the agent can take
|
||||
* description: Only include the fields you want to update
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Agent updated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/AgentEntity'
|
||||
* 400:
|
||||
* description: Validation error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Agent not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
export const patchAgent = async (req: Request, res: Response): Promise<Response> => {
|
||||
const { agentId } = req.params
|
||||
try {
|
||||
logger.debug('Partially updating agent', { agentId })
|
||||
logger.debug('Patch payload', { body: req.body })
|
||||
|
||||
const { validatedBody } = req as ValidationRequest
|
||||
const updatePayload = (validatedBody ?? {}) as UpdateAgentRequest
|
||||
|
||||
const agent = await agentService.updateAgent(agentId, updatePayload)
|
||||
|
||||
if (!agent) {
|
||||
logger.warn('Agent not found for partial update', { agentId })
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Agent not found',
|
||||
type: 'not_found',
|
||||
code: 'agent_not_found'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Agent patched', { agentId })
|
||||
return res.json(agent)
|
||||
} catch (error: any) {
|
||||
if (error instanceof AgentModelValidationError) {
|
||||
logger.warn('Agent model validation error during partial update', {
|
||||
agentId,
|
||||
agentType: error.context.agentType,
|
||||
field: error.context.field,
|
||||
model: error.context.model,
|
||||
detail: error.detail
|
||||
})
|
||||
return res.status(400).json(modelValidationErrorBody(error))
|
||||
}
|
||||
|
||||
logger.error('Error partially updating agent', { error, agentId })
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: `Failed to partially update agent: ${error.message}`,
|
||||
type: 'internal_error',
|
||||
code: 'agent_patch_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /v1/agents/{agentId}:
|
||||
* delete:
|
||||
* summary: Delete agent
|
||||
* description: Deletes an agent and all associated sessions and logs
|
||||
* tags: [Agents]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Agent deleted successfully
|
||||
* 404:
|
||||
* description: Agent not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
export const deleteAgent = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { agentId } = req.params
|
||||
logger.debug('Deleting agent', { agentId })
|
||||
|
||||
const deleted = await agentService.deleteAgent(agentId)
|
||||
|
||||
if (!deleted) {
|
||||
logger.warn('Agent not found for deletion', { agentId })
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Agent not found',
|
||||
type: 'not_found',
|
||||
code: 'agent_not_found'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Agent deleted', { agentId })
|
||||
return res.status(204).send()
|
||||
} catch (error: any) {
|
||||
logger.error('Error deleting agent', { error, agentId: req.params.agentId })
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to delete agent',
|
||||
type: 'internal_error',
|
||||
code: 'agent_delete_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
3
src/main/apiServer/routes/agents/handlers/index.ts
Normal file
3
src/main/apiServer/routes/agents/handlers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * as agentHandlers from './agents'
|
||||
export * as messageHandlers from './messages'
|
||||
export * as sessionHandlers from './sessions'
|
||||
317
src/main/apiServer/routes/agents/handlers/messages.ts
Normal file
317
src/main/apiServer/routes/agents/handlers/messages.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { MESSAGE_STREAM_TIMEOUT_MS } from '@main/apiServer/config/timeouts'
|
||||
import { createStreamAbortController, STREAM_TIMEOUT_REASON } from '@main/apiServer/utils/createStreamAbortController'
|
||||
import { agentService, sessionMessageService, sessionService } from '@main/services/agents'
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
const logger = loggerService.withContext('ApiServerMessagesHandlers')
|
||||
|
||||
// Helper function to verify agent and session exist and belong together
|
||||
const verifyAgentAndSession = async (agentId: string, sessionId: string) => {
|
||||
const agentExists = await agentService.agentExists(agentId)
|
||||
if (!agentExists) {
|
||||
throw { status: 404, code: 'agent_not_found', message: 'Agent not found' }
|
||||
}
|
||||
|
||||
const session = await sessionService.getSession(agentId, sessionId)
|
||||
if (!session) {
|
||||
throw { status: 404, code: 'session_not_found', message: 'Session not found' }
|
||||
}
|
||||
|
||||
if (session.agent_id !== agentId) {
|
||||
throw { status: 404, code: 'session_not_found', message: 'Session not found for this agent' }
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
export const createMessage = async (req: Request, res: Response): Promise<void> => {
|
||||
let clearAbortTimeout: (() => void) | undefined
|
||||
|
||||
try {
|
||||
const { agentId, sessionId } = req.params
|
||||
|
||||
const session = await verifyAgentAndSession(agentId, sessionId)
|
||||
|
||||
const messageData = req.body
|
||||
|
||||
logger.info('Creating streaming message', { agentId, sessionId })
|
||||
logger.debug('Streaming message payload', { messageData })
|
||||
|
||||
// Set SSE headers
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Cache-Control')
|
||||
|
||||
const {
|
||||
abortController,
|
||||
registerAbortHandler,
|
||||
clearAbortTimeout: helperClearAbortTimeout
|
||||
} = createStreamAbortController({
|
||||
timeoutMs: MESSAGE_STREAM_TIMEOUT_MS
|
||||
})
|
||||
clearAbortTimeout = helperClearAbortTimeout
|
||||
const { stream, completion } = await sessionMessageService.createSessionMessage(
|
||||
session,
|
||||
messageData,
|
||||
abortController
|
||||
)
|
||||
const reader = stream.getReader()
|
||||
|
||||
// Track stream lifecycle so we keep the SSE connection open until persistence finishes
|
||||
let responseEnded = false
|
||||
let streamFinished = false
|
||||
|
||||
const cleanupAbortTimeout = () => {
|
||||
clearAbortTimeout?.()
|
||||
}
|
||||
|
||||
const finalizeResponse = () => {
|
||||
if (responseEnded) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!streamFinished) {
|
||||
return
|
||||
}
|
||||
|
||||
responseEnded = true
|
||||
cleanupAbortTimeout()
|
||||
try {
|
||||
// res.write('data: {"type":"finish"}\n\n')
|
||||
res.write('data: [DONE]\n\n')
|
||||
} catch (writeError) {
|
||||
logger.error('Error writing final sentinel to SSE stream', { error: writeError as Error })
|
||||
}
|
||||
res.end()
|
||||
}
|
||||
|
||||
/**
|
||||
* Client Disconnect Detection for Server-Sent Events (SSE)
|
||||
*
|
||||
* We monitor multiple HTTP events to reliably detect when a client disconnects
|
||||
* from the streaming response. This is crucial for:
|
||||
* - Aborting long-running Claude Code processes
|
||||
* - Cleaning up resources and preventing memory leaks
|
||||
* - Avoiding orphaned processes
|
||||
*
|
||||
* Event Priority & Behavior:
|
||||
* 1. res.on('close') - Most common for SSE client disconnects (browser tab close, curl Ctrl+C)
|
||||
* 2. req.on('aborted') - Explicit request abortion
|
||||
* 3. req.on('close') - Request object closure (less common with SSE)
|
||||
*
|
||||
* When any disconnect event fires, we:
|
||||
* - Abort the Claude Code SDK process via abortController
|
||||
* - Clean up event listeners to prevent memory leaks
|
||||
* - Mark the response as ended to prevent further writes
|
||||
*/
|
||||
registerAbortHandler((abortReason) => {
|
||||
cleanupAbortTimeout()
|
||||
|
||||
if (responseEnded) return
|
||||
|
||||
responseEnded = true
|
||||
|
||||
if (abortReason === STREAM_TIMEOUT_REASON) {
|
||||
logger.error('Streaming message timeout', { agentId, sessionId })
|
||||
try {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
message: 'Stream timeout',
|
||||
type: 'timeout_error',
|
||||
code: 'stream_timeout'
|
||||
}
|
||||
})}\n\n`
|
||||
)
|
||||
} catch (writeError) {
|
||||
logger.error('Error writing timeout to SSE stream', { error: writeError })
|
||||
}
|
||||
} else if (abortReason === 'Client disconnected') {
|
||||
logger.info('Streaming client disconnected', { agentId, sessionId })
|
||||
} else {
|
||||
logger.warn('Streaming aborted', { agentId, sessionId, reason: abortReason })
|
||||
}
|
||||
|
||||
reader.cancel(abortReason ?? 'stream aborted').catch(() => {})
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
}
|
||||
|
||||
if (!res.writableEnded) {
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
|
||||
const handleDisconnect = () => {
|
||||
if (abortController.signal.aborted) return
|
||||
abortController.abort('Client disconnected')
|
||||
}
|
||||
|
||||
req.on('close', handleDisconnect)
|
||||
req.on('aborted', handleDisconnect)
|
||||
res.on('close', handleDisconnect)
|
||||
|
||||
const pumpStream = async () => {
|
||||
try {
|
||||
while (!responseEnded) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
res.write(`data: ${JSON.stringify(value)}\n\n`)
|
||||
}
|
||||
|
||||
streamFinished = true
|
||||
finalizeResponse()
|
||||
} catch (error) {
|
||||
if (responseEnded) return
|
||||
logger.error('Error reading agent stream', { error })
|
||||
try {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
message: (error as Error).message || 'Stream processing error',
|
||||
type: 'stream_error',
|
||||
code: 'stream_processing_failed'
|
||||
}
|
||||
})}\n\n`
|
||||
)
|
||||
} catch (writeError) {
|
||||
logger.error('Error writing stream error to SSE', { error: writeError })
|
||||
}
|
||||
responseEnded = true
|
||||
cleanupAbortTimeout()
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
|
||||
pumpStream().catch((error) => {
|
||||
logger.error('Pump stream failure', { error })
|
||||
})
|
||||
|
||||
completion
|
||||
.then(() => {
|
||||
streamFinished = true
|
||||
finalizeResponse()
|
||||
})
|
||||
.catch((error) => {
|
||||
if (responseEnded) return
|
||||
logger.error('Streaming message error', { agentId, sessionId, error })
|
||||
try {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
message: (error as { message?: string })?.message || 'Stream processing error',
|
||||
type: 'stream_error',
|
||||
code: 'stream_processing_failed'
|
||||
}
|
||||
})}\n\n`
|
||||
)
|
||||
} catch (writeError) {
|
||||
logger.error('Error writing completion error to SSE stream', { error: writeError })
|
||||
}
|
||||
responseEnded = true
|
||||
cleanupAbortTimeout()
|
||||
res.end()
|
||||
})
|
||||
// Clear timeout when response ends
|
||||
res.on('close', cleanupAbortTimeout)
|
||||
res.on('finish', cleanupAbortTimeout)
|
||||
} catch (error: any) {
|
||||
clearAbortTimeout?.()
|
||||
logger.error('Error in streaming message handler', {
|
||||
error,
|
||||
agentId: req.params.agentId,
|
||||
sessionId: req.params.sessionId
|
||||
})
|
||||
|
||||
// Send error as SSE if possible
|
||||
if (!res.headersSent) {
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
}
|
||||
|
||||
try {
|
||||
const errorResponse = {
|
||||
type: 'error',
|
||||
error: {
|
||||
message: error.status ? error.message : 'Failed to create streaming message',
|
||||
type: error.status ? 'not_found' : 'internal_error',
|
||||
code: error.status ? error.code : 'stream_creation_failed'
|
||||
}
|
||||
}
|
||||
|
||||
res.write(`data: ${JSON.stringify(errorResponse)}\n\n`)
|
||||
} catch (writeError) {
|
||||
logger.error('Error writing initial error to SSE stream', { error: writeError })
|
||||
}
|
||||
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteMessage = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { agentId, sessionId, messageId: messageIdParam } = req.params
|
||||
const messageId = Number(messageIdParam)
|
||||
|
||||
await verifyAgentAndSession(agentId, sessionId)
|
||||
|
||||
const deleted = await sessionMessageService.deleteSessionMessage(sessionId, messageId)
|
||||
|
||||
if (!deleted) {
|
||||
logger.warn('Session message not found', { agentId, sessionId, messageId })
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Message not found for this session',
|
||||
type: 'not_found',
|
||||
code: 'session_message_not_found'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Session message deleted', { agentId, sessionId, messageId })
|
||||
return res.status(204).send()
|
||||
} catch (error: any) {
|
||||
if (error?.status === 404) {
|
||||
logger.warn('Delete message failed - missing resource', {
|
||||
agentId: req.params.agentId,
|
||||
sessionId: req.params.sessionId,
|
||||
messageId: req.params.messageId,
|
||||
error
|
||||
})
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: error.message,
|
||||
type: 'not_found',
|
||||
code: error.code ?? 'session_message_not_found'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.error('Error deleting session message', {
|
||||
error,
|
||||
agentId: req.params.agentId,
|
||||
sessionId: req.params.sessionId,
|
||||
messageId: Number(req.params.messageId)
|
||||
})
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to delete session message',
|
||||
type: 'internal_error',
|
||||
code: 'session_message_delete_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
366
src/main/apiServer/routes/agents/handlers/sessions.ts
Normal file
366
src/main/apiServer/routes/agents/handlers/sessions.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { AgentModelValidationError, sessionMessageService, sessionService } from '@main/services/agents'
|
||||
import { ListAgentSessionsResponse, type ReplaceSessionRequest, UpdateSessionResponse } from '@types'
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
import type { ValidationRequest } from '../validators/zodValidator'
|
||||
|
||||
const logger = loggerService.withContext('ApiServerSessionsHandlers')
|
||||
|
||||
const modelValidationErrorBody = (error: AgentModelValidationError) => ({
|
||||
error: {
|
||||
message: `Invalid ${error.context.field}: ${error.detail.message}`,
|
||||
type: 'invalid_request_error',
|
||||
code: error.detail.code
|
||||
}
|
||||
})
|
||||
|
||||
export const createSession = async (req: Request, res: Response): Promise<Response> => {
|
||||
const { agentId } = req.params
|
||||
try {
|
||||
const sessionData = req.body
|
||||
|
||||
logger.debug('Creating new session', { agentId })
|
||||
logger.debug('Session payload', { sessionData })
|
||||
|
||||
const session = await sessionService.createSession(agentId, sessionData)
|
||||
|
||||
logger.info('Session created', { agentId, sessionId: session?.id })
|
||||
return res.status(201).json(session)
|
||||
} catch (error: any) {
|
||||
if (error instanceof AgentModelValidationError) {
|
||||
logger.warn('Session model validation error during create', {
|
||||
agentId,
|
||||
agentType: error.context.agentType,
|
||||
field: error.context.field,
|
||||
model: error.context.model,
|
||||
detail: error.detail
|
||||
})
|
||||
return res.status(400).json(modelValidationErrorBody(error))
|
||||
}
|
||||
|
||||
logger.error('Error creating session', { error, agentId })
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: `Failed to create session: ${error.message}`,
|
||||
type: 'internal_error',
|
||||
code: 'session_creation_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const listSessions = async (req: Request, res: Response): Promise<Response> => {
|
||||
const { agentId } = req.params
|
||||
try {
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20
|
||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0
|
||||
const status = req.query.status as any
|
||||
|
||||
logger.debug('Listing agent sessions', { agentId, limit, offset, status })
|
||||
|
||||
const result = await sessionService.listSessions(agentId, { limit, offset })
|
||||
|
||||
logger.info('Agent sessions listed', {
|
||||
agentId,
|
||||
returned: result.sessions.length,
|
||||
total: result.total,
|
||||
limit,
|
||||
offset
|
||||
})
|
||||
return res.json({
|
||||
data: result.sessions,
|
||||
total: result.total,
|
||||
limit,
|
||||
offset
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error('Error listing sessions', { error, agentId })
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to list sessions',
|
||||
type: 'internal_error',
|
||||
code: 'session_list_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const getSession = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { agentId, sessionId } = req.params
|
||||
logger.debug('Getting session', { agentId, sessionId })
|
||||
|
||||
const session = await sessionService.getSession(agentId, sessionId)
|
||||
|
||||
if (!session) {
|
||||
logger.warn('Session not found', { agentId, sessionId })
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Session not found',
|
||||
type: 'not_found',
|
||||
code: 'session_not_found'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// // Verify session belongs to the agent
|
||||
// logger.warn(`Session ${sessionId} does not belong to agent ${agentId}`)
|
||||
// return res.status(404).json({
|
||||
// error: {
|
||||
// message: 'Session not found for this agent',
|
||||
// type: 'not_found',
|
||||
// code: 'session_not_found'
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
// Fetch session messages
|
||||
logger.debug('Fetching session messages', { sessionId })
|
||||
const { messages } = await sessionMessageService.listSessionMessages(sessionId)
|
||||
|
||||
// Add messages to session
|
||||
const sessionWithMessages = {
|
||||
...session,
|
||||
messages: messages
|
||||
}
|
||||
|
||||
logger.info('Session retrieved', { agentId, sessionId, messageCount: messages.length })
|
||||
return res.json(sessionWithMessages)
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting session', { error, agentId: req.params.agentId, sessionId: req.params.sessionId })
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to get session',
|
||||
type: 'internal_error',
|
||||
code: 'session_get_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const updateSession = async (req: Request, res: Response): Promise<Response> => {
|
||||
const { agentId, sessionId } = req.params
|
||||
try {
|
||||
logger.debug('Updating session', { agentId, sessionId })
|
||||
logger.debug('Replace payload', { body: req.body })
|
||||
|
||||
// First check if session exists and belongs to agent
|
||||
const existingSession = await sessionService.getSession(agentId, sessionId)
|
||||
if (!existingSession || existingSession.agent_id !== agentId) {
|
||||
logger.warn('Session not found for update', { agentId, sessionId })
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Session not found for this agent',
|
||||
type: 'not_found',
|
||||
code: 'session_not_found'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const { validatedBody } = req as ValidationRequest
|
||||
const replacePayload = (validatedBody ?? {}) as ReplaceSessionRequest
|
||||
|
||||
const session = await sessionService.updateSession(agentId, sessionId, replacePayload)
|
||||
|
||||
if (!session) {
|
||||
logger.warn('Session missing during update', { agentId, sessionId })
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Session not found',
|
||||
type: 'not_found',
|
||||
code: 'session_not_found'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Session updated', { agentId, sessionId })
|
||||
return res.json(session satisfies UpdateSessionResponse)
|
||||
} catch (error: any) {
|
||||
if (error instanceof AgentModelValidationError) {
|
||||
logger.warn('Session model validation error during update', {
|
||||
agentId,
|
||||
sessionId,
|
||||
agentType: error.context.agentType,
|
||||
field: error.context.field,
|
||||
model: error.context.model,
|
||||
detail: error.detail
|
||||
})
|
||||
return res.status(400).json(modelValidationErrorBody(error))
|
||||
}
|
||||
|
||||
logger.error('Error updating session', { error, agentId, sessionId })
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: `Failed to update session: ${error.message}`,
|
||||
type: 'internal_error',
|
||||
code: 'session_update_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const patchSession = async (req: Request, res: Response): Promise<Response> => {
|
||||
const { agentId, sessionId } = req.params
|
||||
try {
|
||||
logger.debug('Patching session', { agentId, sessionId })
|
||||
logger.debug('Patch payload', { body: req.body })
|
||||
|
||||
// First check if session exists and belongs to agent
|
||||
const existingSession = await sessionService.getSession(agentId, sessionId)
|
||||
if (!existingSession || existingSession.agent_id !== agentId) {
|
||||
logger.warn('Session not found for patch', { agentId, sessionId })
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Session not found for this agent',
|
||||
type: 'not_found',
|
||||
code: 'session_not_found'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateSession = { ...existingSession, ...req.body }
|
||||
const session = await sessionService.updateSession(agentId, sessionId, updateSession)
|
||||
|
||||
if (!session) {
|
||||
logger.warn('Session missing while patching', { agentId, sessionId })
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Session not found',
|
||||
type: 'not_found',
|
||||
code: 'session_not_found'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Session patched', { agentId, sessionId })
|
||||
return res.json(session)
|
||||
} catch (error: any) {
|
||||
if (error instanceof AgentModelValidationError) {
|
||||
logger.warn('Session model validation error during patch', {
|
||||
agentId,
|
||||
sessionId,
|
||||
agentType: error.context.agentType,
|
||||
field: error.context.field,
|
||||
model: error.context.model,
|
||||
detail: error.detail
|
||||
})
|
||||
return res.status(400).json(modelValidationErrorBody(error))
|
||||
}
|
||||
|
||||
logger.error('Error patching session', { error, agentId, sessionId })
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: `Failed to patch session, ${error.message}`,
|
||||
type: 'internal_error',
|
||||
code: 'session_patch_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteSession = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { agentId, sessionId } = req.params
|
||||
logger.debug('Deleting session', { agentId, sessionId })
|
||||
|
||||
// First check if session exists and belongs to agent
|
||||
const existingSession = await sessionService.getSession(agentId, sessionId)
|
||||
if (!existingSession || existingSession.agent_id !== agentId) {
|
||||
logger.warn('Session not found for deletion', { agentId, sessionId })
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Session not found for this agent',
|
||||
type: 'not_found',
|
||||
code: 'session_not_found'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const deleted = await sessionService.deleteSession(agentId, sessionId)
|
||||
|
||||
if (!deleted) {
|
||||
logger.warn('Session missing during delete', { agentId, sessionId })
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Session not found',
|
||||
type: 'not_found',
|
||||
code: 'session_not_found'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Session deleted', { agentId, sessionId })
|
||||
|
||||
const { total } = await sessionService.listSessions(agentId, { limit: 1 })
|
||||
|
||||
if (total === 0) {
|
||||
logger.info('No remaining sessions, creating default', { agentId })
|
||||
try {
|
||||
const fallbackSession = await sessionService.createSession(agentId, {})
|
||||
logger.info('Default session created after delete', {
|
||||
agentId,
|
||||
sessionId: fallbackSession?.id
|
||||
})
|
||||
} catch (recoveryError: any) {
|
||||
logger.error('Failed to recreate session after deleting last session', {
|
||||
agentId,
|
||||
error: recoveryError
|
||||
})
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: `Failed to recreate session after deletion: ${recoveryError.message}`,
|
||||
type: 'internal_error',
|
||||
code: 'session_recovery_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(204).send()
|
||||
} catch (error: any) {
|
||||
logger.error('Error deleting session', { error, agentId: req.params.agentId, sessionId: req.params.sessionId })
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to delete session',
|
||||
type: 'internal_error',
|
||||
code: 'session_delete_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience endpoints for sessions without agent context
|
||||
export const listAllSessions = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 20
|
||||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0
|
||||
const status = req.query.status as any
|
||||
|
||||
logger.debug('Listing all sessions', { limit, offset, status })
|
||||
|
||||
const result = await sessionService.listSessions(undefined, { limit, offset })
|
||||
|
||||
logger.info('Sessions listed', {
|
||||
returned: result.sessions.length,
|
||||
total: result.total,
|
||||
limit,
|
||||
offset
|
||||
})
|
||||
return res.json({
|
||||
data: result.sessions,
|
||||
total: result.total,
|
||||
limit,
|
||||
offset
|
||||
} satisfies ListAgentSessionsResponse)
|
||||
} catch (error: any) {
|
||||
logger.error('Error listing all sessions', { error })
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to list sessions',
|
||||
type: 'internal_error',
|
||||
code: 'session_list_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
965
src/main/apiServer/routes/agents/index.ts
Normal file
965
src/main/apiServer/routes/agents/index.ts
Normal file
@@ -0,0 +1,965 @@
|
||||
import express from 'express'
|
||||
|
||||
import { agentHandlers, messageHandlers, sessionHandlers } from './handlers'
|
||||
import { checkAgentExists, handleValidationErrors } from './middleware'
|
||||
import {
|
||||
validateAgent,
|
||||
validateAgentId,
|
||||
validateAgentReplace,
|
||||
validateAgentUpdate,
|
||||
validatePagination,
|
||||
validateSession,
|
||||
validateSessionId,
|
||||
validateSessionMessage,
|
||||
validateSessionMessageId,
|
||||
validateSessionReplace,
|
||||
validateSessionUpdate
|
||||
} from './validators'
|
||||
|
||||
// Create main agents router
|
||||
const agentsRouter = express.Router()
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* PermissionMode:
|
||||
* type: string
|
||||
* enum: [default, acceptEdits, bypassPermissions, plan]
|
||||
* description: Permission mode for agent operations
|
||||
*
|
||||
* AgentType:
|
||||
* type: string
|
||||
* enum: [claude-code]
|
||||
* description: Type of agent
|
||||
*
|
||||
* AgentConfiguration:
|
||||
* type: object
|
||||
* properties:
|
||||
* permission_mode:
|
||||
* $ref: '#/components/schemas/PermissionMode'
|
||||
* default: default
|
||||
* max_turns:
|
||||
* type: integer
|
||||
* default: 10
|
||||
* description: Maximum number of interaction turns
|
||||
* additionalProperties: true
|
||||
*
|
||||
* AgentBase:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* description: Agent name
|
||||
* description:
|
||||
* type: string
|
||||
* description: Agent description
|
||||
* accessible_paths:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Array of directory paths the agent can access
|
||||
* instructions:
|
||||
* type: string
|
||||
* description: System prompt/instructions
|
||||
* model:
|
||||
* type: string
|
||||
* description: Main model ID
|
||||
* plan_model:
|
||||
* type: string
|
||||
* description: Optional planning model ID
|
||||
* small_model:
|
||||
* type: string
|
||||
* description: Optional small/fast model ID
|
||||
* mcps:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Array of MCP tool IDs
|
||||
* allowed_tools:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Array of allowed tool IDs (whitelist)
|
||||
* configuration:
|
||||
* $ref: '#/components/schemas/AgentConfiguration'
|
||||
* required:
|
||||
* - model
|
||||
* - accessible_paths
|
||||
*
|
||||
* AgentEntity:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/AgentBase'
|
||||
* - type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* description: Unique agent identifier
|
||||
* type:
|
||||
* $ref: '#/components/schemas/AgentType'
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: ISO timestamp of creation
|
||||
* updated_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: ISO timestamp of last update
|
||||
* required:
|
||||
* - id
|
||||
* - type
|
||||
* - created_at
|
||||
* - updated_at
|
||||
* CreateAgentRequest:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/AgentBase'
|
||||
* - type: object
|
||||
* properties:
|
||||
* type:
|
||||
* $ref: '#/components/schemas/AgentType'
|
||||
* name:
|
||||
* type: string
|
||||
* minLength: 1
|
||||
* description: Agent name (required)
|
||||
* model:
|
||||
* type: string
|
||||
* minLength: 1
|
||||
* description: Main model ID (required)
|
||||
* required:
|
||||
* - type
|
||||
* - name
|
||||
* - model
|
||||
*
|
||||
* UpdateAgentRequest:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* description: Agent name
|
||||
* description:
|
||||
* type: string
|
||||
* description: Agent description
|
||||
* accessible_paths:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Array of directory paths the agent can access
|
||||
* instructions:
|
||||
* type: string
|
||||
* description: System prompt/instructions
|
||||
* model:
|
||||
* type: string
|
||||
* description: Main model ID
|
||||
* plan_model:
|
||||
* type: string
|
||||
* description: Optional planning model ID
|
||||
* small_model:
|
||||
* type: string
|
||||
* description: Optional small/fast model ID
|
||||
* mcps:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Array of MCP tool IDs
|
||||
* allowed_tools:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Array of allowed tool IDs (whitelist)
|
||||
* configuration:
|
||||
* $ref: '#/components/schemas/AgentConfiguration'
|
||||
* description: Partial update - all fields are optional
|
||||
*
|
||||
* ReplaceAgentRequest:
|
||||
* $ref: '#/components/schemas/AgentBase'
|
||||
*
|
||||
* SessionEntity:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/AgentBase'
|
||||
* - type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* description: Unique session identifier
|
||||
* agent_id:
|
||||
* type: string
|
||||
* description: Primary agent ID for the session
|
||||
* agent_type:
|
||||
* $ref: '#/components/schemas/AgentType'
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: ISO timestamp of creation
|
||||
* updated_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: ISO timestamp of last update
|
||||
* required:
|
||||
* - id
|
||||
* - agent_id
|
||||
* - agent_type
|
||||
* - created_at
|
||||
* - updated_at
|
||||
*
|
||||
* CreateSessionRequest:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/AgentBase'
|
||||
* - type: object
|
||||
* properties:
|
||||
* model:
|
||||
* type: string
|
||||
* minLength: 1
|
||||
* description: Main model ID (required)
|
||||
* required:
|
||||
* - model
|
||||
*
|
||||
* UpdateSessionRequest:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* description: Session name
|
||||
* description:
|
||||
* type: string
|
||||
* description: Session description
|
||||
* accessible_paths:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Array of directory paths the agent can access
|
||||
* instructions:
|
||||
* type: string
|
||||
* description: System prompt/instructions
|
||||
* model:
|
||||
* type: string
|
||||
* description: Main model ID
|
||||
* plan_model:
|
||||
* type: string
|
||||
* description: Optional planning model ID
|
||||
* small_model:
|
||||
* type: string
|
||||
* description: Optional small/fast model ID
|
||||
* mcps:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Array of MCP tool IDs
|
||||
* allowed_tools:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Array of allowed tool IDs (whitelist)
|
||||
* configuration:
|
||||
* $ref: '#/components/schemas/AgentConfiguration'
|
||||
* description: Partial update - all fields are optional
|
||||
*
|
||||
* ReplaceSessionRequest:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/AgentBase'
|
||||
* - type: object
|
||||
* properties:
|
||||
* model:
|
||||
* type: string
|
||||
* minLength: 1
|
||||
* description: Main model ID (required)
|
||||
* required:
|
||||
* - model
|
||||
*
|
||||
* CreateSessionMessageRequest:
|
||||
* type: object
|
||||
* properties:
|
||||
* content:
|
||||
* type: string
|
||||
* minLength: 1
|
||||
* description: Message content
|
||||
* required:
|
||||
* - content
|
||||
*
|
||||
* PaginationQuery:
|
||||
* type: object
|
||||
* properties:
|
||||
* limit:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 20
|
||||
* description: Number of items to return
|
||||
* offset:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* default: 0
|
||||
* description: Number of items to skip
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [idle, running, completed, failed, stopped]
|
||||
* description: Filter by session status
|
||||
*
|
||||
* ListAgentsResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* agents:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AgentEntity'
|
||||
* total:
|
||||
* type: integer
|
||||
* description: Total number of agents
|
||||
* limit:
|
||||
* type: integer
|
||||
* description: Number of items returned
|
||||
* offset:
|
||||
* type: integer
|
||||
* description: Number of items skipped
|
||||
* required:
|
||||
* - agents
|
||||
* - total
|
||||
* - limit
|
||||
* - offset
|
||||
*
|
||||
* ListSessionsResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessions:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/SessionEntity'
|
||||
* total:
|
||||
* type: integer
|
||||
* description: Total number of sessions
|
||||
* limit:
|
||||
* type: integer
|
||||
* description: Number of items returned
|
||||
* offset:
|
||||
* type: integer
|
||||
* description: Number of items skipped
|
||||
* required:
|
||||
* - sessions
|
||||
* - total
|
||||
* - limit
|
||||
* - offset
|
||||
*
|
||||
* ErrorResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* error:
|
||||
* type: object
|
||||
* properties:
|
||||
* message:
|
||||
* type: string
|
||||
* description: Error message
|
||||
* type:
|
||||
* type: string
|
||||
* description: Error type
|
||||
* code:
|
||||
* type: string
|
||||
* description: Error code
|
||||
* required:
|
||||
* - message
|
||||
* - type
|
||||
* - code
|
||||
* required:
|
||||
* - error
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /agents:
|
||||
* post:
|
||||
* summary: Create a new agent
|
||||
* tags: [Agents]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/CreateAgentRequest'
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Agent created successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/AgentEntity'
|
||||
* 400:
|
||||
* description: Invalid request body
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
// Agent CRUD routes
|
||||
agentsRouter.post('/', validateAgent, handleValidationErrors, agentHandlers.createAgent)
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /agents:
|
||||
* get:
|
||||
* summary: List all agents with pagination
|
||||
* tags: [Agents]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 20
|
||||
* description: Number of agents to return
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* default: 0
|
||||
* description: Number of agents to skip
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [idle, running, completed, failed, stopped]
|
||||
* description: Filter by agent status
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of agents
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ListAgentsResponse'
|
||||
*/
|
||||
agentsRouter.get('/', validatePagination, handleValidationErrors, agentHandlers.listAgents)
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /agents/{agentId}:
|
||||
* get:
|
||||
* summary: Get agent by ID
|
||||
* tags: [Agents]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Agent details
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/AgentEntity'
|
||||
* 404:
|
||||
* description: Agent not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
agentsRouter.get('/:agentId', validateAgentId, handleValidationErrors, agentHandlers.getAgent)
|
||||
/**
|
||||
* @swagger
|
||||
* /agents/{agentId}:
|
||||
* put:
|
||||
* summary: Replace agent (full update)
|
||||
* tags: [Agents]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ReplaceAgentRequest'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Agent updated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/AgentEntity'
|
||||
* 400:
|
||||
* description: Invalid request body
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* 404:
|
||||
* description: Agent not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
agentsRouter.put('/:agentId', validateAgentId, validateAgentReplace, handleValidationErrors, agentHandlers.updateAgent)
|
||||
/**
|
||||
* @swagger
|
||||
* /agents/{agentId}:
|
||||
* patch:
|
||||
* summary: Update agent (partial update)
|
||||
* tags: [Agents]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/UpdateAgentRequest'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Agent updated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/AgentEntity'
|
||||
* 400:
|
||||
* description: Invalid request body
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* 404:
|
||||
* description: Agent not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
agentsRouter.patch('/:agentId', validateAgentId, validateAgentUpdate, handleValidationErrors, agentHandlers.patchAgent)
|
||||
/**
|
||||
* @swagger
|
||||
* /agents/{agentId}:
|
||||
* delete:
|
||||
* summary: Delete agent
|
||||
* tags: [Agents]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Agent deleted successfully
|
||||
* 404:
|
||||
* description: Agent not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
agentsRouter.delete('/:agentId', validateAgentId, handleValidationErrors, agentHandlers.deleteAgent)
|
||||
|
||||
// Create sessions router with agent context
|
||||
const createSessionsRouter = (): express.Router => {
|
||||
const sessionsRouter = express.Router({ mergeParams: true })
|
||||
|
||||
// Session CRUD routes (nested under agent)
|
||||
/**
|
||||
* @swagger
|
||||
* /agents/{agentId}/sessions:
|
||||
* post:
|
||||
* summary: Create a new session for an agent
|
||||
* tags: [Sessions]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/CreateSessionRequest'
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Session created successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SessionEntity'
|
||||
* 400:
|
||||
* description: Invalid request body
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* 404:
|
||||
* description: Agent not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
sessionsRouter.post('/', validateSession, handleValidationErrors, sessionHandlers.createSession)
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /agents/{agentId}/sessions:
|
||||
* get:
|
||||
* summary: List sessions for an agent
|
||||
* tags: [Sessions]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* default: 20
|
||||
* description: Number of sessions to return
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* default: 0
|
||||
* description: Number of sessions to skip
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [idle, running, completed, failed, stopped]
|
||||
* description: Filter by session status
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of sessions
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ListSessionsResponse'
|
||||
* 404:
|
||||
* description: Agent not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
sessionsRouter.get('/', validatePagination, handleValidationErrors, sessionHandlers.listSessions)
|
||||
/**
|
||||
* @swagger
|
||||
* /agents/{agentId}/sessions/{sessionId}:
|
||||
* get:
|
||||
* summary: Get session by ID
|
||||
* tags: [Sessions]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* - in: path
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Session ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session details
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SessionEntity'
|
||||
* 404:
|
||||
* description: Agent or session not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
sessionsRouter.get('/:sessionId', validateSessionId, handleValidationErrors, sessionHandlers.getSession)
|
||||
/**
|
||||
* @swagger
|
||||
* /agents/{agentId}/sessions/{sessionId}:
|
||||
* put:
|
||||
* summary: Replace session (full update)
|
||||
* tags: [Sessions]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* - in: path
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Session ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ReplaceSessionRequest'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session updated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SessionEntity'
|
||||
* 400:
|
||||
* description: Invalid request body
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* 404:
|
||||
* description: Agent or session not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
sessionsRouter.put(
|
||||
'/:sessionId',
|
||||
validateSessionId,
|
||||
validateSessionReplace,
|
||||
handleValidationErrors,
|
||||
sessionHandlers.updateSession
|
||||
)
|
||||
/**
|
||||
* @swagger
|
||||
* /agents/{agentId}/sessions/{sessionId}:
|
||||
* patch:
|
||||
* summary: Update session (partial update)
|
||||
* tags: [Sessions]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* - in: path
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Session ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/UpdateSessionRequest'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session updated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SessionEntity'
|
||||
* 400:
|
||||
* description: Invalid request body
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* 404:
|
||||
* description: Agent or session not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
sessionsRouter.patch(
|
||||
'/:sessionId',
|
||||
validateSessionId,
|
||||
validateSessionUpdate,
|
||||
handleValidationErrors,
|
||||
sessionHandlers.patchSession
|
||||
)
|
||||
/**
|
||||
* @swagger
|
||||
* /agents/{agentId}/sessions/{sessionId}:
|
||||
* delete:
|
||||
* summary: Delete session
|
||||
* tags: [Sessions]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* - in: path
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Session ID
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Session deleted successfully
|
||||
* 404:
|
||||
* description: Agent or session not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
sessionsRouter.delete('/:sessionId', validateSessionId, handleValidationErrors, sessionHandlers.deleteSession)
|
||||
|
||||
return sessionsRouter
|
||||
}
|
||||
|
||||
// Create messages router with agent and session context
|
||||
const createMessagesRouter = (): express.Router => {
|
||||
const messagesRouter = express.Router({ mergeParams: true })
|
||||
|
||||
// Message CRUD routes (nested under agent/session)
|
||||
/**
|
||||
* @swagger
|
||||
* /agents/{agentId}/sessions/{sessionId}/messages:
|
||||
* post:
|
||||
* summary: Create a new message in a session
|
||||
* tags: [Messages]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* - in: path
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Session ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/CreateSessionMessageRequest'
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Message created successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: number
|
||||
* description: Message ID
|
||||
* session_id:
|
||||
* type: string
|
||||
* description: Session ID
|
||||
* role:
|
||||
* type: string
|
||||
* enum: [assistant, user, system, tool]
|
||||
* description: Message role
|
||||
* content:
|
||||
* type: object
|
||||
* description: Message content (AI SDK format)
|
||||
* agent_session_id:
|
||||
* type: string
|
||||
* description: Agent session ID for resuming
|
||||
* metadata:
|
||||
* type: object
|
||||
* description: Additional metadata
|
||||
* created_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* updated_at:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* 400:
|
||||
* description: Invalid request body
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* 404:
|
||||
* description: Agent or session not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
messagesRouter.post('/', validateSessionMessage, handleValidationErrors, messageHandlers.createMessage)
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /agents/{agentId}/sessions/{sessionId}/messages/{messageId}:
|
||||
* delete:
|
||||
* summary: Delete a message from a session
|
||||
* tags: [Messages]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: agentId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Agent ID
|
||||
* - in: path
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Session ID
|
||||
* - in: path
|
||||
* name: messageId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Message ID
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Message deleted successfully
|
||||
* 404:
|
||||
* description: Agent, session, or message not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
messagesRouter.delete('/:messageId', validateSessionMessageId, handleValidationErrors, messageHandlers.deleteMessage)
|
||||
return messagesRouter
|
||||
}
|
||||
|
||||
// Mount nested resources with clear hierarchy
|
||||
const sessionsRouter = createSessionsRouter()
|
||||
const messagesRouter = createMessagesRouter()
|
||||
|
||||
// Mount sessions under specific agent
|
||||
agentsRouter.use('/:agentId/sessions', validateAgentId, checkAgentExists, handleValidationErrors, sessionsRouter)
|
||||
|
||||
// Mount messages under specific agent/session
|
||||
agentsRouter.use(
|
||||
'/:agentId/sessions/:sessionId/messages',
|
||||
validateAgentId,
|
||||
validateSessionId,
|
||||
handleValidationErrors,
|
||||
messagesRouter
|
||||
)
|
||||
|
||||
// Export main router and convenience router
|
||||
export const agentsRoutes = agentsRouter
|
||||
44
src/main/apiServer/routes/agents/middleware/common.ts
Normal file
44
src/main/apiServer/routes/agents/middleware/common.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
import { agentService } from '../../../../services/agents'
|
||||
import { loggerService } from '../../../../services/LoggerService'
|
||||
|
||||
const logger = loggerService.withContext('ApiServerMiddleware')
|
||||
|
||||
// Since Zod validators handle their own errors, this is now a pass-through
|
||||
export const handleValidationErrors = (_req: Request, _res: Response, next: any): void => {
|
||||
next()
|
||||
}
|
||||
|
||||
// Middleware to check if agent exists
|
||||
export const checkAgentExists = async (req: Request, res: Response, next: any): Promise<void> => {
|
||||
try {
|
||||
const { agentId } = req.params
|
||||
const exists = await agentService.agentExists(agentId)
|
||||
|
||||
if (!exists) {
|
||||
res.status(404).json({
|
||||
error: {
|
||||
message: 'Agent not found',
|
||||
type: 'not_found',
|
||||
code: 'agent_not_found'
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
} catch (error) {
|
||||
logger.error('Error checking agent existence', {
|
||||
error: error as Error,
|
||||
agentId: req.params.agentId
|
||||
})
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to validate agent',
|
||||
type: 'internal_error',
|
||||
code: 'agent_validation_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
1
src/main/apiServer/routes/agents/middleware/index.ts
Normal file
1
src/main/apiServer/routes/agents/middleware/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './common'
|
||||
24
src/main/apiServer/routes/agents/validators/agents.ts
Normal file
24
src/main/apiServer/routes/agents/validators/agents.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
AgentIdParamSchema,
|
||||
CreateAgentRequestSchema,
|
||||
ReplaceAgentRequestSchema,
|
||||
UpdateAgentRequestSchema
|
||||
} from '@types'
|
||||
|
||||
import { createZodValidator } from './zodValidator'
|
||||
|
||||
export const validateAgent = createZodValidator({
|
||||
body: CreateAgentRequestSchema
|
||||
})
|
||||
|
||||
export const validateAgentReplace = createZodValidator({
|
||||
body: ReplaceAgentRequestSchema
|
||||
})
|
||||
|
||||
export const validateAgentUpdate = createZodValidator({
|
||||
body: UpdateAgentRequestSchema
|
||||
})
|
||||
|
||||
export const validateAgentId = createZodValidator({
|
||||
params: AgentIdParamSchema
|
||||
})
|
||||
7
src/main/apiServer/routes/agents/validators/common.ts
Normal file
7
src/main/apiServer/routes/agents/validators/common.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PaginationQuerySchema } from '@types'
|
||||
|
||||
import { createZodValidator } from './zodValidator'
|
||||
|
||||
export const validatePagination = createZodValidator({
|
||||
query: PaginationQuerySchema
|
||||
})
|
||||
4
src/main/apiServer/routes/agents/validators/index.ts
Normal file
4
src/main/apiServer/routes/agents/validators/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './agents'
|
||||
export * from './common'
|
||||
export * from './messages'
|
||||
export * from './sessions'
|
||||
11
src/main/apiServer/routes/agents/validators/messages.ts
Normal file
11
src/main/apiServer/routes/agents/validators/messages.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { CreateSessionMessageRequestSchema, SessionMessageIdParamSchema } from '@types'
|
||||
|
||||
import { createZodValidator } from './zodValidator'
|
||||
|
||||
export const validateSessionMessage = createZodValidator({
|
||||
body: CreateSessionMessageRequestSchema
|
||||
})
|
||||
|
||||
export const validateSessionMessageId = createZodValidator({
|
||||
params: SessionMessageIdParamSchema
|
||||
})
|
||||
24
src/main/apiServer/routes/agents/validators/sessions.ts
Normal file
24
src/main/apiServer/routes/agents/validators/sessions.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
CreateSessionRequestSchema,
|
||||
ReplaceSessionRequestSchema,
|
||||
SessionIdParamSchema,
|
||||
UpdateSessionRequestSchema
|
||||
} from '@types'
|
||||
|
||||
import { createZodValidator } from './zodValidator'
|
||||
|
||||
export const validateSession = createZodValidator({
|
||||
body: CreateSessionRequestSchema
|
||||
})
|
||||
|
||||
export const validateSessionReplace = createZodValidator({
|
||||
body: ReplaceSessionRequestSchema
|
||||
})
|
||||
|
||||
export const validateSessionUpdate = createZodValidator({
|
||||
body: UpdateSessionRequestSchema
|
||||
})
|
||||
|
||||
export const validateSessionId = createZodValidator({
|
||||
params: SessionIdParamSchema
|
||||
})
|
||||
68
src/main/apiServer/routes/agents/validators/zodValidator.ts
Normal file
68
src/main/apiServer/routes/agents/validators/zodValidator.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
import { ZodError, ZodType } from 'zod'
|
||||
|
||||
export interface ValidationRequest extends Request {
|
||||
validatedBody?: any
|
||||
validatedParams?: any
|
||||
validatedQuery?: any
|
||||
}
|
||||
|
||||
export interface ZodValidationConfig {
|
||||
body?: ZodType
|
||||
params?: ZodType
|
||||
query?: ZodType
|
||||
}
|
||||
|
||||
export const createZodValidator = (config: ZodValidationConfig) => {
|
||||
return (req: ValidationRequest, res: Response, next: NextFunction): void => {
|
||||
try {
|
||||
if (config.body && req.body) {
|
||||
req.validatedBody = config.body.parse(req.body)
|
||||
}
|
||||
|
||||
if (config.params && req.params) {
|
||||
req.validatedParams = config.params.parse(req.params)
|
||||
}
|
||||
|
||||
if (config.query && req.query) {
|
||||
req.validatedQuery = config.query.parse(req.query)
|
||||
}
|
||||
|
||||
next()
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const validationErrors = error.issues.map((err) => ({
|
||||
type: 'field',
|
||||
value: err.input,
|
||||
msg: err.message,
|
||||
path: err.path.map((p) => String(p)).join('.'),
|
||||
location: getLocationFromPath(err.path, config)
|
||||
}))
|
||||
|
||||
res.status(400).json({
|
||||
error: {
|
||||
message: 'Validation failed',
|
||||
type: 'validation_error',
|
||||
details: validationErrors
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Internal validation error',
|
||||
type: 'internal_error',
|
||||
code: 'validation_processing_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLocationFromPath(path: (string | number | symbol)[], config: ZodValidationConfig): string {
|
||||
if (config.body && path.length > 0) return 'body'
|
||||
if (config.params && path.length > 0) return 'params'
|
||||
if (config.query && path.length > 0) return 'query'
|
||||
return 'unknown'
|
||||
}
|
||||
@@ -1,15 +1,105 @@
|
||||
import express, { Request, Response } from 'express'
|
||||
import OpenAI from 'openai'
|
||||
import { ChatCompletionCreateParams } from 'openai/resources'
|
||||
|
||||
import { loggerService } from '../../services/LoggerService'
|
||||
import { chatCompletionService } from '../services/chat-completion'
|
||||
import { validateModelId } from '../utils'
|
||||
import {
|
||||
ChatCompletionModelError,
|
||||
chatCompletionService,
|
||||
ChatCompletionValidationError
|
||||
} from '../services/chat-completion'
|
||||
|
||||
const logger = loggerService.withContext('ApiServerChatRoutes')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
interface ErrorResponseBody {
|
||||
error: {
|
||||
message: string
|
||||
type: string
|
||||
code: string
|
||||
}
|
||||
}
|
||||
|
||||
const mapChatCompletionError = (error: unknown): { status: number; body: ErrorResponseBody } => {
|
||||
if (error instanceof ChatCompletionValidationError) {
|
||||
logger.warn('Chat completion validation error', {
|
||||
errors: error.errors
|
||||
})
|
||||
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
error: {
|
||||
message: error.errors.join('; '),
|
||||
type: 'invalid_request_error',
|
||||
code: 'validation_failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof ChatCompletionModelError) {
|
||||
logger.warn('Chat completion model error', error.error)
|
||||
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
error: {
|
||||
message: error.error.message,
|
||||
type: 'invalid_request_error',
|
||||
code: error.error.code
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
let statusCode = 500
|
||||
let errorType = 'server_error'
|
||||
let errorCode = 'internal_error'
|
||||
|
||||
if (error.message.includes('API key') || error.message.includes('authentication')) {
|
||||
statusCode = 401
|
||||
errorType = 'authentication_error'
|
||||
errorCode = 'invalid_api_key'
|
||||
} else if (error.message.includes('rate limit') || error.message.includes('quota')) {
|
||||
statusCode = 429
|
||||
errorType = 'rate_limit_error'
|
||||
errorCode = 'rate_limit_exceeded'
|
||||
} else if (error.message.includes('timeout') || error.message.includes('connection')) {
|
||||
statusCode = 502
|
||||
errorType = 'server_error'
|
||||
errorCode = 'upstream_error'
|
||||
}
|
||||
|
||||
logger.error('Chat completion error', { error })
|
||||
|
||||
return {
|
||||
status: statusCode,
|
||||
body: {
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
type: errorType,
|
||||
code: errorCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.error('Chat completion unknown error', { error })
|
||||
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
error: {
|
||||
message: 'Internal server error',
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /v1/chat/completions:
|
||||
@@ -60,7 +150,7 @@ const router = express.Router()
|
||||
* type: integer
|
||||
* total_tokens:
|
||||
* type: integer
|
||||
* text/plain:
|
||||
* text/event-stream:
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Server-sent events stream (when stream=true)
|
||||
@@ -103,72 +193,31 @@ router.post('/completions', async (req: Request, res: Response) => {
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Chat completion request:', {
|
||||
logger.debug('Chat completion request', {
|
||||
model: request.model,
|
||||
messageCount: request.messages?.length || 0,
|
||||
stream: request.stream,
|
||||
temperature: request.temperature
|
||||
})
|
||||
|
||||
// Validate request
|
||||
const validation = chatCompletionService.validateRequest(request)
|
||||
if (!validation.isValid) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: validation.errors.join('; '),
|
||||
type: 'invalid_request_error',
|
||||
code: 'validation_failed'
|
||||
}
|
||||
})
|
||||
}
|
||||
const isStreaming = !!request.stream
|
||||
|
||||
// Validate model ID and get provider
|
||||
const modelValidation = await validateModelId(request.model)
|
||||
if (!modelValidation.valid) {
|
||||
const error = modelValidation.error!
|
||||
logger.warn(`Model validation failed for '${request.model}':`, error)
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: error.message,
|
||||
type: 'invalid_request_error',
|
||||
code: error.code
|
||||
}
|
||||
})
|
||||
}
|
||||
if (isStreaming) {
|
||||
const { stream } = await chatCompletionService.processStreamingCompletion(request)
|
||||
|
||||
const provider = modelValidation.provider!
|
||||
const modelId = modelValidation.modelId!
|
||||
|
||||
logger.info('Model validation successful:', {
|
||||
provider: provider.id,
|
||||
providerType: provider.type,
|
||||
modelId: modelId,
|
||||
fullModelId: request.model
|
||||
})
|
||||
|
||||
// Create OpenAI client
|
||||
const client = new OpenAI({
|
||||
baseURL: provider.apiHost,
|
||||
apiKey: provider.apiKey
|
||||
})
|
||||
request.model = modelId
|
||||
|
||||
// Handle streaming
|
||||
if (request.stream) {
|
||||
const streamResponse = await client.chat.completions.create(request)
|
||||
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.setHeader('X-Accel-Buffering', 'no')
|
||||
res.flushHeaders()
|
||||
|
||||
try {
|
||||
for await (const chunk of streamResponse as any) {
|
||||
for await (const chunk of stream) {
|
||||
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||
}
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
} catch (streamError: any) {
|
||||
logger.error('Stream error:', streamError)
|
||||
logger.error('Stream error', { error: streamError })
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
error: {
|
||||
@@ -178,47 +227,17 @@ router.post('/completions', async (req: Request, res: Response) => {
|
||||
}
|
||||
})}\n\n`
|
||||
)
|
||||
} finally {
|
||||
res.end()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle non-streaming
|
||||
const response = await client.chat.completions.create(request)
|
||||
const { response } = await chatCompletionService.processCompletion(request)
|
||||
return res.json(response)
|
||||
} catch (error: any) {
|
||||
logger.error('Chat completion error:', error)
|
||||
|
||||
let statusCode = 500
|
||||
let errorType = 'server_error'
|
||||
let errorCode = 'internal_error'
|
||||
let errorMessage = 'Internal server error'
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message
|
||||
|
||||
if (error.message.includes('API key') || error.message.includes('authentication')) {
|
||||
statusCode = 401
|
||||
errorType = 'authentication_error'
|
||||
errorCode = 'invalid_api_key'
|
||||
} else if (error.message.includes('rate limit') || error.message.includes('quota')) {
|
||||
statusCode = 429
|
||||
errorType = 'rate_limit_error'
|
||||
errorCode = 'rate_limit_exceeded'
|
||||
} else if (error.message.includes('timeout') || error.message.includes('connection')) {
|
||||
statusCode = 502
|
||||
errorType = 'server_error'
|
||||
errorCode = 'upstream_error'
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(statusCode).json({
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: errorType,
|
||||
code: errorCode
|
||||
}
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const { status, body } = mapChatCompletionError(error)
|
||||
return res.status(status).json(body)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -43,14 +43,14 @@ const router = express.Router()
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
logger.info('Get all MCP servers request received')
|
||||
logger.debug('Listing MCP servers')
|
||||
const servers = await mcpApiService.getAllServers(req)
|
||||
return res.json({
|
||||
success: true,
|
||||
data: servers
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error('Error fetching MCP servers:', error)
|
||||
logger.error('Error fetching MCP servers', { error })
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: {
|
||||
@@ -103,10 +103,12 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
*/
|
||||
router.get('/:server_id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
logger.info('Get MCP server info request received')
|
||||
logger.debug('Get MCP server info request received', {
|
||||
serverId: req.params.server_id
|
||||
})
|
||||
const server = await mcpApiService.getServerInfo(req.params.server_id)
|
||||
if (!server) {
|
||||
logger.warn('MCP server not found')
|
||||
logger.warn('MCP server not found', { serverId: req.params.server_id })
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
@@ -121,7 +123,7 @@ router.get('/:server_id', async (req: Request, res: Response) => {
|
||||
data: server
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error('Error fetching MCP server info:', error)
|
||||
logger.error('Error fetching MCP server info', { error, serverId: req.params.server_id })
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: {
|
||||
@@ -137,7 +139,7 @@ router.get('/:server_id', async (req: Request, res: Response) => {
|
||||
router.all('/:server_id/mcp', async (req: Request, res: Response) => {
|
||||
const server = await mcpApiService.getServerById(req.params.server_id)
|
||||
if (!server) {
|
||||
logger.warn('MCP server not found')
|
||||
logger.warn('MCP server not found', { serverId: req.params.server_id })
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
|
||||
403
src/main/apiServer/routes/messages.ts
Normal file
403
src/main/apiServer/routes/messages.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { MessageCreateParams } from '@anthropic-ai/sdk/resources'
|
||||
import { loggerService } from '@logger'
|
||||
import { Provider } from '@types'
|
||||
import express, { Request, Response } from 'express'
|
||||
|
||||
import { messagesService } from '../services/messages'
|
||||
import { getProviderById, validateModelId } from '../utils'
|
||||
|
||||
const logger = loggerService.withContext('ApiServerMessagesRoutes')
|
||||
|
||||
const router = express.Router()
|
||||
const providerRouter = express.Router({ mergeParams: true })
|
||||
|
||||
// Helper function for basic request validation
|
||||
async function validateRequestBody(req: Request): Promise<{ valid: boolean; error?: any }> {
|
||||
const request: MessageCreateParams = req.body
|
||||
|
||||
if (!request) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'invalid_request_error',
|
||||
message: 'Request body is required'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
interface HandleMessageProcessingOptions {
|
||||
req: Request
|
||||
res: Response
|
||||
provider: Provider
|
||||
request: MessageCreateParams
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
async function handleMessageProcessing({
|
||||
req,
|
||||
res,
|
||||
provider,
|
||||
request,
|
||||
modelId
|
||||
}: HandleMessageProcessingOptions): Promise<void> {
|
||||
try {
|
||||
const validation = messagesService.validateRequest(request)
|
||||
if (!validation.isValid) {
|
||||
res.status(400).json({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'invalid_request_error',
|
||||
message: validation.errors.join('; ')
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const extraHeaders = messagesService.prepareHeaders(req.headers)
|
||||
const { client, anthropicRequest } = await messagesService.processMessage({
|
||||
provider,
|
||||
request,
|
||||
extraHeaders,
|
||||
modelId
|
||||
})
|
||||
|
||||
if (request.stream) {
|
||||
await messagesService.handleStreaming(client, anthropicRequest, { response: res }, provider)
|
||||
return
|
||||
}
|
||||
|
||||
const response = await client.messages.create(anthropicRequest)
|
||||
res.json(response)
|
||||
} catch (error: any) {
|
||||
logger.error('Message processing error', { error })
|
||||
const { statusCode, errorResponse } = messagesService.transformError(error)
|
||||
res.status(statusCode).json(errorResponse)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /v1/messages:
|
||||
* post:
|
||||
* summary: Create message
|
||||
* description: Create a message response using Anthropic's API format
|
||||
* tags: [Messages]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - model
|
||||
* - max_tokens
|
||||
* - messages
|
||||
* properties:
|
||||
* model:
|
||||
* type: string
|
||||
* description: Model ID in format "provider:model_id"
|
||||
* example: "my-anthropic:claude-3-5-sonnet-20241022"
|
||||
* max_tokens:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* description: Maximum number of tokens to generate
|
||||
* example: 1024
|
||||
* messages:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* role:
|
||||
* type: string
|
||||
* enum: [user, assistant]
|
||||
* content:
|
||||
* oneOf:
|
||||
* - type: string
|
||||
* - type: array
|
||||
* system:
|
||||
* type: string
|
||||
* description: System message
|
||||
* temperature:
|
||||
* type: number
|
||||
* minimum: 0
|
||||
* maximum: 1
|
||||
* description: Sampling temperature
|
||||
* top_p:
|
||||
* type: number
|
||||
* minimum: 0
|
||||
* maximum: 1
|
||||
* description: Nucleus sampling
|
||||
* top_k:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* description: Top-k sampling
|
||||
* stream:
|
||||
* type: boolean
|
||||
* description: Whether to stream the response
|
||||
* tools:
|
||||
* type: array
|
||||
* description: Available tools for the model
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Message response
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* type:
|
||||
* type: string
|
||||
* example: message
|
||||
* role:
|
||||
* type: string
|
||||
* example: assistant
|
||||
* content:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* model:
|
||||
* type: string
|
||||
* stop_reason:
|
||||
* type: string
|
||||
* stop_sequence:
|
||||
* type: string
|
||||
* usage:
|
||||
* type: object
|
||||
* properties:
|
||||
* input_tokens:
|
||||
* type: integer
|
||||
* output_tokens:
|
||||
* type: integer
|
||||
* text/event-stream:
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Server-sent events stream (when stream=true)
|
||||
* 400:
|
||||
* description: Bad request
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* example: error
|
||||
* error:
|
||||
* type: object
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* message:
|
||||
* type: string
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 429:
|
||||
* description: Rate limit exceeded
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
// Validate request body
|
||||
const bodyValidation = await validateRequestBody(req)
|
||||
if (!bodyValidation.valid) {
|
||||
return res.status(400).json(bodyValidation.error)
|
||||
}
|
||||
|
||||
try {
|
||||
const request: MessageCreateParams = req.body
|
||||
|
||||
// Validate model ID and get provider
|
||||
const modelValidation = await validateModelId(request.model)
|
||||
if (!modelValidation.valid) {
|
||||
const error = modelValidation.error!
|
||||
logger.warn('Model validation failed', {
|
||||
model: request.model,
|
||||
error
|
||||
})
|
||||
return res.status(400).json({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'invalid_request_error',
|
||||
message: error.message
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const provider = modelValidation.provider!
|
||||
const modelId = modelValidation.modelId!
|
||||
|
||||
return handleMessageProcessing({ req, res, provider, request, modelId })
|
||||
} catch (error: any) {
|
||||
logger.error('Message processing error', { error })
|
||||
const { statusCode, errorResponse } = messagesService.transformError(error)
|
||||
return res.status(statusCode).json(errorResponse)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /{provider_id}/v1/messages:
|
||||
* post:
|
||||
* summary: Create message with provider in path
|
||||
* description: Create a message response using provider ID from URL path
|
||||
* tags: [Messages]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: provider_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Provider ID (e.g., "my-anthropic")
|
||||
* example: "my-anthropic"
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - model
|
||||
* - max_tokens
|
||||
* - messages
|
||||
* properties:
|
||||
* model:
|
||||
* type: string
|
||||
* description: Model ID without provider prefix
|
||||
* example: "claude-3-5-sonnet-20241022"
|
||||
* max_tokens:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* description: Maximum number of tokens to generate
|
||||
* example: 1024
|
||||
* messages:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* role:
|
||||
* type: string
|
||||
* enum: [user, assistant]
|
||||
* content:
|
||||
* oneOf:
|
||||
* - type: string
|
||||
* - type: array
|
||||
* system:
|
||||
* type: string
|
||||
* description: System message
|
||||
* temperature:
|
||||
* type: number
|
||||
* minimum: 0
|
||||
* maximum: 1
|
||||
* description: Sampling temperature
|
||||
* top_p:
|
||||
* type: number
|
||||
* minimum: 0
|
||||
* maximum: 1
|
||||
* description: Nucleus sampling
|
||||
* top_k:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* description: Top-k sampling
|
||||
* stream:
|
||||
* type: boolean
|
||||
* description: Whether to stream the response
|
||||
* tools:
|
||||
* type: array
|
||||
* description: Available tools for the model
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Message response
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* type:
|
||||
* type: string
|
||||
* example: message
|
||||
* role:
|
||||
* type: string
|
||||
* example: assistant
|
||||
* content:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* model:
|
||||
* type: string
|
||||
* stop_reason:
|
||||
* type: string
|
||||
* stop_sequence:
|
||||
* type: string
|
||||
* usage:
|
||||
* type: object
|
||||
* properties:
|
||||
* input_tokens:
|
||||
* type: integer
|
||||
* output_tokens:
|
||||
* type: integer
|
||||
* text/event-stream:
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Server-sent events stream (when stream=true)
|
||||
* 400:
|
||||
* description: Bad request
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 429:
|
||||
* description: Rate limit exceeded
|
||||
* 500:
|
||||
* description: Internal server error
|
||||
*/
|
||||
providerRouter.post('/', async (req: Request, res: Response) => {
|
||||
// Validate request body
|
||||
const bodyValidation = await validateRequestBody(req)
|
||||
if (!bodyValidation.valid) {
|
||||
return res.status(400).json(bodyValidation.error)
|
||||
}
|
||||
|
||||
try {
|
||||
const providerId = req.params.provider
|
||||
|
||||
if (!providerId) {
|
||||
return res.status(400).json({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'invalid_request_error',
|
||||
message: 'Provider ID is required in URL path'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Get provider directly by ID from URL path
|
||||
const provider = await getProviderById(providerId)
|
||||
if (!provider) {
|
||||
return res.status(400).json({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'invalid_request_error',
|
||||
message: `Provider '${providerId}' not found or not enabled`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const request: MessageCreateParams = req.body
|
||||
|
||||
return handleMessageProcessing({ req, res, provider, request })
|
||||
} catch (error: any) {
|
||||
logger.error('Message processing error', { error })
|
||||
const { statusCode, errorResponse } = messagesService.transformError(error)
|
||||
return res.status(statusCode).json(errorResponse)
|
||||
}
|
||||
})
|
||||
|
||||
export { providerRouter as messagesProviderRoutes, router as messagesRoutes }
|
||||
@@ -1,73 +1,124 @@
|
||||
import { ApiModelsFilterSchema, ApiModelsResponse } from '@types'
|
||||
import express, { Request, Response } from 'express'
|
||||
|
||||
import { loggerService } from '../../services/LoggerService'
|
||||
import { chatCompletionService } from '../services/chat-completion'
|
||||
import { modelsService } from '../services/models'
|
||||
|
||||
const logger = loggerService.withContext('ApiServerModelsRoutes')
|
||||
|
||||
const router = express.Router()
|
||||
const router = express
|
||||
.Router()
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /v1/models:
|
||||
* get:
|
||||
* summary: List available models
|
||||
* description: Returns a list of available AI models from all configured providers
|
||||
* tags: [Models]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of available models
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* object:
|
||||
* type: string
|
||||
* example: list
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Model'
|
||||
* 503:
|
||||
* description: Service unavailable
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
logger.info('Models list request received')
|
||||
/**
|
||||
* @swagger
|
||||
* /v1/models:
|
||||
* get:
|
||||
* summary: List available models
|
||||
* description: Returns a list of available AI models from all configured providers with optional filtering
|
||||
* tags: [Models]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: providerType
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [openai, openai-response, anthropic, gemini]
|
||||
* description: Filter models by provider type
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 0
|
||||
* default: 0
|
||||
* description: Pagination offset
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* description: Maximum number of models to return
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of available models
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* object:
|
||||
* type: string
|
||||
* example: list
|
||||
* data:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Model'
|
||||
* total:
|
||||
* type: integer
|
||||
* description: Total number of models (when using pagination)
|
||||
* offset:
|
||||
* type: integer
|
||||
* description: Current offset (when using pagination)
|
||||
* limit:
|
||||
* type: integer
|
||||
* description: Current limit (when using pagination)
|
||||
* 400:
|
||||
* description: Invalid query parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 503:
|
||||
* description: Service unavailable
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
logger.debug('Models list request received', { query: req.query })
|
||||
|
||||
const models = await chatCompletionService.getModels()
|
||||
// Validate query parameters using Zod schema
|
||||
const filterResult = ApiModelsFilterSchema.safeParse(req.query)
|
||||
|
||||
if (models.length === 0) {
|
||||
logger.warn(
|
||||
'No models available from providers. This may be because no OpenAI providers are configured or enabled.'
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`Returning ${models.length} models (OpenAI providers only)`)
|
||||
logger.debug(
|
||||
'Model IDs:',
|
||||
models.map((m) => m.id)
|
||||
)
|
||||
|
||||
return res.json({
|
||||
object: 'list',
|
||||
data: models
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error('Error fetching models:', error)
|
||||
return res.status(503).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve models from available providers',
|
||||
type: 'service_unavailable',
|
||||
code: 'models_unavailable'
|
||||
if (!filterResult.success) {
|
||||
logger.warn('Invalid model query parameters', { issues: filterResult.error.issues })
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid query parameters',
|
||||
type: 'invalid_request_error',
|
||||
code: 'invalid_parameters',
|
||||
details: filterResult.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
message: issue.message
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const filter = filterResult.data
|
||||
const response = await modelsService.getModels(filter)
|
||||
|
||||
if (response.data.length === 0) {
|
||||
logger.warn('No models available from providers', { filter })
|
||||
}
|
||||
|
||||
logger.info('Models response ready', {
|
||||
filter,
|
||||
total: response.total,
|
||||
modelIds: response.data.map((m) => m.id)
|
||||
})
|
||||
|
||||
return res.json(response satisfies ApiModelsResponse)
|
||||
} catch (error: any) {
|
||||
logger.error('Error fetching models', { error })
|
||||
return res.status(503).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve models from available providers',
|
||||
type: 'service_unavailable',
|
||||
code: 'models_unavailable'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export { router as modelsRoutes }
|
||||
|
||||
@@ -1,44 +1,72 @@
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
import { loggerService } from '../services/LoggerService'
|
||||
import { loggerService } from '@logger'
|
||||
|
||||
import { agentService } from '../services/agents'
|
||||
import { app } from './app'
|
||||
import { config } from './config'
|
||||
|
||||
const logger = loggerService.withContext('ApiServer')
|
||||
|
||||
const GLOBAL_REQUEST_TIMEOUT_MS = 5 * 60_000
|
||||
const GLOBAL_HEADERS_TIMEOUT_MS = GLOBAL_REQUEST_TIMEOUT_MS + 5_000
|
||||
const GLOBAL_KEEPALIVE_TIMEOUT_MS = 60_000
|
||||
|
||||
export class ApiServer {
|
||||
private server: ReturnType<typeof createServer> | null = null
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.server) {
|
||||
if (this.server && this.server.listening) {
|
||||
logger.warn('Server already running')
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up any failed server instance
|
||||
if (this.server && !this.server.listening) {
|
||||
logger.warn('Cleaning up failed server instance')
|
||||
this.server = null
|
||||
}
|
||||
|
||||
// Load config
|
||||
const { port, host, apiKey } = await config.load()
|
||||
const { port, host } = await config.load()
|
||||
|
||||
// Initialize AgentService
|
||||
logger.info('Initializing AgentService')
|
||||
await agentService.initialize()
|
||||
logger.info('AgentService initialized')
|
||||
|
||||
// Create server with Express app
|
||||
this.server = createServer(app)
|
||||
this.applyServerTimeouts(this.server)
|
||||
|
||||
// Start server
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server!.listen(port, host, () => {
|
||||
logger.info(`API Server started at http://${host}:${port}`)
|
||||
logger.info(`API Key: ${apiKey}`)
|
||||
logger.info('API server started', { host, port })
|
||||
resolve()
|
||||
})
|
||||
|
||||
this.server!.on('error', reject)
|
||||
this.server!.on('error', (error) => {
|
||||
// Clean up the server instance if listen fails
|
||||
this.server = null
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private applyServerTimeouts(server: ReturnType<typeof createServer>): void {
|
||||
server.requestTimeout = GLOBAL_REQUEST_TIMEOUT_MS
|
||||
server.headersTimeout = Math.max(GLOBAL_HEADERS_TIMEOUT_MS, server.requestTimeout + 1_000)
|
||||
server.keepAliveTimeout = GLOBAL_KEEPALIVE_TIMEOUT_MS
|
||||
server.setTimeout(0)
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.server) return
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.server!.close(() => {
|
||||
logger.info('API Server stopped')
|
||||
logger.info('API server stopped')
|
||||
this.server = null
|
||||
resolve()
|
||||
})
|
||||
@@ -56,7 +84,7 @@ export class ApiServer {
|
||||
const isListening = this.server?.listening || false
|
||||
const result = hasServer && isListening
|
||||
|
||||
logger.debug('isRunning check:', { hasServer, isListening, result })
|
||||
logger.debug('isRunning check', { hasServer, isListening, result })
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,83 +1,132 @@
|
||||
import { Provider } from '@types'
|
||||
import OpenAI from 'openai'
|
||||
import { ChatCompletionCreateParams } from 'openai/resources'
|
||||
import { ChatCompletionCreateParams, ChatCompletionCreateParamsStreaming } from 'openai/resources'
|
||||
|
||||
import { loggerService } from '../../services/LoggerService'
|
||||
import {
|
||||
getProviderByModel,
|
||||
getRealProviderModel,
|
||||
listAllAvailableModels,
|
||||
OpenAICompatibleModel,
|
||||
transformModelToOpenAI,
|
||||
validateProvider
|
||||
} from '../utils'
|
||||
import { ModelValidationError, validateModelId } from '../utils'
|
||||
|
||||
const logger = loggerService.withContext('ChatCompletionService')
|
||||
|
||||
export interface ModelData extends OpenAICompatibleModel {
|
||||
provider_id: string
|
||||
model_id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export class ChatCompletionValidationError extends Error {
|
||||
constructor(public readonly errors: string[]) {
|
||||
super(`Request validation failed: ${errors.join('; ')}`)
|
||||
this.name = 'ChatCompletionValidationError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ChatCompletionModelError extends Error {
|
||||
constructor(public readonly error: ModelValidationError) {
|
||||
super(`Model validation failed: ${error.message}`)
|
||||
this.name = 'ChatCompletionModelError'
|
||||
}
|
||||
}
|
||||
|
||||
export type PrepareRequestResult =
|
||||
| { status: 'validation_error'; errors: string[] }
|
||||
| { status: 'model_error'; error: ModelValidationError }
|
||||
| {
|
||||
status: 'ok'
|
||||
provider: Provider
|
||||
modelId: string
|
||||
client: OpenAI
|
||||
providerRequest: ChatCompletionCreateParams
|
||||
}
|
||||
|
||||
export class ChatCompletionService {
|
||||
async getModels(): Promise<ModelData[]> {
|
||||
try {
|
||||
logger.info('Getting available models from providers')
|
||||
async resolveProviderContext(
|
||||
model: string
|
||||
): Promise<
|
||||
{ ok: false; error: ModelValidationError } | { ok: true; provider: Provider; modelId: string; client: OpenAI }
|
||||
> {
|
||||
const modelValidation = await validateModelId(model)
|
||||
if (!modelValidation.valid) {
|
||||
return {
|
||||
ok: false,
|
||||
error: modelValidation.error!
|
||||
}
|
||||
}
|
||||
|
||||
const models = await listAllAvailableModels()
|
||||
const provider = modelValidation.provider!
|
||||
|
||||
// Use Map to deduplicate models by their full ID (provider:model_id)
|
||||
const uniqueModels = new Map<string, ModelData>()
|
||||
|
||||
for (const model of models) {
|
||||
const openAIModel = transformModelToOpenAI(model)
|
||||
const fullModelId = openAIModel.id // This is already in format "provider:model_id"
|
||||
|
||||
// Only add if not already present (first occurrence wins)
|
||||
if (!uniqueModels.has(fullModelId)) {
|
||||
uniqueModels.set(fullModelId, {
|
||||
...openAIModel,
|
||||
provider_id: model.provider,
|
||||
model_id: model.id,
|
||||
name: model.name
|
||||
})
|
||||
} else {
|
||||
logger.debug(`Skipping duplicate model: ${fullModelId}`)
|
||||
if (provider.type !== 'openai') {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
type: 'unsupported_provider_type',
|
||||
message: `Provider '${provider.id}' of type '${provider.type}' is not supported for OpenAI chat completions`,
|
||||
code: 'unsupported_provider_type'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modelData = Array.from(uniqueModels.values())
|
||||
const modelId = modelValidation.modelId!
|
||||
|
||||
logger.info(`Successfully retrieved ${modelData.length} unique models from ${models.length} total models`)
|
||||
const client = new OpenAI({
|
||||
baseURL: provider.apiHost,
|
||||
apiKey: provider.apiKey
|
||||
})
|
||||
|
||||
if (models.length > modelData.length) {
|
||||
logger.debug(`Filtered out ${models.length - modelData.length} duplicate models`)
|
||||
return {
|
||||
ok: true,
|
||||
provider,
|
||||
modelId,
|
||||
client
|
||||
}
|
||||
}
|
||||
|
||||
async prepareRequest(request: ChatCompletionCreateParams, stream: boolean): Promise<PrepareRequestResult> {
|
||||
const requestValidation = this.validateRequest(request)
|
||||
if (!requestValidation.isValid) {
|
||||
return {
|
||||
status: 'validation_error',
|
||||
errors: requestValidation.errors
|
||||
}
|
||||
}
|
||||
|
||||
return modelData
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting models:', error)
|
||||
return []
|
||||
const providerContext = await this.resolveProviderContext(request.model!)
|
||||
if (!providerContext.ok) {
|
||||
return {
|
||||
status: 'model_error',
|
||||
error: providerContext.error
|
||||
}
|
||||
}
|
||||
|
||||
const { provider, modelId, client } = providerContext
|
||||
|
||||
logger.debug('Model validation successful', {
|
||||
provider: provider.id,
|
||||
providerType: provider.type,
|
||||
modelId,
|
||||
fullModelId: request.model
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
provider,
|
||||
modelId,
|
||||
client,
|
||||
providerRequest: stream
|
||||
? {
|
||||
...request,
|
||||
model: modelId,
|
||||
stream: true as const
|
||||
}
|
||||
: {
|
||||
...request,
|
||||
model: modelId,
|
||||
stream: false as const
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validateRequest(request: ChatCompletionCreateParams): ValidationResult {
|
||||
const errors: string[] = []
|
||||
|
||||
// Validate model
|
||||
if (!request.model) {
|
||||
errors.push('Model is required')
|
||||
} else if (typeof request.model !== 'string') {
|
||||
errors.push('Model must be a string')
|
||||
} else if (!request.model.includes(':')) {
|
||||
errors.push('Model must be in format "provider:model_id"')
|
||||
}
|
||||
|
||||
// Validate messages
|
||||
if (!request.messages) {
|
||||
errors.push('Messages array is required')
|
||||
@@ -98,17 +147,6 @@ export class ChatCompletionService {
|
||||
}
|
||||
|
||||
// Validate optional parameters
|
||||
if (request.temperature !== undefined) {
|
||||
if (typeof request.temperature !== 'number' || request.temperature < 0 || request.temperature > 2) {
|
||||
errors.push('Temperature must be a number between 0 and 2')
|
||||
}
|
||||
}
|
||||
|
||||
if (request.max_tokens !== undefined) {
|
||||
if (typeof request.max_tokens !== 'number' || request.max_tokens < 1) {
|
||||
errors.push('max_tokens must be a positive number')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
@@ -116,48 +154,30 @@ export class ChatCompletionService {
|
||||
}
|
||||
}
|
||||
|
||||
async processCompletion(request: ChatCompletionCreateParams): Promise<OpenAI.Chat.Completions.ChatCompletion> {
|
||||
async processCompletion(request: ChatCompletionCreateParams): Promise<{
|
||||
provider: Provider
|
||||
modelId: string
|
||||
response: OpenAI.Chat.Completions.ChatCompletion
|
||||
}> {
|
||||
try {
|
||||
logger.info('Processing chat completion request:', {
|
||||
logger.debug('Processing chat completion request', {
|
||||
model: request.model,
|
||||
messageCount: request.messages.length,
|
||||
stream: request.stream
|
||||
})
|
||||
|
||||
// Validate request
|
||||
const validation = this.validateRequest(request)
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Request validation failed: ${validation.errors.join(', ')}`)
|
||||
const preparation = await this.prepareRequest(request, false)
|
||||
if (preparation.status === 'validation_error') {
|
||||
throw new ChatCompletionValidationError(preparation.errors)
|
||||
}
|
||||
|
||||
// Get provider for the model
|
||||
const provider = await getProviderByModel(request.model!)
|
||||
if (!provider) {
|
||||
throw new Error(`Provider not found for model: ${request.model}`)
|
||||
if (preparation.status === 'model_error') {
|
||||
throw new ChatCompletionModelError(preparation.error)
|
||||
}
|
||||
|
||||
// Validate provider
|
||||
if (!validateProvider(provider)) {
|
||||
throw new Error(`Provider validation failed for: ${provider.id}`)
|
||||
}
|
||||
const { provider, modelId, client, providerRequest } = preparation
|
||||
|
||||
// Extract model ID from the full model string
|
||||
const modelId = getRealProviderModel(request.model)
|
||||
|
||||
// Create OpenAI client for the provider
|
||||
const client = new OpenAI({
|
||||
baseURL: provider.apiHost,
|
||||
apiKey: provider.apiKey
|
||||
})
|
||||
|
||||
// Prepare request with the actual model ID
|
||||
const providerRequest = {
|
||||
...request,
|
||||
model: modelId,
|
||||
stream: false
|
||||
}
|
||||
|
||||
logger.debug('Sending request to provider:', {
|
||||
logger.debug('Sending request to provider', {
|
||||
provider: provider.id,
|
||||
model: modelId,
|
||||
apiHost: provider.apiHost
|
||||
@@ -165,71 +185,71 @@ export class ChatCompletionService {
|
||||
|
||||
const response = (await client.chat.completions.create(providerRequest)) as OpenAI.Chat.Completions.ChatCompletion
|
||||
|
||||
logger.info('Successfully processed chat completion')
|
||||
return response
|
||||
logger.info('Chat completion processed', {
|
||||
modelId,
|
||||
provider: provider.id
|
||||
})
|
||||
return {
|
||||
provider,
|
||||
modelId,
|
||||
response
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Error processing chat completion:', error)
|
||||
logger.error('Error processing chat completion', {
|
||||
error,
|
||||
model: request.model
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async *processStreamingCompletion(
|
||||
request: ChatCompletionCreateParams
|
||||
): AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk> {
|
||||
async processStreamingCompletion(request: ChatCompletionCreateParams): Promise<{
|
||||
provider: Provider
|
||||
modelId: string
|
||||
stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
|
||||
}> {
|
||||
try {
|
||||
logger.info('Processing streaming chat completion request:', {
|
||||
logger.debug('Processing streaming chat completion request', {
|
||||
model: request.model,
|
||||
messageCount: request.messages.length
|
||||
})
|
||||
|
||||
// Validate request
|
||||
const validation = this.validateRequest(request)
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`Request validation failed: ${validation.errors.join(', ')}`)
|
||||
const preparation = await this.prepareRequest(request, true)
|
||||
if (preparation.status === 'validation_error') {
|
||||
throw new ChatCompletionValidationError(preparation.errors)
|
||||
}
|
||||
|
||||
// Get provider for the model
|
||||
const provider = await getProviderByModel(request.model!)
|
||||
if (!provider) {
|
||||
throw new Error(`Provider not found for model: ${request.model}`)
|
||||
if (preparation.status === 'model_error') {
|
||||
throw new ChatCompletionModelError(preparation.error)
|
||||
}
|
||||
|
||||
// Validate provider
|
||||
if (!validateProvider(provider)) {
|
||||
throw new Error(`Provider validation failed for: ${provider.id}`)
|
||||
}
|
||||
const { provider, modelId, client, providerRequest } = preparation
|
||||
|
||||
// Extract model ID from the full model string
|
||||
const modelId = getRealProviderModel(request.model)
|
||||
|
||||
// Create OpenAI client for the provider
|
||||
const client = new OpenAI({
|
||||
baseURL: provider.apiHost,
|
||||
apiKey: provider.apiKey
|
||||
})
|
||||
|
||||
// Prepare streaming request
|
||||
const streamingRequest = {
|
||||
...request,
|
||||
model: modelId,
|
||||
stream: true as const
|
||||
}
|
||||
|
||||
logger.debug('Sending streaming request to provider:', {
|
||||
logger.debug('Sending streaming request to provider', {
|
||||
provider: provider.id,
|
||||
model: modelId,
|
||||
apiHost: provider.apiHost
|
||||
})
|
||||
|
||||
const stream = await client.chat.completions.create(streamingRequest)
|
||||
const streamRequest = providerRequest as ChatCompletionCreateParamsStreaming
|
||||
const stream = (await client.chat.completions.create(
|
||||
streamRequest
|
||||
)) as AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
|
||||
|
||||
for await (const chunk of stream) {
|
||||
yield chunk
|
||||
logger.info('Streaming chat completion started', {
|
||||
modelId,
|
||||
provider: provider.id
|
||||
})
|
||||
return {
|
||||
provider,
|
||||
modelId,
|
||||
stream
|
||||
}
|
||||
|
||||
logger.info('Successfully completed streaming chat completion')
|
||||
} catch (error: any) {
|
||||
logger.error('Error processing streaming chat completion:', error)
|
||||
logger.error('Error processing streaming chat completion', {
|
||||
error,
|
||||
model: request.model
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@ import { Request, Response } from 'express'
|
||||
import { IncomingMessage, ServerResponse } from 'http'
|
||||
|
||||
import { loggerService } from '../../services/LoggerService'
|
||||
import { reduxService } from '../../services/ReduxService'
|
||||
import { getMcpServerById } from '../utils/mcp'
|
||||
import { getMcpServerById, getMCPServersFromRedux } from '../utils/mcp'
|
||||
|
||||
const logger = loggerService.withContext('MCPApiService')
|
||||
const transports: Record<string, StreamableHTTPServerTransport> = {}
|
||||
@@ -50,42 +49,18 @@ class MCPApiService extends EventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
this.initMcpServer()
|
||||
logger.silly('MCPApiService initialized')
|
||||
logger.debug('MCPApiService initialized')
|
||||
}
|
||||
|
||||
private initMcpServer() {
|
||||
this.transport.onmessage = this.onMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Get servers directly from Redux store
|
||||
*/
|
||||
private async getServersFromRedux(): Promise<MCPServer[]> {
|
||||
try {
|
||||
logger.silly('Getting servers from Redux store')
|
||||
|
||||
// Try to get from cache first (faster)
|
||||
const cachedServers = reduxService.selectSync<MCPServer[]>('state.mcp.servers')
|
||||
if (cachedServers && Array.isArray(cachedServers)) {
|
||||
logger.silly(`Found ${cachedServers.length} servers in Redux cache`)
|
||||
return cachedServers
|
||||
}
|
||||
|
||||
// If cache is not available, get fresh data
|
||||
const servers = await reduxService.select<MCPServer[]>('state.mcp.servers')
|
||||
logger.silly(`Fetched ${servers?.length || 0} servers from Redux store`)
|
||||
return servers || []
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get servers from Redux:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// get all activated servers
|
||||
async getAllServers(req: Request): Promise<McpServersResp> {
|
||||
try {
|
||||
const servers = await this.getServersFromRedux()
|
||||
logger.silly(`Returning ${servers.length} servers`)
|
||||
const servers = await getMCPServersFromRedux()
|
||||
logger.debug('Returning servers from Redux', { count: servers.length })
|
||||
const resp: McpServersResp = {
|
||||
servers: {}
|
||||
}
|
||||
@@ -102,7 +77,7 @@ class MCPApiService extends EventEmitter {
|
||||
}
|
||||
return resp
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get all servers:', error)
|
||||
logger.error('Failed to get all servers', { error })
|
||||
throw new Error('Failed to retrieve servers')
|
||||
}
|
||||
}
|
||||
@@ -110,87 +85,47 @@ class MCPApiService extends EventEmitter {
|
||||
// get server by id
|
||||
async getServerById(id: string): Promise<MCPServer | null> {
|
||||
try {
|
||||
logger.silly(`getServerById called with id: ${id}`)
|
||||
const servers = await this.getServersFromRedux()
|
||||
logger.debug('getServerById called', { id })
|
||||
const servers = await getMCPServersFromRedux()
|
||||
const server = servers.find((s) => s.id === id)
|
||||
if (!server) {
|
||||
logger.warn(`Server with id ${id} not found`)
|
||||
logger.warn('Server not found', { id })
|
||||
return null
|
||||
}
|
||||
logger.silly(`Returning server with id ${id}`)
|
||||
logger.debug('Returning server', { id })
|
||||
return server
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to get server with id ${id}:`, error)
|
||||
logger.error('Failed to get server', { id, error })
|
||||
throw new Error('Failed to retrieve server')
|
||||
}
|
||||
}
|
||||
|
||||
async getServerInfo(id: string): Promise<any> {
|
||||
try {
|
||||
logger.silly(`getServerInfo called with id: ${id}`)
|
||||
const server = await this.getServerById(id)
|
||||
if (!server) {
|
||||
logger.warn(`Server with id ${id} not found`)
|
||||
logger.warn('Server not found while fetching info', { id })
|
||||
return null
|
||||
}
|
||||
logger.silly(`Returning server info for id ${id}`)
|
||||
|
||||
const client = await mcpService.initClient(server)
|
||||
const tools = await client.listTools()
|
||||
|
||||
logger.info(`Server with id ${id} info:`, { tools: JSON.stringify(tools) })
|
||||
|
||||
// const [version, tools, prompts, resources] = await Promise.all([
|
||||
// () => {
|
||||
// try {
|
||||
// return client.getServerVersion()
|
||||
// } catch (error) {
|
||||
// logger.error(`Failed to get server version for id ${id}:`, { error: error })
|
||||
// return '1.0.0'
|
||||
// }
|
||||
// },
|
||||
// (() => {
|
||||
// try {
|
||||
// return client.listTools()
|
||||
// } catch (error) {
|
||||
// logger.error(`Failed to list tools for id ${id}:`, { error: error })
|
||||
// return []
|
||||
// }
|
||||
// })(),
|
||||
// (() => {
|
||||
// try {
|
||||
// return client.listPrompts()
|
||||
// } catch (error) {
|
||||
// logger.error(`Failed to list prompts for id ${id}:`, { error: error })
|
||||
// return []
|
||||
// }
|
||||
// })(),
|
||||
// (() => {
|
||||
// try {
|
||||
// return client.listResources()
|
||||
// } catch (error) {
|
||||
// logger.error(`Failed to list resources for id ${id}:`, { error: error })
|
||||
// return []
|
||||
// }
|
||||
// })()
|
||||
// ])
|
||||
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
type: server.type,
|
||||
description: server.description,
|
||||
tools
|
||||
tools: tools.tools
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to get server info with id ${id}:`, error)
|
||||
logger.error('Failed to get server info', { id, error })
|
||||
throw new Error('Failed to retrieve server info')
|
||||
}
|
||||
}
|
||||
|
||||
async handleRequest(req: Request, res: Response, server: MCPServer) {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined
|
||||
logger.silly(`Handling request for server with sessionId ${sessionId}`)
|
||||
logger.debug('Handling MCP request', { sessionId, serverId: server.id })
|
||||
let transport: StreamableHTTPServerTransport
|
||||
if (sessionId && transports[sessionId]) {
|
||||
transport = transports[sessionId]
|
||||
@@ -203,7 +138,7 @@ class MCPApiService extends EventEmitter {
|
||||
})
|
||||
|
||||
transport.onclose = () => {
|
||||
logger.info(`Transport for sessionId ${sessionId} closed`)
|
||||
logger.info('Transport closed', { sessionId })
|
||||
if (transport.sessionId) {
|
||||
delete transports[transport.sessionId]
|
||||
}
|
||||
@@ -238,12 +173,15 @@ class MCPApiService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Request body`, { rawBody: req.body, messages: JSON.stringify(messages) })
|
||||
logger.debug('Dispatching MCP request', {
|
||||
sessionId: transport.sessionId ?? sessionId,
|
||||
messageCount: messages.length
|
||||
})
|
||||
await transport.handleRequest(req as IncomingMessage, res as ServerResponse, messages)
|
||||
}
|
||||
|
||||
private onMessage(message: JSONRPCMessage, extra?: MessageExtraInfo) {
|
||||
logger.info(`Received message: ${JSON.stringify(message)}`, extra)
|
||||
logger.debug('Received MCP message', { message, extra })
|
||||
// Handle message here
|
||||
}
|
||||
}
|
||||
|
||||
321
src/main/apiServer/services/messages.ts
Normal file
321
src/main/apiServer/services/messages.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { MessageCreateParams, MessageStreamEvent } from '@anthropic-ai/sdk/resources'
|
||||
import { loggerService } from '@logger'
|
||||
import anthropicService from '@main/services/AnthropicService'
|
||||
import { buildClaudeCodeSystemMessage, getSdkClient } from '@shared/anthropic'
|
||||
import { Provider } from '@types'
|
||||
import { Response } from 'express'
|
||||
|
||||
const logger = loggerService.withContext('MessagesService')
|
||||
const EXCLUDED_FORWARD_HEADERS: ReadonlySet<string> = new Set([
|
||||
'host',
|
||||
'x-api-key',
|
||||
'authorization',
|
||||
'sentry-trace',
|
||||
'baggage',
|
||||
'content-length',
|
||||
'connection'
|
||||
])
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
type: 'error'
|
||||
error: {
|
||||
type: string
|
||||
message: string
|
||||
requestId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface StreamConfig {
|
||||
response: Response
|
||||
onChunk?: (chunk: MessageStreamEvent) => void
|
||||
onError?: (error: any) => void
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
export interface ProcessMessageOptions {
|
||||
provider: Provider
|
||||
request: MessageCreateParams
|
||||
extraHeaders?: Record<string, string | string[]>
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
export interface ProcessMessageResult {
|
||||
client: Anthropic
|
||||
anthropicRequest: MessageCreateParams
|
||||
}
|
||||
|
||||
export class MessagesService {
|
||||
validateRequest(request: MessageCreateParams): ValidationResult {
|
||||
// TODO: Implement comprehensive request validation
|
||||
const errors: string[] = []
|
||||
|
||||
if (!request.model || typeof request.model !== 'string') {
|
||||
errors.push('Model is required')
|
||||
}
|
||||
|
||||
if (typeof request.max_tokens !== 'number' || !Number.isFinite(request.max_tokens) || request.max_tokens < 1) {
|
||||
errors.push('max_tokens is required and must be a positive number')
|
||||
}
|
||||
|
||||
if (!request.messages || !Array.isArray(request.messages) || request.messages.length === 0) {
|
||||
errors.push('messages is required and must be a non-empty array')
|
||||
} else {
|
||||
request.messages.forEach((message, index) => {
|
||||
if (!message || typeof message !== 'object') {
|
||||
errors.push(`messages[${index}] must be an object`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!('role' in message) || typeof message.role !== 'string' || message.role.trim().length === 0) {
|
||||
errors.push(`messages[${index}].role is required`)
|
||||
}
|
||||
|
||||
const content: unknown = message.content
|
||||
if (content === undefined || content === null) {
|
||||
errors.push(`messages[${index}].content is required`)
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof content === 'string' && content.trim().length === 0) {
|
||||
errors.push(`messages[${index}].content cannot be empty`)
|
||||
} else if (Array.isArray(content) && content.length === 0) {
|
||||
errors.push(`messages[${index}].content must include at least one item when using an array`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
||||
async getClient(provider: Provider, extraHeaders?: Record<string, string | string[]>): Promise<Anthropic> {
|
||||
// Create Anthropic client for the provider
|
||||
if (provider.authType === 'oauth') {
|
||||
const oauthToken = await anthropicService.getValidAccessToken()
|
||||
return getSdkClient(provider, oauthToken, extraHeaders)
|
||||
}
|
||||
return getSdkClient(provider, null, extraHeaders)
|
||||
}
|
||||
|
||||
prepareHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string | string[]> {
|
||||
const extraHeaders: Record<string, string | string[]> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (value === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
const normalizedKey = key.toLowerCase()
|
||||
if (EXCLUDED_FORWARD_HEADERS.has(normalizedKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
extraHeaders[normalizedKey] = value
|
||||
}
|
||||
|
||||
return extraHeaders
|
||||
}
|
||||
|
||||
createAnthropicRequest(request: MessageCreateParams, provider: Provider, modelId?: string): MessageCreateParams {
|
||||
const anthropicRequest: MessageCreateParams = {
|
||||
...request,
|
||||
stream: !!request.stream
|
||||
}
|
||||
|
||||
// Override model if provided
|
||||
if (modelId) {
|
||||
anthropicRequest.model = modelId
|
||||
}
|
||||
|
||||
// Add Claude Code system message for OAuth providers
|
||||
if (provider.type === 'anthropic' && provider.authType === 'oauth') {
|
||||
anthropicRequest.system = buildClaudeCodeSystemMessage(request.system)
|
||||
}
|
||||
|
||||
return anthropicRequest
|
||||
}
|
||||
|
||||
async handleStreaming(
|
||||
client: Anthropic,
|
||||
request: MessageCreateParams,
|
||||
config: StreamConfig,
|
||||
provider: Provider
|
||||
): Promise<void> {
|
||||
const { response, onChunk, onError, onComplete } = config
|
||||
|
||||
// Set streaming headers
|
||||
response.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
|
||||
response.setHeader('Cache-Control', 'no-cache, no-transform')
|
||||
response.setHeader('Connection', 'keep-alive')
|
||||
response.setHeader('X-Accel-Buffering', 'no')
|
||||
response.flushHeaders()
|
||||
|
||||
const flushableResponse = response as Response & { flush?: () => void }
|
||||
const flushStream = () => {
|
||||
if (typeof flushableResponse.flush !== 'function') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
flushableResponse.flush()
|
||||
} catch (flushError: unknown) {
|
||||
logger.warn('Failed to flush streaming response', { error: flushError })
|
||||
}
|
||||
}
|
||||
|
||||
const writeSse = (eventType: string | undefined, payload: unknown) => {
|
||||
if (response.writableEnded || response.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (eventType) {
|
||||
response.write(`event: ${eventType}\n`)
|
||||
}
|
||||
|
||||
const data = typeof payload === 'string' ? payload : JSON.stringify(payload)
|
||||
response.write(`data: ${data}\n\n`)
|
||||
flushStream()
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = client.messages.stream(request)
|
||||
for await (const chunk of stream) {
|
||||
if (response.writableEnded || response.destroyed) {
|
||||
logger.warn('Streaming response ended before stream completion', {
|
||||
provider: provider.id,
|
||||
model: request.model
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
writeSse(chunk.type, chunk)
|
||||
|
||||
if (onChunk) {
|
||||
onChunk(chunk)
|
||||
}
|
||||
}
|
||||
writeSse(undefined, '[DONE]')
|
||||
|
||||
if (onComplete) {
|
||||
onComplete()
|
||||
}
|
||||
} catch (streamError: any) {
|
||||
logger.error('Stream error', {
|
||||
error: streamError,
|
||||
provider: provider.id,
|
||||
model: request.model,
|
||||
apiHost: provider.apiHost,
|
||||
anthropicApiHost: provider.anthropicApiHost
|
||||
})
|
||||
writeSse(undefined, {
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: 'Stream processing error'
|
||||
}
|
||||
})
|
||||
|
||||
if (onError) {
|
||||
onError(streamError)
|
||||
}
|
||||
} finally {
|
||||
if (!response.writableEnded) {
|
||||
response.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transformError(error: any): { statusCode: number; errorResponse: ErrorResponse } {
|
||||
let statusCode = 500
|
||||
let errorType = 'api_error'
|
||||
let errorMessage = 'Internal server error'
|
||||
|
||||
const anthropicStatus = typeof error?.status === 'number' ? error.status : undefined
|
||||
const anthropicError = error?.error
|
||||
|
||||
if (anthropicStatus) {
|
||||
statusCode = anthropicStatus
|
||||
}
|
||||
|
||||
if (anthropicError?.type) {
|
||||
errorType = anthropicError.type
|
||||
}
|
||||
|
||||
if (anthropicError?.message) {
|
||||
errorMessage = anthropicError.message
|
||||
} else if (error instanceof Error && error.message) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
// Infer error type from message if not from Anthropic API
|
||||
if (!anthropicStatus && error instanceof Error) {
|
||||
const errorMessageText = error.message ?? ''
|
||||
|
||||
if (errorMessageText.includes('API key') || errorMessageText.includes('authentication')) {
|
||||
statusCode = 401
|
||||
errorType = 'authentication_error'
|
||||
} else if (errorMessageText.includes('rate limit') || errorMessageText.includes('quota')) {
|
||||
statusCode = 429
|
||||
errorType = 'rate_limit_error'
|
||||
} else if (errorMessageText.includes('timeout') || errorMessageText.includes('connection')) {
|
||||
statusCode = 502
|
||||
errorType = 'api_error'
|
||||
} else if (errorMessageText.includes('validation') || errorMessageText.includes('invalid')) {
|
||||
statusCode = 400
|
||||
errorType = 'invalid_request_error'
|
||||
}
|
||||
}
|
||||
|
||||
const safeErrorMessage =
|
||||
typeof errorMessage === 'string' && errorMessage.length > 0 ? errorMessage : 'Internal server error'
|
||||
|
||||
return {
|
||||
statusCode,
|
||||
errorResponse: {
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
message: safeErrorMessage,
|
||||
requestId: error?.request_id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async processMessage(options: ProcessMessageOptions): Promise<ProcessMessageResult> {
|
||||
const { provider, request, extraHeaders, modelId } = options
|
||||
|
||||
const client = await this.getClient(provider, extraHeaders)
|
||||
const anthropicRequest = this.createAnthropicRequest(request, provider, modelId)
|
||||
|
||||
const messageCount = Array.isArray(request.messages) ? request.messages.length : 0
|
||||
|
||||
logger.info('Processing anthropic messages request', {
|
||||
provider: provider.id,
|
||||
apiHost: provider.apiHost,
|
||||
anthropicApiHost: provider.anthropicApiHost,
|
||||
model: anthropicRequest.model,
|
||||
stream: !!anthropicRequest.stream,
|
||||
// systemPrompt: JSON.stringify(!!request.system),
|
||||
// messages: JSON.stringify(request.messages),
|
||||
messageCount,
|
||||
toolCount: Array.isArray(request.tools) ? request.tools.length : 0
|
||||
})
|
||||
|
||||
// Return client and request for route layer to handle streaming/non-streaming
|
||||
return {
|
||||
client,
|
||||
anthropicRequest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const messagesService = new MessagesService()
|
||||
113
src/main/apiServer/services/models.ts
Normal file
113
src/main/apiServer/services/models.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import { ApiModel, ApiModelsFilter, ApiModelsResponse } from '../../../renderer/src/types/apiModels'
|
||||
import { loggerService } from '../../services/LoggerService'
|
||||
import {
|
||||
getAvailableProviders,
|
||||
getProviderAnthropicModelChecker,
|
||||
listAllAvailableModels,
|
||||
transformModelToOpenAI
|
||||
} from '../utils'
|
||||
|
||||
const logger = loggerService.withContext('ModelsService')
|
||||
|
||||
// Re-export for backward compatibility
|
||||
|
||||
export type ModelsFilter = ApiModelsFilter
|
||||
|
||||
export class ModelsService {
|
||||
async getModels(filter: ModelsFilter): Promise<ApiModelsResponse> {
|
||||
try {
|
||||
logger.debug('Getting available models from providers', { filter })
|
||||
|
||||
let providers = await getAvailableProviders()
|
||||
|
||||
if (filter.providerType === 'anthropic') {
|
||||
providers = providers.filter((p) => p.type === 'anthropic' || !isEmpty(p.anthropicApiHost?.trim()))
|
||||
}
|
||||
|
||||
const models = await listAllAvailableModels(providers)
|
||||
// Use Map to deduplicate models by their full ID (provider:model_id)
|
||||
const uniqueModels = new Map<string, ApiModel>()
|
||||
|
||||
for (const model of models) {
|
||||
const provider = providers.find((p) => p.id === model.provider)
|
||||
logger.debug(`Processing model ${model.id}`)
|
||||
if (!provider) {
|
||||
logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (filter.providerType === 'anthropic') {
|
||||
const checker = getProviderAnthropicModelChecker(provider.id)
|
||||
if (!checker(model)) {
|
||||
logger.debug(`Skipping model ${model.id} from ${model.provider}. Reason: Not an Anthropic model.`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const openAIModel = transformModelToOpenAI(model, provider)
|
||||
const fullModelId = openAIModel.id // This is already in format "provider:model_id"
|
||||
|
||||
// Only add if not already present (first occurrence wins)
|
||||
if (!uniqueModels.has(fullModelId)) {
|
||||
uniqueModels.set(fullModelId, openAIModel)
|
||||
} else {
|
||||
logger.debug(`Skipping duplicate model: ${fullModelId}`)
|
||||
}
|
||||
}
|
||||
|
||||
let modelData = Array.from(uniqueModels.values())
|
||||
const total = modelData.length
|
||||
|
||||
// Apply pagination
|
||||
const offset = filter?.offset || 0
|
||||
const limit = filter?.limit
|
||||
|
||||
if (limit !== undefined) {
|
||||
modelData = modelData.slice(offset, offset + limit)
|
||||
logger.debug(
|
||||
`Applied pagination: offset=${offset}, limit=${limit}, showing ${modelData.length} of ${total} models`
|
||||
)
|
||||
} else if (offset > 0) {
|
||||
modelData = modelData.slice(offset)
|
||||
logger.debug(`Applied offset: offset=${offset}, showing ${modelData.length} of ${total} models`)
|
||||
}
|
||||
|
||||
logger.info('Models retrieved', {
|
||||
returned: modelData.length,
|
||||
discovered: models.length,
|
||||
filter
|
||||
})
|
||||
|
||||
if (models.length > total) {
|
||||
logger.debug(`Filtered out ${models.length - total} models after deduplication and filtering`)
|
||||
}
|
||||
|
||||
const response: ApiModelsResponse = {
|
||||
object: 'list',
|
||||
data: modelData
|
||||
}
|
||||
|
||||
// Add pagination metadata if applicable
|
||||
if (filter?.limit !== undefined || filter?.offset !== undefined) {
|
||||
response.total = total
|
||||
response.offset = offset
|
||||
if (filter?.limit !== undefined) {
|
||||
response.limit = filter.limit
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting models', { error, filter })
|
||||
return {
|
||||
object: 'list',
|
||||
data: []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const modelsService = new ModelsService()
|
||||
64
src/main/apiServer/utils/createStreamAbortController.ts
Normal file
64
src/main/apiServer/utils/createStreamAbortController.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export type StreamAbortHandler = (reason: unknown) => void
|
||||
|
||||
export interface StreamAbortController {
|
||||
abortController: AbortController
|
||||
registerAbortHandler: (handler: StreamAbortHandler) => void
|
||||
clearAbortTimeout: () => void
|
||||
}
|
||||
|
||||
export const STREAM_TIMEOUT_REASON = 'stream timeout'
|
||||
|
||||
interface CreateStreamAbortControllerOptions {
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
export const createStreamAbortController = (options: CreateStreamAbortControllerOptions): StreamAbortController => {
|
||||
const { timeoutMs } = options
|
||||
const abortController = new AbortController()
|
||||
const signal = abortController.signal
|
||||
|
||||
let timeoutId: NodeJS.Timeout | undefined
|
||||
let abortHandler: StreamAbortHandler | undefined
|
||||
|
||||
const clearAbortTimeout = () => {
|
||||
if (!timeoutId) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = undefined
|
||||
}
|
||||
|
||||
const handleAbort = () => {
|
||||
clearAbortTimeout()
|
||||
|
||||
if (!abortHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
abortHandler(signal.reason)
|
||||
}
|
||||
|
||||
signal.addEventListener('abort', handleAbort, { once: true })
|
||||
|
||||
const registerAbortHandler = (handler: StreamAbortHandler) => {
|
||||
abortHandler = handler
|
||||
|
||||
if (signal.aborted) {
|
||||
abortHandler(signal.reason)
|
||||
}
|
||||
}
|
||||
|
||||
if (timeoutMs > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
if (!signal.aborted) {
|
||||
abortController.abort(STREAM_TIMEOUT_REASON)
|
||||
}
|
||||
}, timeoutMs)
|
||||
}
|
||||
|
||||
return {
|
||||
abortController,
|
||||
registerAbortHandler,
|
||||
clearAbortTimeout
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,60 @@
|
||||
import { CacheService } from '@main/services/CacheService'
|
||||
import { loggerService } from '@main/services/LoggerService'
|
||||
import { reduxService } from '@main/services/ReduxService'
|
||||
import { Model, Provider } from '@types'
|
||||
import { ApiModel, Model, Provider } from '@types'
|
||||
|
||||
const logger = loggerService.withContext('ApiServerUtils')
|
||||
|
||||
// OpenAI compatible model format
|
||||
export interface OpenAICompatibleModel {
|
||||
id: string
|
||||
object: 'model'
|
||||
created: number
|
||||
owned_by: string
|
||||
provider?: string
|
||||
provider_model_id?: string
|
||||
}
|
||||
// Cache configuration
|
||||
const PROVIDERS_CACHE_KEY = 'api-server:providers'
|
||||
const PROVIDERS_CACHE_TTL = 10 * 1000 // 10 seconds
|
||||
|
||||
export async function getAvailableProviders(): Promise<Provider[]> {
|
||||
try {
|
||||
// Wait for store to be ready before accessing providers
|
||||
// Try to get from cache first (faster)
|
||||
const cachedSupportedProviders = CacheService.get<Provider[]>(PROVIDERS_CACHE_KEY)
|
||||
if (cachedSupportedProviders && cachedSupportedProviders.length > 0) {
|
||||
logger.debug('Providers resolved from cache', {
|
||||
count: cachedSupportedProviders.length
|
||||
})
|
||||
return cachedSupportedProviders
|
||||
}
|
||||
|
||||
// If cache is not available, get fresh data from Redux
|
||||
const providers = await reduxService.select('state.llm.providers')
|
||||
if (!providers || !Array.isArray(providers)) {
|
||||
logger.warn('No providers found in Redux store, returning empty array')
|
||||
logger.warn('No providers found in Redux store')
|
||||
return []
|
||||
}
|
||||
|
||||
// Only support OpenAI type providers for API server
|
||||
const openAIProviders = providers.filter((p: Provider) => p.enabled && p.type === 'openai')
|
||||
// Support OpenAI and Anthropic type providers for API server
|
||||
const supportedProviders = providers.filter(
|
||||
(p: Provider) => p.enabled && (p.type === 'openai' || p.type === 'anthropic')
|
||||
)
|
||||
|
||||
logger.info(`Filtered to ${openAIProviders.length} OpenAI providers from ${providers.length} total providers`)
|
||||
// Cache the filtered results
|
||||
CacheService.set(PROVIDERS_CACHE_KEY, supportedProviders, PROVIDERS_CACHE_TTL)
|
||||
|
||||
return openAIProviders
|
||||
logger.info('Providers filtered', {
|
||||
supported: supportedProviders.length,
|
||||
total: providers.length
|
||||
})
|
||||
|
||||
return supportedProviders
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get providers from Redux store:', error)
|
||||
logger.error('Failed to get providers from Redux store', { error })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAllAvailableModels(): Promise<Model[]> {
|
||||
export async function listAllAvailableModels(providers?: Provider[]): Promise<Model[]> {
|
||||
try {
|
||||
const providers = await getAvailableProviders()
|
||||
if (!providers) {
|
||||
providers = await getAvailableProviders()
|
||||
}
|
||||
return providers.map((p: Provider) => p.models || []).flat()
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to list available models:', error)
|
||||
logger.error('Failed to list available models', { error })
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -48,15 +62,13 @@ export async function listAllAvailableModels(): Promise<Model[]> {
|
||||
export async function getProviderByModel(model: string): Promise<Provider | undefined> {
|
||||
try {
|
||||
if (!model || typeof model !== 'string') {
|
||||
logger.warn(`Invalid model parameter: ${model}`)
|
||||
logger.warn('Invalid model parameter', { model })
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Validate model format first
|
||||
if (!model.includes(':')) {
|
||||
logger.warn(
|
||||
`Invalid model format, must contain ':' separator. Expected format "provider:model_id", got: ${model}`
|
||||
)
|
||||
logger.warn('Invalid model format missing separator', { model })
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -64,7 +76,7 @@ export async function getProviderByModel(model: string): Promise<Provider | unde
|
||||
const modelInfo = model.split(':')
|
||||
|
||||
if (modelInfo.length < 2 || modelInfo[0].length === 0 || modelInfo[1].length === 0) {
|
||||
logger.warn(`Invalid model format, expected "provider:model_id" with non-empty parts, got: ${model}`)
|
||||
logger.warn('Invalid model format with empty parts', { model })
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -72,16 +84,17 @@ export async function getProviderByModel(model: string): Promise<Provider | unde
|
||||
const provider = providers.find((p: Provider) => p.id === providerId)
|
||||
|
||||
if (!provider) {
|
||||
logger.warn(
|
||||
`Provider '${providerId}' not found or not enabled. Available providers: ${providers.map((p) => p.id).join(', ')}`
|
||||
)
|
||||
logger.warn('Provider not found for model', {
|
||||
providerId,
|
||||
available: providers.map((p) => p.id)
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
logger.debug(`Found provider '${providerId}' for model: ${model}`)
|
||||
logger.debug('Provider resolved for model', { providerId, model })
|
||||
return provider
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get provider by model:', error)
|
||||
logger.error('Failed to get provider by model', { error, model })
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -96,9 +109,12 @@ export interface ModelValidationError {
|
||||
code: string
|
||||
}
|
||||
|
||||
export async function validateModelId(
|
||||
model: string
|
||||
): Promise<{ valid: boolean; error?: ModelValidationError; provider?: Provider; modelId?: string }> {
|
||||
export async function validateModelId(model: string): Promise<{
|
||||
valid: boolean
|
||||
error?: ModelValidationError
|
||||
provider?: Provider
|
||||
modelId?: string
|
||||
}> {
|
||||
try {
|
||||
if (!model || typeof model !== 'string') {
|
||||
return {
|
||||
@@ -169,7 +185,7 @@ export async function validateModelId(
|
||||
modelId
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Error validating model ID:', error)
|
||||
logger.error('Error validating model ID', { error, model })
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
@@ -181,17 +197,47 @@ export async function validateModelId(
|
||||
}
|
||||
}
|
||||
|
||||
export function transformModelToOpenAI(model: Model): OpenAICompatibleModel {
|
||||
export function transformModelToOpenAI(model: Model, provider?: Provider): ApiModel {
|
||||
const providerDisplayName = provider?.name
|
||||
return {
|
||||
id: `${model.provider}:${model.id}`,
|
||||
object: 'model',
|
||||
name: model.name,
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: model.owned_by || model.provider,
|
||||
owned_by: model.owned_by || providerDisplayName || model.provider,
|
||||
provider: model.provider,
|
||||
provider_name: providerDisplayName,
|
||||
provider_type: provider?.type,
|
||||
provider_model_id: model.id
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProviderById(providerId: string): Promise<Provider | undefined> {
|
||||
try {
|
||||
if (!providerId || typeof providerId !== 'string') {
|
||||
logger.warn('Invalid provider ID parameter', { providerId })
|
||||
return undefined
|
||||
}
|
||||
|
||||
const providers = await getAvailableProviders()
|
||||
const provider = providers.find((p: Provider) => p.id === providerId)
|
||||
|
||||
if (!provider) {
|
||||
logger.warn('Provider not found by ID', {
|
||||
providerId,
|
||||
available: providers.map((p) => p.id)
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
logger.debug('Provider found by ID', { providerId })
|
||||
return provider
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get provider by ID', { error, providerId })
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function validateProvider(provider: Provider): boolean {
|
||||
try {
|
||||
if (!provider) {
|
||||
@@ -200,7 +246,7 @@ export function validateProvider(provider: Provider): boolean {
|
||||
|
||||
// Check required fields
|
||||
if (!provider.id || !provider.type || !provider.apiKey || !provider.apiHost) {
|
||||
logger.warn('Provider missing required fields:', {
|
||||
logger.warn('Provider missing required fields', {
|
||||
id: !!provider.id,
|
||||
type: !!provider.type,
|
||||
apiKey: !!provider.apiKey,
|
||||
@@ -211,21 +257,38 @@ export function validateProvider(provider: Provider): boolean {
|
||||
|
||||
// Check if provider is enabled
|
||||
if (!provider.enabled) {
|
||||
logger.debug(`Provider is disabled: ${provider.id}`)
|
||||
logger.debug('Provider is disabled', { providerId: provider.id })
|
||||
return false
|
||||
}
|
||||
|
||||
// Only support OpenAI type providers
|
||||
if (provider.type !== 'openai') {
|
||||
logger.debug(
|
||||
`Provider type '${provider.type}' not supported, only 'openai' type is currently supported: ${provider.id}`
|
||||
)
|
||||
// Support OpenAI and Anthropic type providers
|
||||
if (provider.type !== 'openai' && provider.type !== 'anthropic') {
|
||||
logger.debug('Provider type not supported', {
|
||||
providerId: provider.id,
|
||||
providerType: provider.type
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error: any) {
|
||||
logger.error('Error validating provider:', error)
|
||||
logger.error('Error validating provider', {
|
||||
error,
|
||||
providerId: provider?.id
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const getProviderAnthropicModelChecker = (providerId: string): ((m: Model) => boolean) => {
|
||||
switch (providerId) {
|
||||
case 'cherryin':
|
||||
case 'new-api':
|
||||
return (m: Model) => m.endpoint_type === 'anthropic'
|
||||
case 'aihubmix':
|
||||
return (m: Model) => m.id.includes('claude')
|
||||
default:
|
||||
// allow all models when checker not configured
|
||||
return () => true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CacheService } from '@main/services/CacheService'
|
||||
import mcpService from '@main/services/MCPService'
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema, ListToolsResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
@@ -8,6 +9,10 @@ import { reduxService } from '../../services/ReduxService'
|
||||
|
||||
const logger = loggerService.withContext('MCPApiService')
|
||||
|
||||
// Cache configuration
|
||||
const MCP_SERVERS_CACHE_KEY = 'api-server:mcp-servers'
|
||||
const MCP_SERVERS_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
const cachedServers: Record<string, Server> = {}
|
||||
|
||||
async function handleListToolsRequest(request: any, extra: any): Promise<ListToolsResult> {
|
||||
@@ -33,20 +38,35 @@ async function handleCallToolRequest(request: any, extra: any): Promise<any> {
|
||||
}
|
||||
|
||||
async function getMcpServerConfigById(id: string): Promise<MCPServer | undefined> {
|
||||
const servers = await getServersFromRedux()
|
||||
const servers = await getMCPServersFromRedux()
|
||||
return servers.find((s) => s.id === id || s.name === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get servers directly from Redux store
|
||||
*/
|
||||
async function getServersFromRedux(): Promise<MCPServer[]> {
|
||||
export async function getMCPServersFromRedux(): Promise<MCPServer[]> {
|
||||
try {
|
||||
logger.debug('Getting servers from Redux store')
|
||||
|
||||
// Try to get from cache first (faster)
|
||||
const cachedServers = CacheService.get<MCPServer[]>(MCP_SERVERS_CACHE_KEY)
|
||||
if (cachedServers) {
|
||||
logger.debug('MCP servers resolved from cache', { count: cachedServers.length })
|
||||
return cachedServers
|
||||
}
|
||||
|
||||
// If cache is not available, get fresh data from Redux
|
||||
const servers = await reduxService.select<MCPServer[]>('state.mcp.servers')
|
||||
logger.silly(`Fetched ${servers?.length || 0} servers from Redux store`)
|
||||
return servers || []
|
||||
const serverList = servers || []
|
||||
|
||||
// Cache the results
|
||||
CacheService.set(MCP_SERVERS_CACHE_KEY, serverList, MCP_SERVERS_CACHE_TTL)
|
||||
|
||||
logger.debug('Fetched servers from Redux store', { count: serverList.length })
|
||||
return serverList
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get servers from Redux:', error)
|
||||
logger.error('Failed to get servers from Redux', { error })
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -54,7 +74,7 @@ async function getServersFromRedux(): Promise<MCPServer[]> {
|
||||
export async function getMcpServerById(id: string): Promise<Server> {
|
||||
const server = cachedServers[id]
|
||||
if (!server) {
|
||||
const servers = await getServersFromRedux()
|
||||
const servers = await getMCPServersFromRedux()
|
||||
const mcpServer = servers.find((s) => s.id === id || s.name === id)
|
||||
if (!mcpServer) {
|
||||
throw new Error(`Server not found: ${id}`)
|
||||
@@ -71,6 +91,6 @@ export async function getMcpServerById(id: string): Promise<Server> {
|
||||
cachedServers[id] = newServer
|
||||
return newServer
|
||||
}
|
||||
logger.silly('getMcpServer ', { server: server })
|
||||
logger.debug('Returning cached MCP server', { id, hasHandlers: Boolean(server) })
|
||||
return server
|
||||
}
|
||||
|
||||
@@ -10,9 +10,13 @@ import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
|
||||
import { app } from 'electron'
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
|
||||
import { isDev, isLinux, isWin } from './constant'
|
||||
|
||||
import process from 'node:process'
|
||||
|
||||
import { registerIpc } from './ipc'
|
||||
import { agentService } from './services/agents'
|
||||
import { apiServerService } from './services/ApiServerService'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import mcpService from './services/MCPService'
|
||||
import { nodeTraceService } from './services/NodeTraceService'
|
||||
@@ -26,8 +30,7 @@ import selectionService, { initSelectionService } from './services/SelectionServ
|
||||
import { registerShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import process from 'node:process'
|
||||
import { apiServerService } from './services/ApiServerService'
|
||||
import { initWebviewHotkeys } from './services/WebviewService'
|
||||
|
||||
const logger = loggerService.withContext('MainEntry')
|
||||
|
||||
@@ -106,6 +109,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
// Some APIs can only be used after this event occurs.
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
initWebviewHotkeys()
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
|
||||
|
||||
@@ -147,11 +151,34 @@ if (!app.requestSingleInstanceLock()) {
|
||||
//start selection assistant service
|
||||
initSelectionService()
|
||||
|
||||
// Start API server if enabled
|
||||
// Initialize Agent Service
|
||||
try {
|
||||
await agentService.initialize()
|
||||
logger.info('Agent service initialized successfully')
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to initialize Agent service:', error)
|
||||
}
|
||||
|
||||
// Start API server if enabled or if agents exist
|
||||
try {
|
||||
const config = await apiServerService.getCurrentConfig()
|
||||
logger.info('API server config:', config)
|
||||
if (config.enabled) {
|
||||
|
||||
// Check if there are any agents
|
||||
let shouldStart = config.enabled
|
||||
if (!shouldStart) {
|
||||
try {
|
||||
const { total } = await agentService.listAgents({ limit: 1 })
|
||||
if (total > 0) {
|
||||
shouldStart = true
|
||||
logger.info(`Detected ${total} agent(s), auto-starting API server`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn('Failed to check agent count:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldStart) {
|
||||
await apiServerService.start()
|
||||
}
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -11,11 +11,21 @@ import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
||||
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileMetadata, Notification, OcrProvider, Provider, Shortcut, SupportedOcrFile, ThemeMode } from '@types'
|
||||
import {
|
||||
AgentPersistedMessage,
|
||||
FileMetadata,
|
||||
Notification,
|
||||
OcrProvider,
|
||||
Provider,
|
||||
Shortcut,
|
||||
SupportedOcrFile,
|
||||
ThemeMode
|
||||
} from '@types'
|
||||
import checkDiskSpace from 'check-disk-space'
|
||||
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
|
||||
import fontList from 'font-list'
|
||||
|
||||
import { agentMessageRepository } from './services/agents/database'
|
||||
import { apiServerService } from './services/ApiServerService'
|
||||
import appService from './services/AppService'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
@@ -35,6 +45,7 @@ import NotificationService from './services/NotificationService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ocrService } from './services/ocr/OcrService'
|
||||
import OvmsManager from './services/OvmsManager'
|
||||
import { proxyManager } from './services/ProxyManager'
|
||||
import { pythonService } from './services/PythonService'
|
||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||
@@ -81,6 +92,7 @@ const obsidianVaultService = new ObsidianVaultService()
|
||||
const vertexAIService = VertexAIService.getInstance()
|
||||
const memoryService = MemoryService.getInstance()
|
||||
const dxtService = new DxtService()
|
||||
const ovmsManager = new OvmsManager()
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater()
|
||||
@@ -130,7 +142,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))
|
||||
|
||||
// Update
|
||||
ipcMain.handle(IpcChannel.App_ShowUpdateDialog, () => appUpdater.showUpdateDialog(mainWindow))
|
||||
ipcMain.handle(IpcChannel.App_QuitAndInstall, () => appUpdater.quitAndInstall())
|
||||
|
||||
// language
|
||||
ipcMain.handle(IpcChannel.App_SetLanguage, (_, language) => {
|
||||
@@ -200,6 +212,27 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.AgentMessage_PersistExchange, async (_event, payload) => {
|
||||
try {
|
||||
return await agentMessageRepository.persistExchange(payload)
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist agent session messages', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IpcChannel.AgentMessage_GetHistory,
|
||||
async (_event, { sessionId }: { sessionId: string }): Promise<AgentPersistedMessage[]> => {
|
||||
try {
|
||||
return await agentMessageRepository.getSessionHistory(sessionId)
|
||||
} catch (error) {
|
||||
logger.error('Failed to get agent session history', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
//only for mac
|
||||
if (isMac) {
|
||||
ipcMain.handle(IpcChannel.App_MacIsProcessTrusted, (): boolean => {
|
||||
@@ -432,6 +465,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
// system
|
||||
ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux'))
|
||||
ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname())
|
||||
ipcMain.handle(IpcChannel.System_GetCpuName, () => require('os').cpus()[0].model)
|
||||
ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender)
|
||||
win && win.webContents.toggleDevTools()
|
||||
@@ -495,6 +529,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_StartWatcher, fileManager.startFileWatcher.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_ShowInFolder, fileManager.showInFolder.bind(fileManager))
|
||||
|
||||
// file service
|
||||
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
|
||||
@@ -710,6 +745,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.App_GetBinaryPath, (_, name: string) => getBinaryPath(name))
|
||||
ipcMain.handle(IpcChannel.App_InstallUvBinary, () => runInstallScript('install-uv.js'))
|
||||
ipcMain.handle(IpcChannel.App_InstallBunBinary, () => runInstallScript('install-bun.js'))
|
||||
ipcMain.handle(IpcChannel.App_InstallOvmsBinary, () => runInstallScript('install-ovms.js'))
|
||||
|
||||
//copilot
|
||||
ipcMain.handle(IpcChannel.Copilot_GetAuthMessage, CopilotService.getAuthMessage.bind(CopilotService))
|
||||
@@ -750,7 +786,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
|
||||
setOpenLinkExternal(webviewId, isExternal)
|
||||
)
|
||||
|
||||
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
|
||||
const webview = webContents.fromId(webviewId)
|
||||
if (!webview) return
|
||||
@@ -840,6 +875,18 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) =>
|
||||
ocrService.ocr(file, provider)
|
||||
)
|
||||
ipcMain.handle(IpcChannel.OCR_ListProviders, () => ocrService.listProviderIds())
|
||||
|
||||
// OVMS
|
||||
ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) =>
|
||||
ovmsManager.addModel(modelName, modelId, modelSource, task)
|
||||
)
|
||||
ipcMain.handle(IpcChannel.Ovms_StopAddModel, () => ovmsManager.stopAddModel())
|
||||
ipcMain.handle(IpcChannel.Ovms_GetModels, () => ovmsManager.getModels())
|
||||
ipcMain.handle(IpcChannel.Ovms_IsRunning, () => ovmsManager.initializeOvms())
|
||||
ipcMain.handle(IpcChannel.Ovms_GetStatus, () => ovmsManager.getOvmsStatus())
|
||||
ipcMain.handle(IpcChannel.Ovms_RunOVMS, () => ovmsManager.runOvms())
|
||||
ipcMain.handle(IpcChannel.Ovms_StopOVMS, () => ovmsManager.stopOvms())
|
||||
|
||||
// CherryAI
|
||||
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
|
||||
|
||||
473
src/main/mcpServers/didi-mcp.ts
Normal file
473
src/main/mcpServers/didi-mcp.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* DiDi MCP Server Implementation
|
||||
*
|
||||
* Based on official DiDi MCP API capabilities.
|
||||
* API Documentation: https://mcp.didichuxing.com/api?tap=api
|
||||
*
|
||||
* Provides ride-hailing services including map search, price estimation,
|
||||
* order management, and driver tracking.
|
||||
*
|
||||
* Note: Only available in Mainland China.
|
||||
*/
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
const logger = loggerService.withContext('DiDiMCPServer')
|
||||
|
||||
export class DiDiMcpServer {
|
||||
private _server: Server
|
||||
private readonly baseUrl = 'http://mcp.didichuxing.com/mcp-servers'
|
||||
private apiKey: string
|
||||
|
||||
constructor(apiKey?: string) {
|
||||
this._server = new Server(
|
||||
{
|
||||
name: 'didi-mcp-server',
|
||||
version: '0.1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Get API key from parameter or environment variables
|
||||
this.apiKey = apiKey || process.env.DIDI_API_KEY || ''
|
||||
if (!this.apiKey) {
|
||||
logger.warn('DIDI_API_KEY environment variable is not set')
|
||||
}
|
||||
|
||||
this.setupRequestHandlers()
|
||||
}
|
||||
|
||||
get server(): Server {
|
||||
return this._server
|
||||
}
|
||||
|
||||
private setupRequestHandlers() {
|
||||
// List available tools
|
||||
this._server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'maps_textsearch',
|
||||
description: 'Search for POI locations based on keywords and city',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
city: {
|
||||
type: 'string',
|
||||
description: 'Query city'
|
||||
},
|
||||
keywords: {
|
||||
type: 'string',
|
||||
description: 'Search keywords'
|
||||
},
|
||||
location: {
|
||||
type: 'string',
|
||||
description: 'Location coordinates, format: longitude,latitude'
|
||||
}
|
||||
},
|
||||
required: ['keywords', 'city']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_cancel_order',
|
||||
description: 'Cancel a taxi order',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
order_id: {
|
||||
type: 'string',
|
||||
description: 'Order ID from order creation or query results'
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Cancellation reason (optional). Examples: no longer needed, waiting too long, urgent matter'
|
||||
}
|
||||
},
|
||||
required: ['order_id']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_create_order',
|
||||
description: 'Create taxi order directly via API without opening any app interface',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
caller_car_phone: {
|
||||
type: 'string',
|
||||
description: 'Caller phone number (optional)'
|
||||
},
|
||||
estimate_trace_id: {
|
||||
type: 'string',
|
||||
description: 'Estimation trace ID from estimation results'
|
||||
},
|
||||
product_category: {
|
||||
type: 'string',
|
||||
description: 'Vehicle category ID from estimation results, comma-separated for multiple types'
|
||||
}
|
||||
},
|
||||
required: ['product_category', 'estimate_trace_id']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_estimate',
|
||||
description: 'Get available ride-hailing vehicle types and fare estimates',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from_lat: {
|
||||
type: 'string',
|
||||
description: 'Departure latitude, must be from map tools'
|
||||
},
|
||||
from_lng: {
|
||||
type: 'string',
|
||||
description: 'Departure longitude, must be from map tools'
|
||||
},
|
||||
from_name: {
|
||||
type: 'string',
|
||||
description: 'Departure location name'
|
||||
},
|
||||
to_lat: {
|
||||
type: 'string',
|
||||
description: 'Destination latitude, must be from map tools'
|
||||
},
|
||||
to_lng: {
|
||||
type: 'string',
|
||||
description: 'Destination longitude, must be from map tools'
|
||||
},
|
||||
to_name: {
|
||||
type: 'string',
|
||||
description: 'Destination name'
|
||||
}
|
||||
},
|
||||
required: ['from_lng', 'from_lat', 'from_name', 'to_lng', 'to_lat', 'to_name']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_generate_ride_app_link',
|
||||
description: 'Generate deep links to open ride-hailing apps based on origin, destination and vehicle type',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from_lat: {
|
||||
type: 'string',
|
||||
description: 'Departure latitude, must be from map tools'
|
||||
},
|
||||
from_lng: {
|
||||
type: 'string',
|
||||
description: 'Departure longitude, must be from map tools'
|
||||
},
|
||||
product_category: {
|
||||
type: 'string',
|
||||
description: 'Vehicle category IDs from estimation results, comma-separated for multiple types'
|
||||
},
|
||||
to_lat: {
|
||||
type: 'string',
|
||||
description: 'Destination latitude, must be from map tools'
|
||||
},
|
||||
to_lng: {
|
||||
type: 'string',
|
||||
description: 'Destination longitude, must be from map tools'
|
||||
}
|
||||
},
|
||||
required: ['from_lng', 'from_lat', 'to_lng', 'to_lat']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_get_driver_location',
|
||||
description: 'Get real-time driver location for a taxi order',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
order_id: {
|
||||
type: 'string',
|
||||
description: 'Taxi order ID'
|
||||
}
|
||||
},
|
||||
required: ['order_id']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'taxi_query_order',
|
||||
description: 'Query taxi order status and information such as driver contact, license plate, ETA',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
order_id: {
|
||||
type: 'string',
|
||||
description: 'Order ID from order creation results, if available; otherwise queries incomplete orders'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Handle tool calls
|
||||
this._server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case 'maps_textsearch':
|
||||
return await this.handleMapsTextSearch(args)
|
||||
case 'taxi_cancel_order':
|
||||
return await this.handleTaxiCancelOrder(args)
|
||||
case 'taxi_create_order':
|
||||
return await this.handleTaxiCreateOrder(args)
|
||||
case 'taxi_estimate':
|
||||
return await this.handleTaxiEstimate(args)
|
||||
case 'taxi_generate_ride_app_link':
|
||||
return await this.handleTaxiGenerateRideAppLink(args)
|
||||
case 'taxi_get_driver_location':
|
||||
return await this.handleTaxiGetDriverLocation(args)
|
||||
case 'taxi_query_order':
|
||||
return await this.handleTaxiQueryOrder(args)
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error calling tool ${name}:`, error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async handleMapsTextSearch(args: any) {
|
||||
const { city, keywords, location } = args
|
||||
|
||||
const params = {
|
||||
name: 'maps_textsearch',
|
||||
arguments: {
|
||||
keywords,
|
||||
city,
|
||||
...(location && { location })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Maps text search error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiCancelOrder(args: any) {
|
||||
const { order_id, reason } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_cancel_order',
|
||||
arguments: {
|
||||
order_id,
|
||||
...(reason && { reason })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi cancel order error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiCreateOrder(args: any) {
|
||||
const { caller_car_phone, estimate_trace_id, product_category } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_create_order',
|
||||
arguments: {
|
||||
product_category,
|
||||
estimate_trace_id,
|
||||
...(caller_car_phone && { caller_car_phone })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi create order error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiEstimate(args: any) {
|
||||
const { from_lng, from_lat, from_name, to_lng, to_lat, to_name } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_estimate',
|
||||
arguments: {
|
||||
from_lng,
|
||||
from_lat,
|
||||
from_name,
|
||||
to_lng,
|
||||
to_lat,
|
||||
to_name
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi estimate error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiGenerateRideAppLink(args: any) {
|
||||
const { from_lng, from_lat, to_lng, to_lat, product_category } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_generate_ride_app_link',
|
||||
arguments: {
|
||||
from_lng,
|
||||
from_lat,
|
||||
to_lng,
|
||||
to_lat,
|
||||
...(product_category && { product_category })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi generate ride app link error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiGetDriverLocation(args: any) {
|
||||
const { order_id } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_get_driver_location',
|
||||
arguments: {
|
||||
order_id
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi get driver location error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTaxiQueryOrder(args: any) {
|
||||
const { order_id } = args
|
||||
|
||||
const params = {
|
||||
name: 'taxi_query_order',
|
||||
arguments: {
|
||||
...(order_id && { order_id })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.makeRequest('tools/call', params)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2)
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Taxi query order error:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async makeRequest(method: string, params: any): Promise<any> {
|
||||
const requestData = {
|
||||
jsonrpc: '2.0',
|
||||
method: method,
|
||||
id: Date.now(),
|
||||
...(Object.keys(params).length > 0 && { params })
|
||||
}
|
||||
|
||||
// API key is passed as URL parameter
|
||||
const url = `${this.baseUrl}?key=${this.apiKey}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(`API Error: ${JSON.stringify(data.error)}`)
|
||||
}
|
||||
|
||||
return data.result
|
||||
}
|
||||
}
|
||||
|
||||
export default DiDiMcpServer
|
||||
@@ -3,7 +3,7 @@ import { loggerService } from '@logger'
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { net } from 'electron'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
|
||||
const logger = loggerService.withContext('DifyKnowledgeServer')
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types'
|
||||
|
||||
import BraveSearchServer from './brave-search'
|
||||
import DiDiMcpServer from './didi-mcp'
|
||||
import DifyKnowledgeServer from './dify-knowledge'
|
||||
import FetchServer from './fetch'
|
||||
import FileSystemServer from './filesystem'
|
||||
@@ -42,6 +43,10 @@ export function createInMemoryMCPServer(
|
||||
case BuiltinMCPServerNames.python: {
|
||||
return new PythonServer().server
|
||||
}
|
||||
case BuiltinMCPServerNames.didiMCP: {
|
||||
const apiKey = envs.DIDI_API_KEY
|
||||
return new DiDiMcpServer(apiKey).server
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown in-memory MCP server: ${name}`)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
|
||||
import { net } from 'electron'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import TurndownService from 'turndown'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
|
||||
export const RequestPayloadSchema = z.object({
|
||||
url: z.url(),
|
||||
|
||||
@@ -8,7 +8,7 @@ import fs from 'fs/promises'
|
||||
import { minimatch } from 'minimatch'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
|
||||
const logger = loggerService.withContext('MCP:FileSystemServer')
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { ApiServerConfig } from '@types'
|
||||
import {
|
||||
ApiServerConfig,
|
||||
GetApiServerStatusResult,
|
||||
RestartApiServerStatusResult,
|
||||
StartApiServerStatusResult,
|
||||
StopApiServerStatusResult
|
||||
} from '@types'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
import { apiServer } from '../apiServer'
|
||||
@@ -52,7 +58,7 @@ export class ApiServerService {
|
||||
|
||||
registerIpcHandlers(): void {
|
||||
// API Server
|
||||
ipcMain.handle(IpcChannel.ApiServer_Start, async () => {
|
||||
ipcMain.handle(IpcChannel.ApiServer_Start, async (): Promise<StartApiServerStatusResult> => {
|
||||
try {
|
||||
await this.start()
|
||||
return { success: true }
|
||||
@@ -61,7 +67,7 @@ export class ApiServerService {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ApiServer_Stop, async () => {
|
||||
ipcMain.handle(IpcChannel.ApiServer_Stop, async (): Promise<StopApiServerStatusResult> => {
|
||||
try {
|
||||
await this.stop()
|
||||
return { success: true }
|
||||
@@ -70,7 +76,7 @@ export class ApiServerService {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ApiServer_Restart, async () => {
|
||||
ipcMain.handle(IpcChannel.ApiServer_Restart, async (): Promise<RestartApiServerStatusResult> => {
|
||||
try {
|
||||
await this.restart()
|
||||
return { success: true }
|
||||
@@ -79,7 +85,7 @@ export class ApiServerService {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.ApiServer_GetStatus, async () => {
|
||||
ipcMain.handle(IpcChannel.ApiServer_GetStatus, async (): Promise<GetApiServerStatusResult> => {
|
||||
try {
|
||||
const config = await this.getCurrentConfig()
|
||||
return {
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { isWin } from '@main/constant'
|
||||
import { getIpCountry } from '@main/utils/ipService'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { generateUserAgent } from '@main/utils/systemInfo'
|
||||
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
|
||||
import { app, BrowserWindow, dialog, net } from 'electron'
|
||||
import { app, net } from 'electron'
|
||||
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
|
||||
import path from 'path'
|
||||
import semver from 'semver'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
import { configManager } from './ConfigManager'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
@@ -26,7 +24,6 @@ const LANG_MARKERS = {
|
||||
|
||||
export default class AppUpdater {
|
||||
autoUpdater: _AppUpdater = autoUpdater
|
||||
private releaseInfo: UpdateInfo | undefined
|
||||
private cancellationToken: CancellationToken = new CancellationToken()
|
||||
private updateCheckResult: UpdateCheckResult | null = null
|
||||
|
||||
@@ -66,7 +63,6 @@ export default class AppUpdater {
|
||||
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
|
||||
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
|
||||
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
|
||||
this.releaseInfo = processedReleaseInfo
|
||||
logger.info('update downloaded', processedReleaseInfo)
|
||||
})
|
||||
|
||||
@@ -247,37 +243,9 @@ export default class AppUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
public async showUpdateDialog(mainWindow: BrowserWindow) {
|
||||
if (!this.releaseInfo) {
|
||||
return
|
||||
}
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { update: updateLocale } = locale.translation
|
||||
|
||||
let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes)
|
||||
if (detail === '') {
|
||||
detail = updateLocale.noReleaseNotes
|
||||
}
|
||||
|
||||
dialog
|
||||
.showMessageBox({
|
||||
type: 'info',
|
||||
title: updateLocale.title,
|
||||
icon,
|
||||
message: updateLocale.message.replace('{{version}}', this.releaseInfo.version),
|
||||
detail,
|
||||
buttons: [updateLocale.later, updateLocale.install],
|
||||
defaultId: 1,
|
||||
cancelId: 0
|
||||
})
|
||||
.then(({ response }) => {
|
||||
if (response === 1) {
|
||||
app.isQuitting = true
|
||||
setImmediate(() => autoUpdater.quitAndInstall())
|
||||
} else {
|
||||
mainWindow.webContents.send(IpcChannel.UpdateDownloadedCancelled)
|
||||
}
|
||||
})
|
||||
public quitAndInstall() {
|
||||
app.isQuitting = true
|
||||
setImmediate(() => autoUpdater.quitAndInstall())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,38 +317,9 @@ export default class AppUpdater {
|
||||
|
||||
return processedInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Format release notes for display
|
||||
* @param releaseNotes - Release notes in various formats
|
||||
* @returns Formatted string for display
|
||||
*/
|
||||
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
|
||||
if (!releaseNotes) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof releaseNotes === 'string') {
|
||||
// Check if it contains multi-language markers
|
||||
if (this.hasMultiLanguageMarkers(releaseNotes)) {
|
||||
return this.parseMultiLangReleaseNotes(releaseNotes)
|
||||
}
|
||||
return releaseNotes
|
||||
}
|
||||
|
||||
if (Array.isArray(releaseNotes)) {
|
||||
return releaseNotes.map((note) => note.note).join('\n')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
}
|
||||
interface GithubReleaseInfo {
|
||||
draft: boolean
|
||||
prerelease: boolean
|
||||
tag_name: string
|
||||
}
|
||||
interface ReleaseNoteInfo {
|
||||
readonly version: string
|
||||
readonly note: string | null
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@ interface VersionInfo {
|
||||
|
||||
class CodeToolsService {
|
||||
private versionCache: Map<string, { version: string; timestamp: number }> = new Map()
|
||||
private terminalsCache: { terminals: TerminalConfig[]; timestamp: number } | null = null
|
||||
private terminalsCache: {
|
||||
terminals: TerminalConfig[]
|
||||
timestamp: number
|
||||
} | null = null
|
||||
private customTerminalPaths: Map<string, string> = new Map() // Store user-configured terminal paths
|
||||
private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache
|
||||
private readonly TERMINALS_CACHE_DURATION = 1000 * 60 * 5 // 5 minutes cache for terminals
|
||||
@@ -82,6 +85,8 @@ class CodeToolsService {
|
||||
return '@qwen-code/qwen-code'
|
||||
case codeTools.iFlowCli:
|
||||
return '@iflow-ai/iflow-cli'
|
||||
case codeTools.githubCopilotCli:
|
||||
return '@github/copilot'
|
||||
default:
|
||||
throw new Error(`Unsupported CLI tool: ${cliTool}`)
|
||||
}
|
||||
@@ -99,6 +104,8 @@ class CodeToolsService {
|
||||
return 'qwen'
|
||||
case codeTools.iFlowCli:
|
||||
return 'iflow'
|
||||
case codeTools.githubCopilotCli:
|
||||
return 'copilot'
|
||||
default:
|
||||
throw new Error(`Unsupported CLI tool: ${cliTool}`)
|
||||
}
|
||||
@@ -144,7 +151,9 @@ class CodeToolsService {
|
||||
case terminalApps.powershell:
|
||||
// Check for PowerShell in PATH
|
||||
try {
|
||||
await execAsync('powershell -Command "Get-Host"', { timeout: 3000 })
|
||||
await execAsync('powershell -Command "Get-Host"', {
|
||||
timeout: 3000
|
||||
})
|
||||
return terminal
|
||||
} catch {
|
||||
try {
|
||||
@@ -384,7 +393,9 @@ class CodeToolsService {
|
||||
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
|
||||
|
||||
const { stdout } = await execAsync(`"${executablePath}" --version`, { timeout: 10000 })
|
||||
const { stdout } = await execAsync(`"${executablePath}" --version`, {
|
||||
timeout: 10000
|
||||
})
|
||||
// Extract version number from output (format may vary by tool)
|
||||
const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/)
|
||||
installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0]
|
||||
@@ -425,7 +436,10 @@ class CodeToolsService {
|
||||
logger.info(`${packageName} latest version: ${latestVersion}`)
|
||||
|
||||
// Cache the result
|
||||
this.versionCache.set(cacheKey, { version: latestVersion!, timestamp: now })
|
||||
this.versionCache.set(cacheKey, {
|
||||
version: latestVersion!,
|
||||
timestamp: now
|
||||
})
|
||||
logger.debug(`Cached latest version for ${packageName}`)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to get latest version for ${packageName}:`, error as Error)
|
||||
|
||||
@@ -725,7 +725,10 @@ class FileStorage {
|
||||
}
|
||||
|
||||
public openPath = async (_: Electron.IpcMainInvokeEvent, path: string): Promise<void> => {
|
||||
shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
|
||||
const resolved = await shell.openPath(path)
|
||||
if (resolved !== '') {
|
||||
throw new Error(resolved)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1229,6 +1232,19 @@ class FileStorage {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public showInFolder = async (_: Electron.IpcMainInvokeEvent, path: string): Promise<void> => {
|
||||
if (!fs.existsSync(path)) {
|
||||
const msg = `File or folder does not exist: ${path}`
|
||||
logger.error(msg)
|
||||
throw new Error(msg)
|
||||
}
|
||||
try {
|
||||
shell.showItemInFolder(path)
|
||||
} catch (error) {
|
||||
logger.error('Failed to show item in folder:', error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fileStorage = new FileStorage()
|
||||
|
||||
@@ -29,7 +29,7 @@ import Reranker from '@main/knowledge/reranker/Reranker'
|
||||
import { fileStorage } from '@main/services/FileStorage'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getDataPath } from '@main/utils'
|
||||
import { getAllFiles } from '@main/utils/file'
|
||||
import { getAllFiles, sanitizeFilename } from '@main/utils/file'
|
||||
import { TraceMethod } from '@mcp-trace/trace-core'
|
||||
import { MB } from '@shared/config/constant'
|
||||
import type { LoaderReturn } from '@shared/config/types'
|
||||
@@ -147,11 +147,16 @@ class KnowledgeService {
|
||||
}
|
||||
}
|
||||
|
||||
private getDbPath = (id: string): string => {
|
||||
// 消除网络搜索requestI d中的特殊字符
|
||||
return path.join(this.storageDir, sanitizeFilename(id, '_'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete knowledge base file
|
||||
*/
|
||||
private deleteKnowledgeFile = (id: string): boolean => {
|
||||
const dbPath = path.join(this.storageDir, id)
|
||||
const dbPath = this.getDbPath(id)
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
fs.rmSync(dbPath, { recursive: true })
|
||||
@@ -244,7 +249,8 @@ class KnowledgeService {
|
||||
dimensions
|
||||
})
|
||||
try {
|
||||
const libSqlDb = new LibSqlDb({ path: path.join(this.storageDir, id) })
|
||||
const dbPath = this.getDbPath(id)
|
||||
const libSqlDb = new LibSqlDb({ path: dbPath })
|
||||
// Save database instance for later closing
|
||||
this.dbInstances.set(id, libSqlDb)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createInMemoryMCPServer } from '@main/mcpServers/factory'
|
||||
import { makeSureDirExists, removeEnvProxy } from '@main/utils'
|
||||
import { buildFunctionCallToolName } from '@main/utils/mcp'
|
||||
import { getBinaryName, getBinaryPath } from '@main/utils/process'
|
||||
import getLoginShellEnvironment from '@main/utils/shell-env'
|
||||
import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||
@@ -43,14 +44,12 @@ import {
|
||||
} from '@types'
|
||||
import { app, net } from 'electron'
|
||||
import { EventEmitter } from 'events'
|
||||
import { memoize } from 'lodash'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { CacheService } from './CacheService'
|
||||
import DxtService from './DxtService'
|
||||
import { CallBackServer } from './mcp/oauth/callback'
|
||||
import { McpOAuthClientProvider } from './mcp/oauth/provider'
|
||||
import getLoginShellEnvironment from './mcp/shell-env'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
// Generic type for caching wrapped functions
|
||||
@@ -335,7 +334,7 @@ class McpService {
|
||||
|
||||
getServerLogger(server).debug(`Starting server`, { command: cmd, args })
|
||||
// Logger.info(`[MCP] Environment variables for server:`, server.env)
|
||||
const loginShellEnv = await this.getLoginShellEnv()
|
||||
const loginShellEnv = await getLoginShellEnvironment()
|
||||
|
||||
// Bun not support proxy https://github.com/oven-sh/bun/issues/16812
|
||||
if (cmd.includes('bun')) {
|
||||
@@ -878,20 +877,6 @@ class McpService {
|
||||
return await cachedGetResource(server, uri)
|
||||
}
|
||||
|
||||
private getLoginShellEnv = memoize(async (): Promise<Record<string, string>> => {
|
||||
try {
|
||||
const loginEnv = await getLoginShellEnvironment()
|
||||
const pathSeparator = process.platform === 'win32' ? ';' : ':'
|
||||
const cherryBinPath = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
loginEnv.PATH = `${loginEnv.PATH}${pathSeparator}${cherryBinPath}`
|
||||
logger.debug('Successfully fetched login shell environment variables:')
|
||||
return loginEnv
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch login shell environment variables:', error as Error)
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
// 实现 abortTool 方法
|
||||
public async abortTool(_: Electron.IpcMainInvokeEvent, callId: string) {
|
||||
const activeToolCall = this.activeToolCalls.get(callId)
|
||||
|
||||
586
src/main/services/OvmsManager.ts
Normal file
586
src/main/services/OvmsManager.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
import { exec } from 'node:child_process'
|
||||
import { homedir } from 'node:os'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import * as fs from 'fs-extra'
|
||||
import * as path from 'path'
|
||||
|
||||
const logger = loggerService.withContext('OvmsManager')
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
interface OvmsProcess {
|
||||
pid: number
|
||||
path: string
|
||||
workingDirectory: string
|
||||
}
|
||||
|
||||
interface ModelConfig {
|
||||
name: string
|
||||
base_path: string
|
||||
}
|
||||
|
||||
interface OvmsConfig {
|
||||
mediapipe_config_list: ModelConfig[]
|
||||
}
|
||||
|
||||
class OvmsManager {
|
||||
private ovms: OvmsProcess | null = null
|
||||
|
||||
/**
|
||||
* Recursively terminate a process and all its child processes
|
||||
* @param pid Process ID to terminate
|
||||
* @returns Promise<{ success: boolean; message?: string }>
|
||||
*/
|
||||
private async terminalProcess(pid: number): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
// Check if the process is running
|
||||
const processCheckCommand = `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object Id | ConvertTo-Json`
|
||||
const { stdout: processStdout } = await execAsync(`powershell -Command "${processCheckCommand}"`)
|
||||
|
||||
if (!processStdout.trim()) {
|
||||
logger.info(`Process with PID ${pid} is not running`)
|
||||
return { success: true, message: `Process with PID ${pid} is not running` }
|
||||
}
|
||||
|
||||
// Find child processes
|
||||
const childProcessCommand = `Get-WmiObject -Class Win32_Process | Where-Object { $_.ParentProcessId -eq ${pid} } | Select-Object ProcessId | ConvertTo-Json`
|
||||
const { stdout: childStdout } = await execAsync(`powershell -Command "${childProcessCommand}"`)
|
||||
|
||||
// If there are child processes, terminate them first
|
||||
if (childStdout.trim()) {
|
||||
const childProcesses = JSON.parse(childStdout)
|
||||
const childList = Array.isArray(childProcesses) ? childProcesses : [childProcesses]
|
||||
|
||||
logger.info(`Found ${childList.length} child processes for PID ${pid}`)
|
||||
|
||||
// Recursively terminate each child process
|
||||
for (const childProcess of childList) {
|
||||
const childPid = childProcess.ProcessId
|
||||
logger.info(`Terminating child process PID: ${childPid}`)
|
||||
await this.terminalProcess(childPid)
|
||||
}
|
||||
} else {
|
||||
logger.info(`No child processes found for PID ${pid}`)
|
||||
}
|
||||
|
||||
// Finally, terminate the parent process
|
||||
const killCommand = `Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue`
|
||||
await execAsync(`powershell -Command "${killCommand}"`)
|
||||
logger.info(`Terminated process with PID: ${pid}`)
|
||||
|
||||
// Wait for the process to disappear with 5-second timeout
|
||||
const timeout = 5000 // 5 seconds
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const checkCommand = `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object Id | ConvertTo-Json`
|
||||
const { stdout: checkStdout } = await execAsync(`powershell -Command "${checkCommand}"`)
|
||||
|
||||
if (!checkStdout.trim()) {
|
||||
logger.info(`Process with PID ${pid} has disappeared`)
|
||||
return { success: true, message: `Process ${pid} and all child processes terminated successfully` }
|
||||
}
|
||||
|
||||
// Wait 300ms before checking again
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
}
|
||||
|
||||
logger.warn(`Process with PID ${pid} did not disappear within timeout`)
|
||||
return { success: false, message: `Process ${pid} did not disappear within 5 seconds` }
|
||||
} catch (error) {
|
||||
logger.error(`Failed to terminate process ${pid}:`, error as Error)
|
||||
return { success: false, message: `Failed to terminate process ${pid}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop OVMS process if it's running
|
||||
* @returns Promise<{ success: boolean; message?: string }>
|
||||
*/
|
||||
public async stopOvms(): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
// Check if OVMS process is running
|
||||
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
|
||||
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
|
||||
|
||||
if (!stdout.trim()) {
|
||||
logger.info('OVMS process is not running')
|
||||
return { success: true, message: 'OVMS process is not running' }
|
||||
}
|
||||
|
||||
const processes = JSON.parse(stdout)
|
||||
const processList = Array.isArray(processes) ? processes : [processes]
|
||||
|
||||
if (processList.length === 0) {
|
||||
logger.info('OVMS process is not running')
|
||||
return { success: true, message: 'OVMS process is not running' }
|
||||
}
|
||||
|
||||
// Terminate all OVMS processes using terminalProcess
|
||||
for (const process of processList) {
|
||||
const result = await this.terminalProcess(process.Id)
|
||||
if (!result.success) {
|
||||
logger.error(`Failed to terminate OVMS process with PID: ${process.Id}, ${result.message}`)
|
||||
return { success: false, message: `Failed to terminate OVMS process: ${result.message}` }
|
||||
}
|
||||
logger.info(`Terminated OVMS process with PID: ${process.Id}`)
|
||||
}
|
||||
|
||||
// Reset the ovms instance
|
||||
this.ovms = null
|
||||
|
||||
logger.info('OVMS process stopped successfully')
|
||||
return { success: true, message: 'OVMS process stopped successfully' }
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop OVMS process: ${error}`)
|
||||
return { success: false, message: 'Failed to stop OVMS process' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run OVMS by ensuring config.json exists and executing run.bat
|
||||
* @returns Promise<{ success: boolean; message?: string }>
|
||||
*/
|
||||
public async runOvms(): Promise<{ success: boolean; message?: string }> {
|
||||
const homeDir = homedir()
|
||||
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
||||
const configPath = path.join(ovmsDir, 'models', 'config.json')
|
||||
const runBatPath = path.join(ovmsDir, 'run.bat')
|
||||
|
||||
try {
|
||||
// Check if config.json exists, if not create it with default content
|
||||
if (!(await fs.pathExists(configPath))) {
|
||||
logger.info(`Config file does not exist, creating: ${configPath}`)
|
||||
|
||||
// Ensure the models directory exists
|
||||
await fs.ensureDir(path.dirname(configPath))
|
||||
|
||||
// Create config.json with default content
|
||||
const defaultConfig = {
|
||||
mediapipe_config_list: [],
|
||||
model_config_list: []
|
||||
}
|
||||
|
||||
await fs.writeJson(configPath, defaultConfig, { spaces: 2 })
|
||||
logger.info(`Config file created: ${configPath}`)
|
||||
}
|
||||
|
||||
// Check if run.bat exists
|
||||
if (!(await fs.pathExists(runBatPath))) {
|
||||
logger.error(`run.bat not found at: ${runBatPath}`)
|
||||
return { success: false, message: 'run.bat not found' }
|
||||
}
|
||||
|
||||
// Run run.bat without waiting for it to complete
|
||||
logger.info(`Starting OVMS with run.bat: ${runBatPath}`)
|
||||
exec(`"${runBatPath}"`, { cwd: ovmsDir }, (error) => {
|
||||
if (error) {
|
||||
logger.error(`Error running run.bat: ${error}`)
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('OVMS started successfully')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`Failed to run OVMS: ${error}`)
|
||||
return { success: false, message: 'Failed to run OVMS' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OVMS status - checks installation and running status
|
||||
* @returns 'not-installed' | 'not-running' | 'running'
|
||||
*/
|
||||
public async getOvmsStatus(): Promise<'not-installed' | 'not-running' | 'running'> {
|
||||
const homeDir = homedir()
|
||||
const ovmsPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'ovms.exe')
|
||||
|
||||
try {
|
||||
// Check if OVMS executable exists
|
||||
if (!(await fs.pathExists(ovmsPath))) {
|
||||
logger.info(`OVMS executable not found at: ${ovmsPath}`)
|
||||
return 'not-installed'
|
||||
}
|
||||
|
||||
// Check if OVMS process is running
|
||||
//const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq "${ovmsPath.replace(/\\/g, '\\\\')}" } | Select-Object Id | ConvertTo-Json`;
|
||||
//const { stdout } = await execAsync(`powershell -Command "${psCommand}"`);
|
||||
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
|
||||
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
|
||||
|
||||
if (!stdout.trim()) {
|
||||
logger.info('OVMS process not running')
|
||||
return 'not-running'
|
||||
}
|
||||
|
||||
const processes = JSON.parse(stdout)
|
||||
const processList = Array.isArray(processes) ? processes : [processes]
|
||||
|
||||
if (processList.length > 0) {
|
||||
logger.info('OVMS process is running')
|
||||
return 'running'
|
||||
} else {
|
||||
logger.info('OVMS process not running')
|
||||
return 'not-running'
|
||||
}
|
||||
} catch (error) {
|
||||
logger.info(`Failed to check OVMS status: ${error}`)
|
||||
return 'not-running'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize OVMS by finding the executable path and working directory
|
||||
*/
|
||||
public async initializeOvms(): Promise<boolean> {
|
||||
// Use PowerShell to find ovms.exe processes with their paths
|
||||
const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
|
||||
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
|
||||
|
||||
if (!stdout.trim()) {
|
||||
logger.error('Command to find OVMS process returned no output')
|
||||
return false
|
||||
}
|
||||
logger.debug(`OVMS process output: ${stdout}`)
|
||||
|
||||
const processes = JSON.parse(stdout)
|
||||
const processList = Array.isArray(processes) ? processes : [processes]
|
||||
|
||||
// Find the first process with a valid path
|
||||
for (const process of processList) {
|
||||
this.ovms = {
|
||||
pid: process.Id,
|
||||
path: process.Path,
|
||||
workingDirectory: path.dirname(process.Path)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return this.ovms !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Model Name and ID are valid, they are valid only if they are not used in the config.json
|
||||
* @param modelName Name of the model to check
|
||||
* @param modelId ID of the model to check
|
||||
*/
|
||||
public async isNameAndIDAvalid(modelName: string, modelId: string): Promise<boolean> {
|
||||
if (!modelName || !modelId) {
|
||||
logger.error('Model name and ID cannot be empty')
|
||||
return false
|
||||
}
|
||||
|
||||
const homeDir = homedir()
|
||||
const configPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'models', 'config.json')
|
||||
try {
|
||||
if (!(await fs.pathExists(configPath))) {
|
||||
logger.warn(`Config file does not exist: ${configPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const config: OvmsConfig = await fs.readJson(configPath)
|
||||
if (!config.mediapipe_config_list) {
|
||||
logger.warn(`No mediapipe_config_list found in config: ${configPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the model name or ID already exists in the config
|
||||
const exists = config.mediapipe_config_list.some(
|
||||
(model) => model.name === modelName || model.base_path === modelId
|
||||
)
|
||||
if (exists) {
|
||||
logger.warn(`Model with name "${modelName}" or ID "${modelId}" already exists in the config`)
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to check model existence: ${error}`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private async applyModelPath(modelDirPath: string): Promise<boolean> {
|
||||
const homeDir = homedir()
|
||||
const patchDir = path.join(homeDir, '.cherrystudio', 'ovms', 'patch')
|
||||
if (!(await fs.pathExists(patchDir))) {
|
||||
return true
|
||||
}
|
||||
|
||||
const modelId = path.basename(modelDirPath)
|
||||
|
||||
// get all sub directories in patchDir
|
||||
const patchs = await fs.readdir(patchDir)
|
||||
for (const patch of patchs) {
|
||||
const fullPatchPath = path.join(patchDir, patch)
|
||||
|
||||
if (fs.lstatSync(fullPatchPath).isDirectory()) {
|
||||
if (modelId.toLowerCase().includes(patch.toLowerCase())) {
|
||||
// copy all files from fullPath to modelDirPath
|
||||
try {
|
||||
const files = await fs.readdir(fullPatchPath)
|
||||
for (const file of files) {
|
||||
const srcFile = path.join(fullPatchPath, file)
|
||||
const destFile = path.join(modelDirPath, file)
|
||||
await fs.copyFile(srcFile, destFile)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to copy files from ${fullPatchPath} to ${modelDirPath}: ${error}`)
|
||||
return false
|
||||
}
|
||||
logger.info(`Applied patchs for model ${modelId}`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a model to OVMS by downloading it
|
||||
* @param modelName Name of the model to add
|
||||
* @param modelId ID of the model to download
|
||||
* @param modelSource Model Source: huggingface, hf-mirror and modelscope, default is huggingface
|
||||
* @param task Task type: text_generation, embedding, rerank, image_generation
|
||||
*/
|
||||
public async addModel(
|
||||
modelName: string,
|
||||
modelId: string,
|
||||
modelSource: string,
|
||||
task: string = 'text_generation'
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
logger.info(`Adding model: ${modelName} with ID: ${modelId}, Source: ${modelSource}, Task: ${task}`)
|
||||
|
||||
const homeDir = homedir()
|
||||
const ovdndDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
||||
const pathModel = path.join(ovdndDir, 'models', modelId)
|
||||
|
||||
try {
|
||||
// check the ovdnDir+'models'+modelId exist or not
|
||||
if (await fs.pathExists(pathModel)) {
|
||||
logger.error(`Model with ID ${modelId} already exists`)
|
||||
return { success: false, message: 'Model ID already exists!' }
|
||||
}
|
||||
|
||||
// remove the model directory if it exists
|
||||
if (await fs.pathExists(pathModel)) {
|
||||
logger.info(`Removing existing model directory: ${pathModel}`)
|
||||
await fs.remove(pathModel)
|
||||
}
|
||||
|
||||
// Use ovdnd.exe for downloading instead of ovms.exe
|
||||
const ovdndPath = path.join(ovdndDir, 'ovdnd.exe')
|
||||
const command =
|
||||
`"${ovdndPath}" --pull ` +
|
||||
`--model_repository_path "${ovdndDir}/models" ` +
|
||||
`--source_model "${modelId}" ` +
|
||||
`--model_name "${modelName}" ` +
|
||||
`--target_device GPU ` +
|
||||
`--task ${task} ` +
|
||||
`--overwrite_models`
|
||||
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
OVMS_DIR: ovdndDir,
|
||||
PYTHONHOME: path.join(ovdndDir, 'python'),
|
||||
PATH: `${process.env.PATH};${ovdndDir};${path.join(ovdndDir, 'python')}`
|
||||
}
|
||||
|
||||
if (modelSource) {
|
||||
env.HF_ENDPOINT = modelSource
|
||||
}
|
||||
|
||||
logger.info(`Running command: ${command} from ${modelSource}`)
|
||||
const { stdout } = await execAsync(command, { env: env, cwd: ovdndDir })
|
||||
|
||||
logger.info('Model download completed')
|
||||
logger.debug(`Command output: ${stdout}`)
|
||||
} catch (error) {
|
||||
// remove ovdnDir+'models'+modelId if it exists
|
||||
if (await fs.pathExists(pathModel)) {
|
||||
logger.info(`Removing failed model directory: ${pathModel}`)
|
||||
await fs.remove(pathModel)
|
||||
}
|
||||
logger.error(`Failed to add model: ${error}`)
|
||||
return {
|
||||
success: false,
|
||||
message: `Download model ${modelId} failed, please check following items and try it again:<p>- the model id</p><p>- network connection and proxy</p>`
|
||||
}
|
||||
}
|
||||
|
||||
// Update config file
|
||||
if (!(await this.updateModelConfig(modelName, modelId))) {
|
||||
logger.error('Failed to update model config')
|
||||
return { success: false, message: 'Failed to update model config' }
|
||||
}
|
||||
|
||||
if (!(await this.applyModelPath(pathModel))) {
|
||||
logger.error('Failed to apply model patchs')
|
||||
return { success: false, message: 'Failed to apply model patchs' }
|
||||
}
|
||||
|
||||
logger.info(`Model ${modelName} added successfully with ID ${modelId}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the model download process if it's running
|
||||
* @returns Promise<{ success: boolean; message?: string }>
|
||||
*/
|
||||
public async stopAddModel(): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
// Check if ovdnd.exe process is running
|
||||
const psCommand = `Get-Process -Name "ovdnd" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json`
|
||||
const { stdout } = await execAsync(`powershell -Command "${psCommand}"`)
|
||||
|
||||
if (!stdout.trim()) {
|
||||
logger.info('ovdnd process is not running')
|
||||
return { success: true, message: 'Model download process is not running' }
|
||||
}
|
||||
|
||||
const processes = JSON.parse(stdout)
|
||||
const processList = Array.isArray(processes) ? processes : [processes]
|
||||
|
||||
if (processList.length === 0) {
|
||||
logger.info('ovdnd process is not running')
|
||||
return { success: true, message: 'Model download process is not running' }
|
||||
}
|
||||
|
||||
// Terminate all ovdnd processes
|
||||
for (const process of processList) {
|
||||
this.terminalProcess(process.Id)
|
||||
}
|
||||
|
||||
logger.info('Model download process stopped successfully')
|
||||
return { success: true, message: 'Model download process stopped successfully' }
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop model download process: ${error}`)
|
||||
return { success: false, message: 'Failed to stop model download process' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* check if the model id exists in the OVMS configuration
|
||||
* @param modelId ID of the model to check
|
||||
*/
|
||||
public async checkModelExists(modelId: string): Promise<boolean> {
|
||||
const homeDir = homedir()
|
||||
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
||||
const configPath = path.join(ovmsDir, 'models', 'config.json')
|
||||
|
||||
try {
|
||||
if (!(await fs.pathExists(configPath))) {
|
||||
logger.warn(`Config file does not exist: ${configPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const config: OvmsConfig = await fs.readJson(configPath)
|
||||
if (!config.mediapipe_config_list) {
|
||||
logger.warn('No mediapipe_config_list found in config')
|
||||
return false
|
||||
}
|
||||
|
||||
return config.mediapipe_config_list.some((model) => model.base_path === modelId)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to check model existence: ${error}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the model configuration file
|
||||
*/
|
||||
public async updateModelConfig(modelName: string, modelId: string): Promise<boolean> {
|
||||
const homeDir = homedir()
|
||||
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
||||
const configPath = path.join(ovmsDir, 'models', 'config.json')
|
||||
|
||||
try {
|
||||
// Ensure the models directory exists
|
||||
await fs.ensureDir(path.dirname(configPath))
|
||||
let config: OvmsConfig
|
||||
|
||||
// Read existing config or create new one
|
||||
if (await fs.pathExists(configPath)) {
|
||||
config = await fs.readJson(configPath)
|
||||
} else {
|
||||
config = { mediapipe_config_list: [] }
|
||||
}
|
||||
|
||||
// Ensure mediapipe_config_list exists
|
||||
if (!config.mediapipe_config_list) {
|
||||
config.mediapipe_config_list = []
|
||||
}
|
||||
|
||||
// Add new model config
|
||||
const newModelConfig: ModelConfig = {
|
||||
name: modelName,
|
||||
base_path: modelId
|
||||
}
|
||||
|
||||
// Check if model already exists, if so, update it
|
||||
const existingIndex = config.mediapipe_config_list.findIndex((model) => model.base_path === modelId)
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
config.mediapipe_config_list[existingIndex] = newModelConfig
|
||||
logger.info(`Updated existing model config: ${modelName}`)
|
||||
} else {
|
||||
config.mediapipe_config_list.push(newModelConfig)
|
||||
logger.info(`Added new model config: ${modelName}`)
|
||||
}
|
||||
|
||||
// Write config back to file
|
||||
await fs.writeJson(configPath, config, { spaces: 2 })
|
||||
logger.info(`Config file updated: ${configPath}`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update model config: ${error}`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all models from OVMS config, filtered for image generation models
|
||||
* @returns Array of model configurations
|
||||
*/
|
||||
public async getModels(): Promise<ModelConfig[]> {
|
||||
const homeDir = homedir()
|
||||
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
|
||||
const configPath = path.join(ovmsDir, 'models', 'config.json')
|
||||
|
||||
try {
|
||||
if (!(await fs.pathExists(configPath))) {
|
||||
logger.warn(`Config file does not exist: ${configPath}`)
|
||||
return []
|
||||
}
|
||||
|
||||
const config: OvmsConfig = await fs.readJson(configPath)
|
||||
if (!config.mediapipe_config_list) {
|
||||
logger.warn('No mediapipe_config_list found in config')
|
||||
return []
|
||||
}
|
||||
|
||||
// Filter models for image generation (SD, Stable-Diffusion, Stable Diffusion, FLUX)
|
||||
const imageGenerationModels = config.mediapipe_config_list.filter((model) => {
|
||||
const modelName = model.name.toLowerCase()
|
||||
return (
|
||||
modelName.startsWith('sd') ||
|
||||
modelName.startsWith('stable-diffusion') ||
|
||||
modelName.startsWith('stable diffusion') ||
|
||||
modelName.startsWith('flux')
|
||||
)
|
||||
})
|
||||
|
||||
logger.info(`Found ${imageGenerationModels.length} image generation models`)
|
||||
return imageGenerationModels
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get models: ${error}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default OvmsManager
|
||||
@@ -1,4 +1,5 @@
|
||||
import { session, shell, webContents } from 'electron'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { app, session, shell, webContents } from 'electron'
|
||||
|
||||
/**
|
||||
* init the useragent of the webview session
|
||||
@@ -36,3 +37,66 @@ export function setOpenLinkExternal(webviewId: number, isExternal: boolean) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const attachKeyboardHandler = (contents: Electron.WebContents) => {
|
||||
if (contents.getType?.() !== 'webview') {
|
||||
return
|
||||
}
|
||||
|
||||
const handleBeforeInput = (event: Electron.Event, input: Electron.Input) => {
|
||||
if (!input) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = input.key?.toLowerCase()
|
||||
if (!key) {
|
||||
return
|
||||
}
|
||||
|
||||
const isFindShortcut = (input.control || input.meta) && key === 'f'
|
||||
const isEscape = key === 'escape'
|
||||
const isEnter = key === 'enter'
|
||||
|
||||
if (!isFindShortcut && !isEscape && !isEnter) {
|
||||
return
|
||||
}
|
||||
|
||||
const host = contents.hostWebContents
|
||||
if (!host || host.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Always prevent Cmd/Ctrl+F to override the guest page's native find dialog
|
||||
if (isFindShortcut) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// Send the hotkey event to the renderer
|
||||
// The renderer will decide whether to preventDefault for Escape and Enter
|
||||
// based on whether the search bar is visible
|
||||
host.send(IpcChannel.Webview_SearchHotkey, {
|
||||
webviewId: contents.id,
|
||||
key,
|
||||
control: Boolean(input.control),
|
||||
meta: Boolean(input.meta),
|
||||
shift: Boolean(input.shift),
|
||||
alt: Boolean(input.alt)
|
||||
})
|
||||
}
|
||||
|
||||
contents.on('before-input-event', handleBeforeInput)
|
||||
contents.once('destroyed', () => {
|
||||
contents.removeListener('before-input-event', handleBeforeInput)
|
||||
})
|
||||
}
|
||||
|
||||
export function initWebviewHotkeys() {
|
||||
webContents.getAllWebContents().forEach((contents) => {
|
||||
if (contents.isDestroyed()) return
|
||||
attachKeyboardHandler(contents)
|
||||
})
|
||||
|
||||
app.on('web-contents-created', (_, contents) => {
|
||||
attachKeyboardHandler(contents)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -274,46 +274,4 @@ describe('AppUpdater', () => {
|
||||
expect(result.releaseNotes).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatReleaseNotes', () => {
|
||||
it('should format string release notes with markers', () => {
|
||||
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
|
||||
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('English')
|
||||
})
|
||||
|
||||
it('should format string release notes without markers', () => {
|
||||
const notes = 'Simple notes'
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('Simple notes')
|
||||
})
|
||||
|
||||
it('should format array release notes', () => {
|
||||
const notes = [
|
||||
{ version: '1.0.0', note: 'Note 1' },
|
||||
{ version: '1.0.1', note: 'Note 2' }
|
||||
]
|
||||
|
||||
const result = (appUpdater as any).formatReleaseNotes(notes)
|
||||
|
||||
expect(result).toBe('Note 1\nNote 2')
|
||||
})
|
||||
|
||||
it('should handle null release notes', () => {
|
||||
const result = (appUpdater as any).formatReleaseNotes(null)
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should handle undefined release notes', () => {
|
||||
const result = (appUpdater as any).formatReleaseNotes(undefined)
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
336
src/main/services/agents/BaseService.ts
Normal file
336
src/main/services/agents/BaseService.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { type Client, createClient } from '@libsql/client'
|
||||
import { loggerService } from '@logger'
|
||||
import { mcpApiService } from '@main/apiServer/services/mcp'
|
||||
import { ModelValidationError, validateModelId } from '@main/apiServer/utils'
|
||||
import { AgentType, MCPTool, objectKeys, SlashCommand, Tool } from '@types'
|
||||
import { drizzle, type LibSQLDatabase } from 'drizzle-orm/libsql'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import { MigrationService } from './database/MigrationService'
|
||||
import * as schema from './database/schema'
|
||||
import { dbPath } from './drizzle.config'
|
||||
import { AgentModelField, AgentModelValidationError } from './errors'
|
||||
import { builtinSlashCommands } from './services/claudecode/commands'
|
||||
import { builtinTools } from './services/claudecode/tools'
|
||||
|
||||
const logger = loggerService.withContext('BaseService')
|
||||
|
||||
/**
|
||||
* Base service class providing shared database connection and utilities
|
||||
* for all agent-related services.
|
||||
*
|
||||
* Features:
|
||||
* - Programmatic schema management (no CLI dependencies)
|
||||
* - Automatic table creation and migration
|
||||
* - Schema version tracking and compatibility checks
|
||||
* - Transaction-based operations for safety
|
||||
* - Development vs production mode handling
|
||||
* - Connection retry logic with exponential backoff
|
||||
*/
|
||||
export abstract class BaseService {
|
||||
protected static client: Client | null = null
|
||||
protected static db: LibSQLDatabase<typeof schema> | null = null
|
||||
protected static isInitialized = false
|
||||
protected static initializationPromise: Promise<void> | null = null
|
||||
protected jsonFields: string[] = ['tools', 'mcps', 'configuration', 'accessible_paths', 'allowed_tools']
|
||||
|
||||
/**
|
||||
* Initialize database with retry logic and proper error handling
|
||||
*/
|
||||
protected static async initialize(): Promise<void> {
|
||||
// Return existing initialization if in progress
|
||||
if (BaseService.initializationPromise) {
|
||||
return BaseService.initializationPromise
|
||||
}
|
||||
|
||||
if (BaseService.isInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
BaseService.initializationPromise = BaseService.performInitialization()
|
||||
return BaseService.initializationPromise
|
||||
}
|
||||
|
||||
public async listMcpTools(agentType: AgentType, ids?: string[]): Promise<Tool[]> {
|
||||
const tools: Tool[] = []
|
||||
if (agentType === 'claude-code') {
|
||||
tools.push(...builtinTools)
|
||||
}
|
||||
if (ids && ids.length > 0) {
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const server = await mcpApiService.getServerInfo(id)
|
||||
if (server) {
|
||||
server.tools.forEach((tool: MCPTool) => {
|
||||
tools.push({
|
||||
id: `mcp_${id}_${tool.name}`,
|
||||
name: tool.name,
|
||||
type: 'mcp',
|
||||
description: tool.description || '',
|
||||
requirePermissions: true
|
||||
})
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to list MCP tools', {
|
||||
id,
|
||||
error: error as Error
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
public async listSlashCommands(agentType: AgentType): Promise<SlashCommand[]> {
|
||||
if (agentType === 'claude-code') {
|
||||
return builtinSlashCommands
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private static async performInitialization(): Promise<void> {
|
||||
const maxRetries = 3
|
||||
let lastError: Error
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
logger.info(`Initializing Agent database at: ${dbPath} (attempt ${attempt}/${maxRetries})`)
|
||||
|
||||
// Ensure the database directory exists
|
||||
const dbDir = path.dirname(dbPath)
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
logger.info(`Creating database directory: ${dbDir}`)
|
||||
fs.mkdirSync(dbDir, { recursive: true })
|
||||
}
|
||||
|
||||
BaseService.client = createClient({
|
||||
url: `file:${dbPath}`
|
||||
})
|
||||
|
||||
BaseService.db = drizzle(BaseService.client, { schema })
|
||||
|
||||
// Run database migrations
|
||||
const migrationService = new MigrationService(BaseService.db, BaseService.client)
|
||||
await migrationService.runMigrations()
|
||||
|
||||
BaseService.isInitialized = true
|
||||
logger.info('Agent database initialized successfully')
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
logger.warn(`Database initialization attempt ${attempt} failed:`, lastError)
|
||||
|
||||
// Clean up on failure
|
||||
if (BaseService.client) {
|
||||
try {
|
||||
BaseService.client.close()
|
||||
} catch (closeError) {
|
||||
logger.warn('Failed to close client during cleanup:', closeError as Error)
|
||||
}
|
||||
}
|
||||
BaseService.client = null
|
||||
BaseService.db = null
|
||||
|
||||
// Wait before retrying (exponential backoff)
|
||||
if (attempt < maxRetries) {
|
||||
const delay = Math.pow(2, attempt) * 1000 // 2s, 4s, 8s
|
||||
logger.info(`Retrying in ${delay}ms...`)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed
|
||||
BaseService.initializationPromise = null
|
||||
logger.error('Failed to initialize Agent database after all retries:', lastError!)
|
||||
throw lastError!
|
||||
}
|
||||
|
||||
protected ensureInitialized(): void {
|
||||
if (!BaseService.isInitialized || !BaseService.db || !BaseService.client) {
|
||||
throw new Error('Database not initialized. Call initialize() first.')
|
||||
}
|
||||
}
|
||||
|
||||
protected get database(): LibSQLDatabase<typeof schema> {
|
||||
this.ensureInitialized()
|
||||
return BaseService.db!
|
||||
}
|
||||
|
||||
protected get rawClient(): Client {
|
||||
this.ensureInitialized()
|
||||
return BaseService.client!
|
||||
}
|
||||
|
||||
protected serializeJsonFields(data: any): any {
|
||||
const serialized = { ...data }
|
||||
|
||||
for (const field of this.jsonFields) {
|
||||
if (serialized[field] !== undefined) {
|
||||
serialized[field] =
|
||||
Array.isArray(serialized[field]) || typeof serialized[field] === 'object'
|
||||
? JSON.stringify(serialized[field])
|
||||
: serialized[field]
|
||||
}
|
||||
}
|
||||
|
||||
return serialized
|
||||
}
|
||||
|
||||
protected deserializeJsonFields(data: any): any {
|
||||
if (!data) return data
|
||||
|
||||
const deserialized = { ...data }
|
||||
|
||||
for (const field of this.jsonFields) {
|
||||
if (deserialized[field] && typeof deserialized[field] === 'string') {
|
||||
try {
|
||||
deserialized[field] = JSON.parse(deserialized[field])
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to parse JSON field ${field}:`, error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// convert null from db to undefined to satisfy type definition
|
||||
for (const key of objectKeys(data)) {
|
||||
if (deserialized[key] === null) {
|
||||
deserialized[key] = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return deserialized
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate, normalize, and ensure filesystem access for a set of absolute paths.
|
||||
*
|
||||
* - Requires every entry to be an absolute path and throws if not.
|
||||
* - Normalizes each path and deduplicates while preserving order.
|
||||
* - Creates missing directories (or parent directories for file-like paths).
|
||||
*/
|
||||
protected ensurePathsExist(paths?: string[]): string[] {
|
||||
if (!paths?.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const sanitizedPaths: string[] = []
|
||||
const seenPaths = new Set<string>()
|
||||
|
||||
for (const rawPath of paths) {
|
||||
if (!rawPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(rawPath)) {
|
||||
throw new Error(`Accessible path must be absolute: ${rawPath}`)
|
||||
}
|
||||
|
||||
// Normalize to provide consistent values to downstream consumers.
|
||||
const resolvedPath = path.normalize(rawPath)
|
||||
|
||||
let stats: fs.Stats | null = null
|
||||
try {
|
||||
// Attempt to stat the path to understand whether it already exists and if it is a file.
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
stats = fs.statSync(resolvedPath)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to inspect accessible path', {
|
||||
path: rawPath,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
|
||||
const looksLikeFile =
|
||||
(stats && stats.isFile()) || (!stats && path.extname(resolvedPath) !== '' && !resolvedPath.endsWith(path.sep))
|
||||
|
||||
// For file-like targets create the parent directory; otherwise ensure the directory itself.
|
||||
const directoryToEnsure = looksLikeFile ? path.dirname(resolvedPath) : resolvedPath
|
||||
|
||||
if (!fs.existsSync(directoryToEnsure)) {
|
||||
try {
|
||||
fs.mkdirSync(directoryToEnsure, { recursive: true })
|
||||
} catch (error) {
|
||||
logger.error('Failed to create accessible path directory', {
|
||||
path: directoryToEnsure,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve the first occurrence only to avoid duplicates while keeping caller order stable.
|
||||
if (!seenPaths.has(resolvedPath)) {
|
||||
seenPaths.add(resolvedPath)
|
||||
sanitizedPaths.push(resolvedPath)
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizedPaths
|
||||
}
|
||||
|
||||
/**
|
||||
* Force re-initialization (for development/testing)
|
||||
*/
|
||||
protected async validateAgentModels(
|
||||
agentType: AgentType,
|
||||
models: Partial<Record<AgentModelField, string | undefined>>
|
||||
): Promise<void> {
|
||||
const entries = Object.entries(models) as [AgentModelField, string | undefined][]
|
||||
if (entries.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const [field, rawValue] of entries) {
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
continue
|
||||
}
|
||||
|
||||
const modelValue = rawValue
|
||||
const validation = await validateModelId(modelValue)
|
||||
|
||||
if (!validation.valid || !validation.provider) {
|
||||
const detail: ModelValidationError = validation.error ?? {
|
||||
type: 'invalid_format',
|
||||
message: 'Unknown model validation error',
|
||||
code: 'validation_error'
|
||||
}
|
||||
|
||||
throw new AgentModelValidationError({ agentType, field, model: modelValue }, detail)
|
||||
}
|
||||
|
||||
if (!validation.provider.apiKey) {
|
||||
throw new AgentModelValidationError(
|
||||
{ agentType, field, model: modelValue },
|
||||
{
|
||||
type: 'invalid_format',
|
||||
message: `Provider '${validation.provider.id}' is missing an API key`,
|
||||
code: 'provider_api_key_missing'
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async reinitialize(): Promise<void> {
|
||||
BaseService.isInitialized = false
|
||||
BaseService.initializationPromise = null
|
||||
|
||||
if (BaseService.client) {
|
||||
try {
|
||||
BaseService.client.close()
|
||||
} catch (error) {
|
||||
logger.warn('Failed to close client during reinitialize:', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
BaseService.client = null
|
||||
BaseService.db = null
|
||||
|
||||
await BaseService.initialize()
|
||||
}
|
||||
}
|
||||
81
src/main/services/agents/README.md
Normal file
81
src/main/services/agents/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Agents Service
|
||||
|
||||
Simplified Drizzle ORM implementation for agent and session management in Cherry Studio.
|
||||
|
||||
## Features
|
||||
|
||||
- **Native Drizzle migrations** - Uses built-in migrate() function
|
||||
- **Zero CLI dependencies** in production
|
||||
- **Auto-initialization** with retry logic
|
||||
- **Full TypeScript** type safety
|
||||
- **Model validation** to ensure models exist and provider configuration matches the agent type
|
||||
|
||||
## Schema
|
||||
|
||||
- `agents.schema.ts` - Agent definitions
|
||||
- `sessions.schema.ts` - Session and message tables
|
||||
- `migrations.schema.ts` - Migration tracking
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { agentService } from './services'
|
||||
|
||||
// Create agent - fully typed
|
||||
const agent = await agentService.createAgent({
|
||||
type: 'custom',
|
||||
name: 'My Agent',
|
||||
model: 'anthropic:claude-3-5-sonnet-20241022'
|
||||
})
|
||||
```
|
||||
|
||||
## Model Validation
|
||||
|
||||
- Model identifiers must use the `provider:model_id` format (for example `anthropic:claude-3-5-sonnet-20241022`).
|
||||
- `model`, `plan_model`, and `small_model` are validated against the configured providers before the database is touched.
|
||||
- Invalid configurations return a `400 invalid_request_error` response and the create/update operation is aborted.
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Apply schema changes
|
||||
yarn agents:generate
|
||||
|
||||
# Quick development sync
|
||||
yarn agents:push
|
||||
|
||||
# Database tools
|
||||
yarn agents:studio # Open Drizzle Studio
|
||||
yarn agents:health # Health check
|
||||
yarn agents:drop # Reset database
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Edit schema** in `/database/schema/`
|
||||
2. **Generate migration** with `yarn agents:generate`
|
||||
3. **Test changes** with `yarn agents:health`
|
||||
4. **Deploy** - migrations apply automatically
|
||||
|
||||
## Services
|
||||
|
||||
- `AgentService` - Agent CRUD operations
|
||||
- `SessionService` - Session management
|
||||
- `SessionMessageService` - Message logging
|
||||
- `BaseService` - Database utilities
|
||||
- `schemaSyncer` - Migration handler
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
yarn agents:health
|
||||
|
||||
# Apply migrations
|
||||
yarn agents:migrate
|
||||
|
||||
# Reset completely
|
||||
yarn agents:reset --yes
|
||||
```
|
||||
|
||||
The simplified migration system reduced complexity from 463 to ~30 lines while maintaining all functionality through Drizzle's native migration system.
|
||||
161
src/main/services/agents/database/MigrationService.ts
Normal file
161
src/main/services/agents/database/MigrationService.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { type Client } from '@libsql/client'
|
||||
import { loggerService } from '@logger'
|
||||
import { getResourcePath } from '@main/utils'
|
||||
import { type LibSQLDatabase } from 'drizzle-orm/libsql'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import * as schema from './schema'
|
||||
import { migrations, type NewMigration } from './schema/migrations.schema'
|
||||
|
||||
const logger = loggerService.withContext('MigrationService')
|
||||
|
||||
interface MigrationJournal {
|
||||
version: string
|
||||
dialect: string
|
||||
entries: Array<{
|
||||
idx: number
|
||||
version: string
|
||||
when: number
|
||||
tag: string
|
||||
breakpoints: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
export class MigrationService {
|
||||
private db: LibSQLDatabase<typeof schema>
|
||||
private client: Client
|
||||
private migrationDir: string
|
||||
|
||||
constructor(db: LibSQLDatabase<typeof schema>, client: Client) {
|
||||
this.db = db
|
||||
this.client = client
|
||||
this.migrationDir = path.join(getResourcePath(), 'database', 'drizzle')
|
||||
}
|
||||
|
||||
async runMigrations(): Promise<void> {
|
||||
try {
|
||||
logger.info('Starting migration check...')
|
||||
|
||||
const hasMigrationsTable = await this.migrationsTableExists()
|
||||
|
||||
if (!hasMigrationsTable) {
|
||||
logger.info('Migrations table not found; assuming fresh database state')
|
||||
}
|
||||
|
||||
// Read migration journal
|
||||
const journal = await this.readMigrationJournal()
|
||||
if (!journal.entries.length) {
|
||||
logger.info('No migrations found in journal')
|
||||
return
|
||||
}
|
||||
|
||||
// Get applied migrations
|
||||
const appliedMigrations = hasMigrationsTable ? await this.getAppliedMigrations() : []
|
||||
const appliedVersions = new Set(appliedMigrations.map((m) => Number(m.version)))
|
||||
|
||||
const latestAppliedVersion = appliedMigrations.reduce(
|
||||
(max, migration) => Math.max(max, Number(migration.version)),
|
||||
0
|
||||
)
|
||||
const latestJournalVersion = journal.entries.reduce((max, entry) => Math.max(max, entry.idx), 0)
|
||||
|
||||
logger.info(`Latest applied migration: v${latestAppliedVersion}, latest available: v${latestJournalVersion}`)
|
||||
|
||||
// Find pending migrations (compare journal idx with stored version, which is the same value)
|
||||
const pendingMigrations = journal.entries
|
||||
.filter((entry) => !appliedVersions.has(entry.idx))
|
||||
.sort((a, b) => a.idx - b.idx)
|
||||
|
||||
if (pendingMigrations.length === 0) {
|
||||
logger.info('Database is up to date')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Found ${pendingMigrations.length} pending migrations`)
|
||||
|
||||
// Execute pending migrations
|
||||
for (const migration of pendingMigrations) {
|
||||
await this.executeMigration(migration)
|
||||
}
|
||||
|
||||
logger.info('All migrations completed successfully')
|
||||
} catch (error) {
|
||||
logger.error('Migration failed:', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async migrationsTableExists(): Promise<boolean> {
|
||||
try {
|
||||
const table = await this.client.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'`)
|
||||
return table.rows.length > 0
|
||||
} catch (error) {
|
||||
logger.error('Failed to check migrations table status:', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async readMigrationJournal(): Promise<MigrationJournal> {
|
||||
const journalPath = path.join(this.migrationDir, 'meta', '_journal.json')
|
||||
|
||||
if (!fs.existsSync(journalPath)) {
|
||||
logger.warn('Migration journal not found:', { journalPath })
|
||||
return { version: '7', dialect: 'sqlite', entries: [] }
|
||||
}
|
||||
|
||||
try {
|
||||
const journalContent = fs.readFileSync(journalPath, 'utf-8')
|
||||
return JSON.parse(journalContent)
|
||||
} catch (error) {
|
||||
logger.error('Failed to read migration journal:', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async getAppliedMigrations(): Promise<schema.Migration[]> {
|
||||
try {
|
||||
return await this.db.select().from(migrations)
|
||||
} catch (error) {
|
||||
// This should not happen since we ensure the table exists in runMigrations()
|
||||
logger.error('Failed to query applied migrations:', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async executeMigration(migration: MigrationJournal['entries'][0]): Promise<void> {
|
||||
const sqlFilePath = path.join(this.migrationDir, `${migration.tag}.sql`)
|
||||
|
||||
if (!fs.existsSync(sqlFilePath)) {
|
||||
throw new Error(`Migration SQL file not found: ${sqlFilePath}`)
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`Executing migration ${migration.tag}...`)
|
||||
const startTime = Date.now()
|
||||
|
||||
// Read and execute SQL
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, 'utf-8')
|
||||
await this.client.executeMultiple(sqlContent)
|
||||
|
||||
// Record migration as applied (store journal idx as version for tracking)
|
||||
const newMigration: NewMigration = {
|
||||
version: migration.idx,
|
||||
tag: migration.tag,
|
||||
executedAt: Date.now()
|
||||
}
|
||||
|
||||
if (!(await this.migrationsTableExists())) {
|
||||
throw new Error('Migrations table missing after executing migration; cannot record progress')
|
||||
}
|
||||
|
||||
await this.db.insert(migrations).values(newMigration)
|
||||
|
||||
const executionTime = Date.now() - startTime
|
||||
logger.info(`Migration ${migration.tag} completed in ${executionTime}ms`)
|
||||
} catch (error) {
|
||||
logger.error(`Migration ${migration.tag} failed:`, { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/main/services/agents/database/index.ts
Normal file
14
src/main/services/agents/database/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Database Module
|
||||
*
|
||||
* This module provides centralized access to Drizzle ORM schemas
|
||||
* for type-safe database operations.
|
||||
*
|
||||
* Schema evolution is handled by Drizzle Kit migrations.
|
||||
*/
|
||||
|
||||
// Drizzle ORM schemas
|
||||
export * from './schema'
|
||||
|
||||
// Repository helpers
|
||||
export * from './sessionMessageRepository'
|
||||
35
src/main/services/agents/database/schema/agents.schema.ts
Normal file
35
src/main/services/agents/database/schema/agents.schema.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Drizzle ORM schema for agents table
|
||||
*/
|
||||
|
||||
import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
export const agentsTable = sqliteTable('agents', {
|
||||
id: text('id').primaryKey(),
|
||||
type: text('type').notNull(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
accessible_paths: text('accessible_paths'), // JSON array of directory paths the agent can access
|
||||
|
||||
instructions: text('instructions'),
|
||||
|
||||
model: text('model').notNull(), // Main model ID (required)
|
||||
plan_model: text('plan_model'), // Optional plan/thinking model ID
|
||||
small_model: text('small_model'), // Optional small/fast model ID
|
||||
|
||||
mcps: text('mcps'), // JSON array of MCP tool IDs
|
||||
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
|
||||
|
||||
configuration: text('configuration'), // JSON, extensible settings
|
||||
|
||||
created_at: text('created_at').notNull(),
|
||||
updated_at: text('updated_at').notNull()
|
||||
})
|
||||
|
||||
// Indexes for agents table
|
||||
export const agentsNameIdx = index('idx_agents_name').on(agentsTable.name)
|
||||
export const agentsTypeIdx = index('idx_agents_type').on(agentsTable.type)
|
||||
export const agentsCreatedAtIdx = index('idx_agents_created_at').on(agentsTable.created_at)
|
||||
|
||||
export type AgentRow = typeof agentsTable.$inferSelect
|
||||
export type InsertAgentRow = typeof agentsTable.$inferInsert
|
||||
8
src/main/services/agents/database/schema/index.ts
Normal file
8
src/main/services/agents/database/schema/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Drizzle ORM schema exports
|
||||
*/
|
||||
|
||||
export * from './agents.schema'
|
||||
export * from './messages.schema'
|
||||
export * from './migrations.schema'
|
||||
export * from './sessions.schema'
|
||||
30
src/main/services/agents/database/schema/messages.schema.ts
Normal file
30
src/main/services/agents/database/schema/messages.schema.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { foreignKey, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
import { sessionsTable } from './sessions.schema'
|
||||
|
||||
// session_messages table to log all messages, thoughts, actions, observations in a session
|
||||
export const sessionMessagesTable = sqliteTable('session_messages', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
session_id: text('session_id').notNull(),
|
||||
role: text('role').notNull(), // 'user', 'agent', 'system', 'tool'
|
||||
content: text('content').notNull(), // JSON structured data
|
||||
agent_session_id: text('agent_session_id').default(''),
|
||||
metadata: text('metadata'), // JSON metadata (optional)
|
||||
created_at: text('created_at').notNull(),
|
||||
updated_at: text('updated_at').notNull()
|
||||
})
|
||||
|
||||
// Indexes for session_messages table
|
||||
export const sessionMessagesSessionIdIdx = index('idx_session_messages_session_id').on(sessionMessagesTable.session_id)
|
||||
export const sessionMessagesCreatedAtIdx = index('idx_session_messages_created_at').on(sessionMessagesTable.created_at)
|
||||
export const sessionMessagesUpdatedAtIdx = index('idx_session_messages_updated_at').on(sessionMessagesTable.updated_at)
|
||||
|
||||
// Foreign keys for session_messages table
|
||||
export const sessionMessagesFkSession = foreignKey({
|
||||
columns: [sessionMessagesTable.session_id],
|
||||
foreignColumns: [sessionsTable.id],
|
||||
name: 'fk_session_messages_session_id'
|
||||
}).onDelete('cascade')
|
||||
|
||||
export type SessionMessageRow = typeof sessionMessagesTable.$inferSelect
|
||||
export type InsertSessionMessageRow = typeof sessionMessagesTable.$inferInsert
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Migration tracking schema
|
||||
*/
|
||||
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
export const migrations = sqliteTable('migrations', {
|
||||
version: integer('version').primaryKey(),
|
||||
tag: text('tag').notNull(),
|
||||
executedAt: integer('executed_at').notNull()
|
||||
})
|
||||
|
||||
export type Migration = typeof migrations.$inferSelect
|
||||
export type NewMigration = typeof migrations.$inferInsert
|
||||
45
src/main/services/agents/database/schema/sessions.schema.ts
Normal file
45
src/main/services/agents/database/schema/sessions.schema.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Drizzle ORM schema for sessions and session_logs tables
|
||||
*/
|
||||
|
||||
import { foreignKey, index, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
import { agentsTable } from './agents.schema'
|
||||
|
||||
export const sessionsTable = sqliteTable('sessions', {
|
||||
id: text('id').primaryKey(),
|
||||
agent_type: text('agent_type').notNull(),
|
||||
agent_id: text('agent_id').notNull(), // Primary agent ID for the session
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
accessible_paths: text('accessible_paths'), // JSON array of directory paths the agent can access
|
||||
|
||||
instructions: text('instructions'),
|
||||
|
||||
model: text('model').notNull(), // Main model ID (required)
|
||||
plan_model: text('plan_model'), // Optional plan/thinking model ID
|
||||
small_model: text('small_model'), // Optional small/fast model ID
|
||||
|
||||
mcps: text('mcps'), // JSON array of MCP tool IDs
|
||||
allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist)
|
||||
|
||||
configuration: text('configuration'), // JSON, extensible settings
|
||||
|
||||
created_at: text('created_at').notNull(),
|
||||
updated_at: text('updated_at').notNull()
|
||||
})
|
||||
|
||||
// Foreign keys for sessions table
|
||||
export const sessionsFkAgent = foreignKey({
|
||||
columns: [sessionsTable.agent_id],
|
||||
foreignColumns: [agentsTable.id],
|
||||
name: 'fk_session_agent_id'
|
||||
}).onDelete('cascade')
|
||||
|
||||
// Indexes for sessions table
|
||||
export const sessionsCreatedAtIdx = index('idx_sessions_created_at').on(sessionsTable.created_at)
|
||||
export const sessionsMainAgentIdIdx = index('idx_sessions_agent_id').on(sessionsTable.agent_id)
|
||||
export const sessionsModelIdx = index('idx_sessions_model').on(sessionsTable.model)
|
||||
|
||||
export type SessionRow = typeof sessionsTable.$inferSelect
|
||||
export type InsertSessionRow = typeof sessionsTable.$inferInsert
|
||||
257
src/main/services/agents/database/sessionMessageRepository.ts
Normal file
257
src/main/services/agents/database/sessionMessageRepository.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type {
|
||||
AgentMessageAssistantPersistPayload,
|
||||
AgentMessagePersistExchangePayload,
|
||||
AgentMessagePersistExchangeResult,
|
||||
AgentMessageUserPersistPayload,
|
||||
AgentPersistedMessage,
|
||||
AgentSessionMessageEntity
|
||||
} from '@types'
|
||||
import { and, asc, eq } from 'drizzle-orm'
|
||||
|
||||
import { BaseService } from '../BaseService'
|
||||
import type { InsertSessionMessageRow, SessionMessageRow } from './schema'
|
||||
import { sessionMessagesTable } from './schema'
|
||||
|
||||
const logger = loggerService.withContext('AgentMessageRepository')
|
||||
|
||||
type TxClient = any
|
||||
|
||||
export type PersistUserMessageParams = AgentMessageUserPersistPayload & {
|
||||
sessionId: string
|
||||
agentSessionId?: string
|
||||
tx?: TxClient
|
||||
}
|
||||
|
||||
export type PersistAssistantMessageParams = AgentMessageAssistantPersistPayload & {
|
||||
sessionId: string
|
||||
agentSessionId: string
|
||||
tx?: TxClient
|
||||
}
|
||||
|
||||
type PersistExchangeParams = AgentMessagePersistExchangePayload & {
|
||||
tx?: TxClient
|
||||
}
|
||||
|
||||
type PersistExchangeResult = AgentMessagePersistExchangeResult
|
||||
|
||||
class AgentMessageRepository extends BaseService {
|
||||
private static instance: AgentMessageRepository | null = null
|
||||
|
||||
static getInstance(): AgentMessageRepository {
|
||||
if (!AgentMessageRepository.instance) {
|
||||
AgentMessageRepository.instance = new AgentMessageRepository()
|
||||
}
|
||||
|
||||
return AgentMessageRepository.instance
|
||||
}
|
||||
|
||||
private serializeMessage(payload: AgentPersistedMessage): string {
|
||||
return JSON.stringify(payload)
|
||||
}
|
||||
|
||||
private serializeMetadata(metadata?: Record<string, unknown>): string | undefined {
|
||||
if (!metadata) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(metadata)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to serialize session message metadata', error as Error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private deserialize(row: any): AgentSessionMessageEntity {
|
||||
if (!row) return row
|
||||
|
||||
const deserialized = { ...row }
|
||||
|
||||
if (typeof deserialized.content === 'string') {
|
||||
try {
|
||||
deserialized.content = JSON.parse(deserialized.content)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse session message content JSON', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof deserialized.metadata === 'string') {
|
||||
try {
|
||||
deserialized.metadata = JSON.parse(deserialized.metadata)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse session message metadata JSON', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
return deserialized
|
||||
}
|
||||
|
||||
private getWriter(tx?: TxClient): TxClient {
|
||||
return tx ?? this.database
|
||||
}
|
||||
|
||||
private async findExistingMessageRow(
|
||||
writer: TxClient,
|
||||
sessionId: string,
|
||||
role: string,
|
||||
messageId: string
|
||||
): Promise<SessionMessageRow | null> {
|
||||
const candidateRows: SessionMessageRow[] = await writer
|
||||
.select()
|
||||
.from(sessionMessagesTable)
|
||||
.where(and(eq(sessionMessagesTable.session_id, sessionId), eq(sessionMessagesTable.role, role)))
|
||||
.orderBy(asc(sessionMessagesTable.created_at))
|
||||
|
||||
for (const row of candidateRows) {
|
||||
if (!row?.content) continue
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(row.content) as AgentPersistedMessage | undefined
|
||||
if (parsed?.message?.id === messageId) {
|
||||
return row
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse session message content JSON during lookup', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async upsertMessage(
|
||||
params: PersistUserMessageParams | PersistAssistantMessageParams
|
||||
): Promise<AgentSessionMessageEntity> {
|
||||
await AgentMessageRepository.initialize()
|
||||
this.ensureInitialized()
|
||||
|
||||
const { sessionId, agentSessionId = '', payload, metadata, createdAt, tx } = params
|
||||
|
||||
if (!payload?.message?.role) {
|
||||
throw new Error('Message payload missing role')
|
||||
}
|
||||
|
||||
if (!payload.message.id) {
|
||||
throw new Error('Message payload missing id')
|
||||
}
|
||||
|
||||
const writer = this.getWriter(tx)
|
||||
const now = createdAt ?? payload.message.createdAt ?? new Date().toISOString()
|
||||
const serializedPayload = this.serializeMessage(payload)
|
||||
const serializedMetadata = this.serializeMetadata(metadata)
|
||||
|
||||
const existingRow = await this.findExistingMessageRow(writer, sessionId, payload.message.role, payload.message.id)
|
||||
|
||||
if (existingRow) {
|
||||
const metadataToPersist = serializedMetadata ?? existingRow.metadata ?? undefined
|
||||
const agentSessionToPersist = agentSessionId || existingRow.agent_session_id || ''
|
||||
|
||||
await writer
|
||||
.update(sessionMessagesTable)
|
||||
.set({
|
||||
content: serializedPayload,
|
||||
metadata: metadataToPersist,
|
||||
agent_session_id: agentSessionToPersist,
|
||||
updated_at: now
|
||||
})
|
||||
.where(eq(sessionMessagesTable.id, existingRow.id))
|
||||
|
||||
return this.deserialize({
|
||||
...existingRow,
|
||||
content: serializedPayload,
|
||||
metadata: metadataToPersist,
|
||||
agent_session_id: agentSessionToPersist,
|
||||
updated_at: now
|
||||
})
|
||||
}
|
||||
|
||||
const insertData: InsertSessionMessageRow = {
|
||||
session_id: sessionId,
|
||||
role: payload.message.role,
|
||||
content: serializedPayload,
|
||||
agent_session_id: agentSessionId,
|
||||
metadata: serializedMetadata,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
}
|
||||
|
||||
const [saved] = await writer.insert(sessionMessagesTable).values(insertData).returning()
|
||||
|
||||
return this.deserialize(saved)
|
||||
}
|
||||
|
||||
async persistUserMessage(params: PersistUserMessageParams): Promise<AgentSessionMessageEntity> {
|
||||
return this.upsertMessage({ ...params, agentSessionId: params.agentSessionId ?? '' })
|
||||
}
|
||||
|
||||
async persistAssistantMessage(params: PersistAssistantMessageParams): Promise<AgentSessionMessageEntity> {
|
||||
return this.upsertMessage(params)
|
||||
}
|
||||
|
||||
async persistExchange(params: PersistExchangeParams): Promise<PersistExchangeResult> {
|
||||
await AgentMessageRepository.initialize()
|
||||
this.ensureInitialized()
|
||||
|
||||
const { sessionId, agentSessionId, user, assistant } = params
|
||||
|
||||
const result = await this.database.transaction(async (tx) => {
|
||||
const exchangeResult: PersistExchangeResult = {}
|
||||
|
||||
if (user?.payload) {
|
||||
exchangeResult.userMessage = await this.persistUserMessage({
|
||||
sessionId,
|
||||
agentSessionId,
|
||||
payload: user.payload,
|
||||
metadata: user.metadata,
|
||||
createdAt: user.createdAt,
|
||||
tx
|
||||
})
|
||||
}
|
||||
|
||||
if (assistant?.payload) {
|
||||
exchangeResult.assistantMessage = await this.persistAssistantMessage({
|
||||
sessionId,
|
||||
agentSessionId,
|
||||
payload: assistant.payload,
|
||||
metadata: assistant.metadata,
|
||||
createdAt: assistant.createdAt,
|
||||
tx
|
||||
})
|
||||
}
|
||||
|
||||
return exchangeResult
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async getSessionHistory(sessionId: string): Promise<AgentPersistedMessage[]> {
|
||||
await AgentMessageRepository.initialize()
|
||||
this.ensureInitialized()
|
||||
|
||||
try {
|
||||
const rows = await this.database
|
||||
.select()
|
||||
.from(sessionMessagesTable)
|
||||
.where(eq(sessionMessagesTable.session_id, sessionId))
|
||||
.orderBy(asc(sessionMessagesTable.created_at))
|
||||
|
||||
const messages: AgentPersistedMessage[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
const deserialized = this.deserialize(row)
|
||||
if (deserialized?.content) {
|
||||
messages.push(deserialized.content as AgentPersistedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Loaded ${messages.length} messages for session ${sessionId}`)
|
||||
return messages
|
||||
} catch (error) {
|
||||
logger.error('Failed to load session history', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const agentMessageRepository = AgentMessageRepository.getInstance()
|
||||
31
src/main/services/agents/drizzle.config.ts
Normal file
31
src/main/services/agents/drizzle.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Drizzle Kit configuration for agents database
|
||||
*/
|
||||
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
import { app } from 'electron'
|
||||
|
||||
function getDbPath() {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return path.join(os.homedir(), '.cherrystudio', 'data', 'agents.db')
|
||||
}
|
||||
return path.join(app.getPath('userData'), 'agents.db')
|
||||
}
|
||||
|
||||
const resolvedDbPath = getDbPath()
|
||||
|
||||
export const dbPath = resolvedDbPath
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'sqlite',
|
||||
schema: './src/main/services/agents/database/schema/index.ts',
|
||||
out: './resources/database/drizzle',
|
||||
dbCredentials: {
|
||||
url: `file:${resolvedDbPath}`
|
||||
},
|
||||
verbose: true,
|
||||
strict: true
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user