Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3be6ed35ba | ||
|
|
fe47bb29cb | ||
|
|
c7edd749a9 | ||
|
|
90a3dff557 | ||
|
|
fc6b5ad309 | ||
|
|
ffb268554e | ||
|
|
1d30b98280 | ||
|
|
dce09f8fdd | ||
|
|
ce11952daf | ||
|
|
2cb1314b6b | ||
|
|
a60c938e89 | ||
|
|
508d5692c5 | ||
|
|
05c6b427c6 | ||
|
|
a06a9ad20a | ||
|
|
006d44c4f4 | ||
|
|
0accd0e319 | ||
|
|
95d858ade8 | ||
|
|
6bb31046cc | ||
|
|
09cd8577e0 | ||
|
|
3205f4a017 | ||
|
|
d2840ae0b3 | ||
|
|
2468801050 | ||
|
|
cf6551516f | ||
|
|
736f88a00a | ||
|
|
c247abb78a | ||
|
|
f3f5b76d68 | ||
|
|
1b9fe1f539 | ||
|
|
2381d48a56 | ||
|
|
e248aa8c02 | ||
|
|
ee02e7eba5 | ||
|
|
dd2f96ba65 | ||
|
|
9024af7499 | ||
|
|
3c5c99fd6d | ||
|
|
321ad8ad4f | ||
|
|
a2f0f0d46a | ||
|
|
a6c010d25a | ||
|
|
67d135ab4d | ||
|
|
4af02c5f98 | ||
|
|
03d19c9821 | ||
|
|
2a80e1e931 | ||
|
|
ca79699492 | ||
|
|
268ceab408 | ||
|
|
cde1620710 | ||
|
|
2ddeab20a0 | ||
|
|
dcab4b7608 | ||
|
|
7b07b6649a | ||
|
|
d7f5a3445a | ||
|
|
787bc1f9f3 | ||
|
|
c6d8d87e64 | ||
|
|
e1c128e32f | ||
|
|
1a7d54b82f | ||
|
|
639da67916 | ||
|
|
2fdbb289ed | ||
|
|
8586310867 | ||
|
|
7b51599088 | ||
|
|
c7cef56cb2 | ||
|
|
cc6b3fbee1 | ||
|
|
379b69e0ec | ||
|
|
072390e0fc | ||
|
|
98e2e3f483 | ||
|
|
a9450b1f18 | ||
|
|
d4ab909144 | ||
|
|
93490d1634 | ||
|
|
71914044d7 | ||
|
|
0148446fab | ||
|
|
346d26bdf1 | ||
|
|
b1fdd777f7 | ||
|
|
9ecc68ba59 | ||
|
|
fd7bfcd9d3 | ||
|
|
0807b130e9 | ||
|
|
edbc473660 | ||
|
|
e08d7c7370 | ||
|
|
04f7158aa7 | ||
|
|
2d0b8e9648 | ||
|
|
6e2ec39052 | ||
|
|
a7a1487004 | ||
|
|
f98f6eac39 | ||
|
|
4299a83cc0 | ||
|
|
7ed28f1e56 | ||
|
|
a0e0cc66d8 | ||
|
|
7deb330c11 | ||
|
|
dfe6bf0709 | ||
|
|
ac17bacd91 | ||
|
|
a1a0cb815b | ||
|
|
af3ee66cd7 | ||
|
|
fe1d8a1e1f | ||
|
|
f0ff7ee49e | ||
|
|
4f58b4e8d1 | ||
|
|
7ab18f1e99 | ||
|
|
3f712aeb66 | ||
|
|
9f3ddec78f | ||
|
|
4bbdf8cb26 | ||
|
|
5bd947b958 | ||
|
|
2afe4c3e39 | ||
|
|
de24f0b2b9 | ||
|
|
e66473f06e | ||
|
|
95a7aeccb6 | ||
|
|
e8bb346eb9 | ||
|
|
dece612853 | ||
|
|
52f5d06766 | ||
|
|
a6416f6ef3 | ||
|
|
dbb562f550 | ||
|
|
e22bdae74d | ||
|
|
9a02c28bfe | ||
|
|
87a340e5b6 | ||
|
|
3fd9e79b29 | ||
|
|
24f764082d | ||
|
|
b22f3c45cc | ||
|
|
765068e272 | ||
|
|
8bea96614e | ||
|
|
0e08c31fb9 | ||
|
|
75d063a553 | ||
|
|
d4f8303053 | ||
|
|
6a3eecc07e | ||
|
|
be4d2f58c1 | ||
|
|
d626c9f02c | ||
|
|
513fec91fc | ||
|
|
9e19b8a8de | ||
|
|
513b644252 | ||
|
|
68982c36dc | ||
|
|
9390435cde | ||
|
|
eb9c4c4752 | ||
|
|
5e1a788af1 | ||
|
|
972c55bcb2 | ||
|
|
7070880b4e | ||
|
|
442ab73c48 | ||
|
|
baf0f27793 | ||
|
|
f421913703 | ||
|
|
41c79ac5c4 | ||
|
|
e58934c744 | ||
|
|
aaf15b6797 | ||
|
|
776f4e267d | ||
|
|
0ed89ebdeb | ||
|
|
78a9414e05 | ||
|
|
b8ecdc7922 | ||
|
|
3c9496ffa2 | ||
|
|
7ef7283fad | ||
|
|
b83cf28e29 | ||
|
|
16c22432e3 | ||
|
|
11442847b0 | ||
|
|
15112bccf1 | ||
|
|
8e7cb8f559 | ||
|
|
db6bf00d0a | ||
|
|
df77311516 | ||
|
|
fd58b85fe4 | ||
|
|
1d0b86d110 | ||
|
|
f11a056d17 | ||
|
|
d949a59ebb | ||
|
|
adfa805fe3 | ||
|
|
13850b85a8 | ||
|
|
6f1f02eddb | ||
|
|
cd0ce4d9b0 | ||
|
|
69f66997fd | ||
|
|
567c7f582b | ||
|
|
adf393c0a5 | ||
|
|
12f23138e0 | ||
|
|
c19ac2dae6 | ||
|
|
5805566d88 | ||
|
|
da91a0643f | ||
|
|
5b0d96b1b5 | ||
|
|
10776fed51 | ||
|
|
afa4586e3d | ||
|
|
bd2dd42ba4 | ||
|
|
0f77470fe0 | ||
|
|
3b93c6137d | ||
|
|
09cf6306af | ||
|
|
08181be4bf | ||
|
|
cac3ba4769 | ||
|
|
936501069c | ||
|
|
8e159a9773 | ||
|
|
0c5d3c8635 | ||
|
|
114dfdb211 | ||
|
|
9047e7fe39 | ||
|
|
446d7bf549 | ||
|
|
b34965b936 | ||
|
|
ee899d4dd5 | ||
|
|
888ce2f9bb | ||
|
|
f74cc39c33 | ||
|
|
e2a57e7a32 | ||
|
|
a6644d580c | ||
|
|
883366ce55 | ||
|
|
be2386661d | ||
|
|
12c2fe1f79 | ||
|
|
f334994ab6 | ||
|
|
6974853695 | ||
|
|
f516c52a72 | ||
|
|
8065c0d759 | ||
|
|
fd46d61543 | ||
|
|
7255515607 | ||
|
|
8a6a5715ff | ||
|
|
df2a7749b4 | ||
|
|
6091bf0c6f | ||
|
|
fd4c833511 | ||
|
|
1678114f1f | ||
|
|
97573e6f46 | ||
|
|
17971f642a | ||
|
|
a53b7a8593 | ||
|
|
08c583d1c6 | ||
|
|
928633e4b2 | ||
|
|
542aa89130 | ||
|
|
eb2a3bf824 | ||
|
|
274d47f35f | ||
|
|
b34cffe801 | ||
|
|
132edb3154 | ||
|
|
7b2ff1e73a | ||
|
|
f1db2e56df | ||
|
|
f14ad0c693 | ||
|
|
7ce39e29d0 | ||
|
|
09a78573f5 | ||
|
|
ef9d218f91 | ||
|
|
055fe882fc | ||
|
|
d25cfa5a2a | ||
|
|
5c1dd1270c | ||
|
|
bf4bc5d2ea | ||
|
|
d1a491ff3b | ||
|
|
db3a807141 | ||
|
|
116d7d3d23 | ||
|
|
8e6270e3a3 | ||
|
|
adc702d3f8 | ||
|
|
765fcb6c2e | ||
|
|
ae796482e8 | ||
|
|
63c7c83c51 | ||
|
|
86ca4625ad | ||
|
|
d1f4ee9aaf | ||
|
|
8d07a0b4eb | ||
|
|
faf5c9ce01 | ||
|
|
9d5e4b18eb | ||
|
|
2d753a6930 | ||
|
|
745220e62c | ||
|
|
0214388d10 | ||
|
|
9217fee211 | ||
|
|
7c2785efe2 | ||
|
|
3e61155b33 | ||
|
|
c79b1c5140 | ||
|
|
4abb8e205d | ||
|
|
c6319594c7 | ||
|
|
77d44c8cd0 | ||
|
|
5d015d42c9 | ||
|
|
f8a380d4fd | ||
|
|
2632478d96 | ||
|
|
c529abce3c | ||
|
|
11c41e7c4a | ||
|
|
3c2d22b948 | ||
|
|
5d0d4b7d27 | ||
|
|
184948b772 | ||
|
|
f49d69cf92 | ||
|
|
ec34b7e1c0 | ||
|
|
13b15f3255 | ||
|
|
593eaa8cf7 | ||
|
|
adcfb8f730 | ||
|
|
0e3e602121 | ||
|
|
47f106c8db | ||
|
|
04b4818f87 | ||
|
|
f418963585 | ||
|
|
9dbb3315da | ||
|
|
b1f72a202e | ||
|
|
aa674655a8 | ||
|
|
e7e560715e | ||
|
|
2c0146232a | ||
|
|
e4e84f2b43 | ||
|
|
753bde713f | ||
|
|
f6deb83fe0 | ||
|
|
c43c07487f | ||
|
|
7cadbd7403 | ||
|
|
82b8535b7c | ||
|
|
47bcb1b01a | ||
|
|
9e18ce26dc | ||
|
|
8e2a96167e | ||
|
|
315ce293df | ||
|
|
0591b68ff6 | ||
|
|
3d7832a750 | ||
|
|
fefd9b99cc | ||
|
|
a3c15c54af | ||
|
|
85480b16a6 | ||
|
|
4d6b8cd982 | ||
|
|
39f90149fb | ||
|
|
bdd2f3c2cd | ||
|
|
bd9e45f761 | ||
|
|
da8bba99a5 | ||
|
|
e3a2526932 | ||
|
|
d482f30188 | ||
|
|
b2465df4e0 | ||
|
|
bb0290f576 | ||
|
|
16fe369f5a | ||
|
|
d111db5454 | ||
|
|
273150a9ce | ||
|
|
39bc29d93d | ||
|
|
ee4437d053 | ||
|
|
222a144858 | ||
|
|
97560a4fba | ||
|
|
d0a9b5de08 | ||
|
|
d18c8438d3 | ||
|
|
74abb77a0b | ||
|
|
0551967a7e | ||
|
|
2e0be429d9 | ||
|
|
9f004c059d | ||
|
|
793bb3f1b8 | ||
|
|
fae778bb51 | ||
|
|
5179fb2fac | ||
|
|
7cb3982d89 | ||
|
|
0b54df85be | ||
|
|
3cc05be36b | ||
|
|
acb0f93d5f | ||
|
|
127a4e631c | ||
|
|
499f0119bb | ||
|
|
0da9bd15c9 | ||
|
|
9da32c07ce | ||
|
|
c80cc2d823 | ||
|
|
086599a5d6 | ||
|
|
ecb8ac9a80 | ||
|
|
d94e404f64 | ||
|
|
142de0c45b | ||
|
|
2b5cbff7be | ||
|
|
9e03a675ae | ||
|
|
946c239c03 | ||
|
|
6f1585e88f | ||
|
|
62ac73c773 | ||
|
|
3531146792 | ||
|
|
de8adf76a9 | ||
|
|
a737523f0a | ||
|
|
7bfd6e43e6 | ||
|
|
5e28902cb4 | ||
|
|
5a1d3a41da | ||
|
|
4b18ad683f | ||
|
|
cc283a96f3 | ||
|
|
2977be33c6 | ||
|
|
6628a3d987 | ||
|
|
1c561db0b9 | ||
|
|
813eb29692 | ||
|
|
520550a2b9 | ||
|
|
f4eec277c5 | ||
|
|
1e39032c67 | ||
|
|
25fe3e42fd | ||
|
|
b525684378 | ||
|
|
054674baa1 | ||
|
|
b4741a9a39 | ||
|
|
53a859058a | ||
|
|
f44c05931c | ||
|
|
34d945d425 | ||
|
|
8e829730a9 | ||
|
|
aae6d810cd | ||
|
|
14125ed57c | ||
|
|
555eeabf56 | ||
|
|
97abc43a40 | ||
|
|
4a5043a658 | ||
|
|
4e2b806917 | ||
|
|
5bc763d319 | ||
|
|
da40ba413b | ||
|
|
8d537e954b | ||
|
|
b0d4563ae8 | ||
|
|
88f32b208b | ||
|
|
629e57ad83 | ||
|
|
368d68baa5 | ||
|
|
1c38412e42 | ||
|
|
404ea1947b | ||
|
|
d922303645 | ||
|
|
86210296c1 | ||
|
|
106c6b04f1 | ||
|
|
72a85a0823 | ||
|
|
18d5aafed8 | ||
|
|
117028b5d9 | ||
|
|
f320d141a0 | ||
|
|
bc5d1eafaf | ||
|
|
6cec6d8014 | ||
|
|
473659ba6e | ||
|
|
7b704b5dbc | ||
|
|
5459ebbb59 | ||
|
|
f0dea09123 | ||
|
|
58e2f45793 | ||
|
|
844573387d | ||
|
|
08ac18be6e | ||
|
|
52151853b3 | ||
|
|
53c492b682 | ||
|
|
18ef86b69d | ||
|
|
d76842c549 | ||
|
|
fe7e4c0952 | ||
|
|
45679509ea | ||
|
|
226fa69cc3 | ||
|
|
b6f697d40f | ||
|
|
1d94384ee5 | ||
|
|
a5dbce396a | ||
|
|
38e2a460e3 | ||
|
|
a1c289dbd1 | ||
|
|
55a5eae4f6 | ||
|
|
04448dcee9 | ||
|
|
f6c5011eb0 | ||
|
|
ff258dd0a9 | ||
|
|
47ad98239f | ||
|
|
650c14dc1a | ||
|
|
551666a53b | ||
|
|
507e886322 | ||
|
|
1b60d2c773 | ||
|
|
85250c2694 | ||
|
|
b92d2bc97d | ||
|
|
ddce7ef549 | ||
|
|
274bbcb9e4 | ||
|
|
28f22c3973 | ||
|
|
f9a0aa71fa | ||
|
|
651e0d2bc8 | ||
|
|
5643bfb5f0 | ||
|
|
515b0cd985 | ||
|
|
60f40e3c5e | ||
|
|
eb8834b9df | ||
|
|
b4bdaf11da | ||
|
|
47e9a55feb | ||
|
|
66dff812fb | ||
|
|
4b4dc51336 | ||
|
|
6f6d9f6570 | ||
|
|
fd5150ffad | ||
|
|
ba6ab901f8 | ||
|
|
8794c85cfe | ||
|
|
87d557260c | ||
|
|
a9f0e9628f | ||
|
|
122b6c431c | ||
|
|
fdc24e4dd0 | ||
|
|
85f17db487 | ||
|
|
05800a4f48 | ||
|
|
4ebb421fd5 | ||
|
|
34452e2d4c | ||
|
|
70ed09ad2b | ||
|
|
34abe90bb2 | ||
|
|
00038cd1ae | ||
|
|
d896355bad | ||
|
|
065a5ee4bf | ||
|
|
3e6be97ec9 | ||
|
|
806f1e9767 | ||
|
|
5fdc2c60b2 | ||
|
|
523ce6133e | ||
|
|
da00664a00 | ||
|
|
2809a54b29 | ||
|
|
68a936eb2c | ||
|
|
bbde5399cf | ||
|
|
afa288f240 | ||
|
|
75a0e86820 | ||
|
|
0839edd134 | ||
|
|
d8a32313a8 | ||
|
|
80ced3081d | ||
|
|
ab84bc686c | ||
|
|
cc450a87ab | ||
|
|
7977820500 | ||
|
|
5a9736b7f0 | ||
|
|
d5f1d03a59 | ||
|
|
90bfd2324a | ||
|
|
0100bf10af | ||
|
|
908c1a52ed | ||
|
|
081aa12657 | ||
|
|
44bda94a36 | ||
|
|
f16b5b7214 | ||
|
|
b380e395c0 | ||
|
|
6fe93e9cb4 | ||
|
|
3f66368689 | ||
|
|
fd7bf241d0 | ||
|
|
2a98cc44e5 | ||
|
|
029cd91c9a | ||
|
|
3942fe2056 | ||
|
|
f52975fd95 | ||
|
|
9f10e55d5e | ||
|
|
03069dbd89 | ||
|
|
1520a85a9e | ||
|
|
33f2df4270 | ||
|
|
006b915533 | ||
|
|
5dc44ba637 | ||
|
|
f0e5d25636 | ||
|
|
389fd09765 | ||
|
|
2b387f2dcf | ||
|
|
378799f8ea | ||
|
|
7d444d065e | ||
|
|
14f1009ddc | ||
|
|
5eac3c1027 | ||
|
|
61d07a16a6 | ||
|
|
7ab47af8e8 | ||
|
|
fd9f172588 | ||
|
|
2156bc6fc6 | ||
|
|
959cf6a5a6 | ||
|
|
cccbecb974 | ||
|
|
2272f255f2 | ||
|
|
524944dfc8 | ||
|
|
c6af92e612 | ||
|
|
ffd08bcd21 | ||
|
|
19d4e65133 | ||
|
|
ed75d103f5 | ||
|
|
313f8a28f0 | ||
|
|
3f8407a853 | ||
|
|
ffe1f3c4c0 | ||
|
|
0ab90af250 | ||
|
|
dcf65d72c7 | ||
|
|
9219042a8f | ||
|
|
5c3193f326 | ||
|
|
d010ab1995 | ||
|
|
066a2995e4 | ||
|
|
82991c4a69 | ||
|
|
4ba1dcc7a2 | ||
|
|
2655d79acb | ||
|
|
f27c067b1d | ||
|
|
75ddf8fd1d | ||
|
|
392747798a | ||
|
|
df3b347653 | ||
|
|
998382aebe | ||
|
|
66989fc2e0 | ||
|
|
1d07183d09 | ||
|
|
b271b48489 | ||
|
|
66e3ccbf0d | ||
|
|
bb2d595fb6 | ||
|
|
75f8ef2564 | ||
|
|
a3a3b55f1e | ||
|
|
3a265e0f52 | ||
|
|
cf1b122107 | ||
|
|
e30ee43298 | ||
|
|
06f7f630e4 | ||
|
|
63d9bea9b2 | ||
|
|
c9a0b61bf2 | ||
|
|
afb0b57a06 | ||
|
|
f0ce7215ff | ||
|
|
d417cecc45 |
No files matched your search
@@ -0,0 +1,44 @@
|
||||
# Contributing to GenericAgent
|
||||
|
||||
## Why This File Is Short
|
||||
|
||||
GenericAgent's core is ~3K lines. Every file in this repo will be read by AI agents — potentially thousands of times. Extra words cost real tokens and push useful context out of the window, increasing hallucinations. This document practices what it preaches: **say only what matters.**
|
||||
|
||||
## Before You Contribute
|
||||
|
||||
1. **Read the codebase first.** It's small enough to read in one sitting. Understand the philosophy before proposing changes.
|
||||
2. **Open an Issue first** for anything non-trivial. Discuss before coding.
|
||||
|
||||
## Code Standards
|
||||
|
||||
All PRs go through a strict automated code review skill. Key expectations:
|
||||
|
||||
- **Self-documenting code, minimal comments.** If code needs a paragraph to explain, rewrite it.
|
||||
- **Compact and visually uniform.** Fewer lines, consistent line lengths, no fluff.
|
||||
- **Small change radius.** Changing A shouldn't ripple through B, C, D.
|
||||
- **More features → less code.** Good abstractions make the codebase shrink, not grow.
|
||||
- **Let it crash by failure radius.** Critical errors fail loud; trivial ones pass silently. No blanket try-catch.
|
||||
|
||||
> ⚠️ This review is deliberately strict — most AI-generated code (e.g. Claude Code output) will not pass as-is. Read the full principles before submitting.
|
||||
|
||||
## Skill Contributions
|
||||
|
||||
GenericAgent evolves through skills. Not all skills belong in the core repo:
|
||||
|
||||
| Type | Where it goes | Example |
|
||||
|---|---|---|
|
||||
| **Fundamental / universal** | Core repo (`memory/`) | File search, clipboard, basic web ops |
|
||||
| **Domain-specific / niche** | Skill Marketplace *(coming soon)* | Stock screening, food delivery, specific API integrations |
|
||||
|
||||
If your skill only makes sense for a specific workflow, it's a marketplace candidate, not a core PR.
|
||||
|
||||
## PR Checklist
|
||||
|
||||
- [ ] Issue linked or context explained in ≤3 sentences
|
||||
- [ ] Code passes the [review principles] self-check:
|
||||
1. Can I safely modify this locally without reading the whole codebase?
|
||||
2. Is there a clear core abstraction — new features add implementations, not modify old logic?
|
||||
3. Are change points converging at boundaries, not scattered everywhere?
|
||||
4. On failure, can I quickly locate the responsible module?
|
||||
- [ ] Net line count: ideally negative or zero for refactors
|
||||
- [ ] No unnecessary dependencies added
|
||||
@@ -0,0 +1,271 @@
|
||||
# 🚀 新手上手指南
|
||||
|
||||
> 完全没接触过编程也没关系,跟着做就行。Mac / Windows 都适用。
|
||||
>
|
||||
> 如果你已经有 Python 环境,直接跳到[第 2 步](#2-配置-api-key)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 安装 Python
|
||||
|
||||
### Mac
|
||||
|
||||
打开「终端」(启动台搜索 "终端" 或 "Terminal"),粘贴这行命令然后回车:
|
||||
|
||||
```bash
|
||||
brew install python
|
||||
```
|
||||
|
||||
如果提示 `brew: command not found`,说明还没装 Homebrew,先粘贴这行:
|
||||
|
||||
```bash
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
```
|
||||
|
||||
装完后再执行 `brew install python`。
|
||||
|
||||
### Windows
|
||||
|
||||
1. 打开 [python.org/downloads](https://www.python.org/downloads/),点黄色大按钮下载
|
||||
2. 运行安装包,**底部的 "Add Python to PATH" 一定要勾上**
|
||||
3. 点 "Install Now"
|
||||
|
||||
### 验证
|
||||
|
||||
终端 / 命令提示符里输入:
|
||||
|
||||
```bash
|
||||
python3 --version
|
||||
```
|
||||
|
||||
看到 `Python 3.x.x` 就 OK。Windows 上也可以试 `python --version`。
|
||||
|
||||
> ⚠️ **版本提示**:推荐 **Python 3.11 或 3.12**。不要使用 3.14(与 pywebview 等依赖不兼容)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 配置 API Key
|
||||
|
||||
### 下载项目
|
||||
|
||||
1. 打开 [GitHub 仓库页面](https://github.com/lsdefine/GenericAgent)
|
||||
2. 点绿色 **Code** 按钮 → **Download ZIP**
|
||||
3. 解压到你喜欢的位置
|
||||
|
||||
### 创建配置文件
|
||||
|
||||
进入项目文件夹,把 `mykey_template.py` 复制一份,重命名为 `mykey.py`。
|
||||
|
||||
用任意文本编辑器打开 `mykey.py`,填入你的 API 信息。**选一种填就行**,不用的配置删掉或留着不管都行。
|
||||
|
||||
> 💡 也可以运行交互式向导 `python assets/configure_mykey.py`,按提示选择厂商、填入 Key 即可自动生成 `mykey.py`。
|
||||
|
||||
### 配置示例
|
||||
|
||||
**最常见的用法:**
|
||||
|
||||
```python
|
||||
# 变量名含 'oai' → 走 OpenAI 兼容格式 (/chat/completions)
|
||||
oai_config = {
|
||||
'apikey': 'sk-你的密钥',
|
||||
'apibase': 'http://你的API地址:端口',
|
||||
'model': '模型名称',
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# 变量名含 'claude'(不含 'native')→ 走 Claude 兼容格式 (/messages)
|
||||
claude_config = {
|
||||
'apikey': 'sk-你的密钥',
|
||||
'apibase': 'http://你的API地址:端口',
|
||||
'model': 'claude-sonnet-4-20250514',
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# MiniMax 使用 OpenAI 兼容格式,变量名含 'oai' 即可
|
||||
# 温度自动修正为 (0, 1],支持 M2.7 / M2.5 全系列,204K 上下文
|
||||
oai_minimax_config = {
|
||||
'apikey': 'eyJh...',
|
||||
'apibase': 'https://api.minimax.io/v1',
|
||||
'model': 'MiniMax-M2.7',
|
||||
}
|
||||
```
|
||||
|
||||
**使用标准工具调用格式(适合较弱模型):**
|
||||
|
||||
```python
|
||||
# 变量名同时含 'native' 和 'claude' → Claude 标准工具调用格式
|
||||
native_claude_config = {
|
||||
'apikey': 'sk-ant-你的密钥',
|
||||
'apibase': 'https://api.anthropic.com',
|
||||
'model': 'claude-sonnet-4-20250514',
|
||||
}
|
||||
```
|
||||
|
||||
> 💡 还支持 `native_oai_config`(OpenAI 标准工具调用)、`sider_cookie`(Sider)等,详见 `mykey_template.py` 中的注释。
|
||||
|
||||
### 关键规则
|
||||
|
||||
**变量命名决定接口格式**(不是模型名决定的):
|
||||
|
||||
| 变量名包含 | 触发的 Session | 适用场景 |
|
||||
|-----------|---------------|---------|
|
||||
| `oai` | OpenAI 兼容 | 大多数 API 服务、OpenAI 官方 |
|
||||
| `claude`(不含 `native`) | Claude 兼容 | Claude API 服务 |
|
||||
| `native` + `claude` | Claude 标准工具调用 | 较弱模型推荐,工具调用更规范 |
|
||||
| `native` + `oai` | OpenAI 标准工具调用 | 较弱模型推荐,工具调用更规范 |
|
||||
|
||||
> 例:用 Claude 模型,但 API 服务提供的是 OpenAI 兼容接口 → 变量名用 `oai_xxx`。
|
||||
> 例:用 MiniMax 模型 → 变量名用 `oai_minimax_config`,MiniMax 走 OpenAI 兼容接口。
|
||||
|
||||
**`apibase` 填写规则**(会自动拼接端点路径):
|
||||
|
||||
| 你填的内容 | 系统行为 |
|
||||
|-----------|---------|
|
||||
| `http://host:2001` | 自动补 `/v1/chat/completions` |
|
||||
| `http://host:2001/v1` | 自动补 `/chat/completions` |
|
||||
| `http://host:2001/v1/chat/completions` | 直接使用,不拼接 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 初次启动
|
||||
|
||||
终端里进入项目文件夹,运行:
|
||||
|
||||
```bash
|
||||
cd 你的解压路径
|
||||
python3 agentmain.py
|
||||
```
|
||||
|
||||
这就是**命令行模式**,已经可以用了。你会看到一个输入提示符,直接打字发送任务即可。
|
||||
|
||||
试试你的第一个任务:
|
||||
|
||||
```
|
||||
帮我在桌面创建一个 hello.txt,内容是 Hello World
|
||||
```
|
||||
|
||||
> 💡 Windows 上如果 `python3` 不识别,换成 `python agentmain.py`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 让 Agent 自己装依赖
|
||||
|
||||
Agent 启动后,只需要一句话,它就会自己搞定所有依赖:
|
||||
|
||||
```
|
||||
请查看你的代码,安装所有用得上的 python 依赖
|
||||
```
|
||||
|
||||
Agent 会自己读代码、找出需要的包、全部装好。
|
||||
|
||||
> ⚠️ 如果遇到网络问题导致 Agent 无法调用 API,可能需要先手动装一个包:
|
||||
> ```bash
|
||||
> pip install requests
|
||||
> ```
|
||||
|
||||
### 升级到图形界面
|
||||
|
||||
依赖装完后,就可以用 GUI 模式了:
|
||||
|
||||
```bash
|
||||
python3 launch.pyw
|
||||
```
|
||||
|
||||
启动后会出现一个桌面悬浮窗,直接在里面输入任务指令。
|
||||
|
||||
### 可选:让 Agent 帮你做的事
|
||||
|
||||
```
|
||||
请帮我建立 git 连接,方便以后更新代码
|
||||
```
|
||||
|
||||
Agent 会自动配好。如果你电脑上没有 Git,它也会帮你下载 portable 版。
|
||||
|
||||
```
|
||||
请帮我在桌面创建一个 launch.pyw 的快捷方式
|
||||
```
|
||||
|
||||
这样以后双击桌面图标就能启动,不用再开终端了。
|
||||
|
||||
---
|
||||
|
||||
## 5. 能力解锁
|
||||
|
||||
环境跑起来之后,你可以逐步解锁更多能力。每一项都只需要**对 Agent 说一句话**:
|
||||
|
||||
### 基础能力
|
||||
|
||||
| 能力 | 对 Agent 说 | 说明 |
|
||||
|------|-----------|------|
|
||||
| **PowerShell 脚本执行** | `帮我解锁当前用户的 PowerShell ps1 执行权限` | Windows 默认禁止运行 .ps1 脚本 |
|
||||
| **全局文件搜索** | `安装并配置 Everything 命令行工具进 PATH` | 毫秒级全盘文件搜索 |
|
||||
|
||||
### 浏览器自动化
|
||||
|
||||
| 能力 | 对 Agent 说 | 说明 |
|
||||
|------|-----------|------|
|
||||
| **Web 工具解锁** | `执行 web setup sop,解锁 web 工具` | 注入浏览器插件,使 Agent 能直接操控网页 |
|
||||
|
||||
解锁后,Agent 可以在**保留你登录态**的真实浏览器中操作:
|
||||
|
||||
```
|
||||
打开淘宝,搜索 iPhone 16,按价格排序
|
||||
去 B 站,查看我最近看过的历史视频
|
||||
```
|
||||
|
||||
### 进阶能力
|
||||
|
||||
| 能力 | 对 Agent 说 | 说明 |
|
||||
|------|-----------|------|
|
||||
| **OCR** | `用rapidocr配置你的ocr能力并存入记忆` | 让 Agent 能"看到"屏幕文字 |
|
||||
| **屏幕视觉** | `仿造你的llmcore,写个调用vision的能力并存入记忆` | 让 Agent 能"看到"屏幕内容 |
|
||||
| **移动端控制** | `配置 ADB 环境,准备连接安卓设备` | 通过 USB/WiFi 控制 Android 手机 |
|
||||
|
||||
### 聊天平台接入(可选)
|
||||
|
||||
接入后可以随时随地通过手机给电脑上的 Agent 发指令。
|
||||
|
||||
对 Agent 说:`看你的代码,帮我配置 XX 平台的机器人接入`
|
||||
|
||||
支持的平台:**微信个人Bot** / QQ / 飞书 / 企业微信 / 钉钉 / Telegram
|
||||
|
||||
> Agent 会自动读取代码、引导你完成配置。
|
||||
|
||||
### 高级模式
|
||||
|
||||
以下模式全部**自文档化**——不用查手册,直接问 Agent 即可:
|
||||
|
||||
| 模式 | 对 Agent 说 |
|
||||
|------|------------|
|
||||
| **Reflect(反射)** | `查看你的代码,告诉我你的 reflect 模式怎么启用` |
|
||||
| **计划任务** | `查看你的代码,告诉我你的计划任务模式怎么启用` |
|
||||
| **Plan(规划)** | `查看你的代码,告诉我你的 plan 模式怎么启用` |
|
||||
| **SubAgent(子代理)** | `查看你的代码,告诉我你的 subagent 模式怎么启用` |
|
||||
| **自主探索** | `查看你的代码,告诉我你的自主探索模式怎么启用` |
|
||||
|
||||
> 💡 这就是 GenericAgent 的核心设计理念:**代码即文档**。Agent 能读懂自己的源码,所以任何功能你都可以直接问它。
|
||||
|
||||
---
|
||||
|
||||
## 💡 使用越久越强
|
||||
|
||||
GenericAgent 不预设技能,而是**靠使用进化**。每完成一个新任务,它会自动将执行路径固化为 Skill,下次遇到类似任务直接调用。
|
||||
|
||||
你不需要管理这些 Skill,Agent 会自动处理。使用时间越长,积累的技能越多,最终形成一棵完全属于你的专属技能树。
|
||||
|
||||
> 💡 如果你觉得某些重要信息 Agent 没有记住,可以直接告诉它:`把这个记到你的记忆里`,它会主动记忆。
|
||||
|
||||
**其他 Claw 的 Skill 也可以直接复用:**
|
||||
|
||||
- 让 Agent 搜索:`帮我找个做 XXX 的 skill` → 完成后 → `加入你的记忆中`
|
||||
- 直接指定来源:`访问 XXX 文件夹/URL,按照这个 skill 做 XXX`
|
||||
|
||||
**保持更新:**
|
||||
|
||||
对 Agent 说:`git 更新你的代码,然后看看 commit 有什么新功能`
|
||||
|
||||
> Agent 会自动 pull 最新代码并解读 commit log,告诉你新增了什么能力。
|
||||
|
||||
> 更多细节请参阅 [README.md](README.md) 或 [详细版图文教程](https://my.feishu.cn/wiki/CGrDw0T76iNFuskmwxdcWrpinPb)。
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 lsdefine
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,281 +1,634 @@
|
||||
# GenericAgent — 3,300 Lines to Full OS Autonomy
|
||||
<div align="center">
|
||||
<img src="assets/images/bar.jpg" width="880"/>
|
||||
|
||||
[English](#english) | [中文](#chinese)
|
||||
<a href="https://trendshift.io/repositories/25944" target="_blank"><img src="https://trendshift.io/api/badge/repositories/25944" alt="lsdefine%2FGenericAgent | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="#english">English</a> | <a href="#chinese">中文</a> | 📄 Technical Report: <a href="https://arxiv.org/abs/2604.17091"><img src="https://img.shields.io/badge/arXiv-2604.17091-b31b1b?logo=arxiv&logoColor=white" alt="arXiv" height="18"/></a> <a href="assets/GenericAgent_Technical_Report.pdf"><img src="https://img.shields.io/badge/-PDF-EA4335?logo=adobeacrobatreader&logoColor=white" alt="Technical Report PDF" height="18"/></a> <a href="https://github.com/JinyiHan99/GA-Technical-Report"><img src="https://img.shields.io/badge/-Code%20%26%20Data-181717?logo=github&logoColor=white" alt="Experiments & Reproduction Repo" height="18"/></a> | 📘 <a href="https://datawhalechina.github.io/hello-generic-agent/">教程</a> | <a href="https://fudankw.cn/sophub">Sophub</a>
|
||||
</p>
|
||||
|
||||
> 📌 **Official channel**: This GitHub repository is the sole official source for GenericAgent. We have no affiliation with any third-party website using the GenericAgent name.
|
||||
|
||||
---
|
||||
<a name="english"></a>
|
||||
## 🌟 Overview
|
||||
|
||||
A minimalist autonomous agent framework that gives any LLM physical-level control over your PC — browser, terminal, file system, keyboard, mouse, screen vision, and mobile devices — in ~3,300 lines of Python.
|
||||
**GenericAgent** is a minimal, self-evolving autonomous agent framework. Its core is just **~3K lines of code**. Through **9 atomic tools + a ~100-line Agent Loop**, it grants any LLM system-level control over a local computer — covering browser, terminal, filesystem, keyboard/mouse input, screen vision, and mobile devices (ADB).
|
||||
|
||||
No Electron. No Docker. No Mac Mini. No 500K-line codebase. No paid installation service.
|
||||
Its design philosophy: **don't preload skills — evolve them.**
|
||||
|
||||
## See It in Action
|
||||
Every time GenericAgent solves a new task, it automatically crystallizes the execution path into an skill for direct reuse later. The longer you use it, the more skills accumulate — forming a skill tree that belongs entirely to you, grown from 3K lines of seed code.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="45%" align="center"><img src="assets/demo/order_tea.gif" width="100%"><br><em>"Order me a milk tea" — navigates a delivery app, picks items, and checks out.</em></td>
|
||||
<td width="55%" align="center"><img src="assets/demo/selectstock.gif" width="100%"><br><em>"Find GEM stocks with EXPMA golden cross, turnover > 5%" — quantitative screening via mootdx.</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
> **🤖 Self-Bootstrap Proof** — Everything in this repository, from installing Git and running `git init` to every commit message, was completed autonomously by GenericAgent. The author never opened a terminal once.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%"><img src="assets/demo/autonomous_explore.png" width="100%"><br><em>Autonomous web exploration — browses and summarizes on its own schedule.</em></td>
|
||||
<td width="34%"><img src="assets/demo/alipay_expense.png" width="100%"><br><em>"Find expenses over ¥2K in the past 3 months" — drives Alipay on a phone via ADB.</em></td>
|
||||
<td width="33%"><img src="assets/demo/wechat_batch.png" width="100%"><br><em>WeChat batch messaging — yes, it can drive WeChat too.</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
## 📋 Core Features
|
||||
- **Self-Evolving**: Automatically crystallizes each task into an skill. Capabilities grow with every use, forming your personal skill tree.
|
||||
- **Minimal Architecture**: ~3K lines of core code. Agent Loop is ~100 lines. No complex dependencies, zero deployment overhead.
|
||||
- **Strong Execution**: Injects into a real browser (preserving login sessions). 9 atomic tools take direct control of the system.
|
||||
- **High Compatibility**: Supports Claude / Gemini / Kimi / MiniMax and other major models. Cross-platform.
|
||||
- **Token Efficient**: <30K context window — a fraction of the 200K–1M other agents consume. Layered memory ensures the right knowledge is always in scope. Less noise, fewer hallucinations, higher success rate — at a fraction of the cost.
|
||||
|
||||
## What Happens When You Use It
|
||||
|
||||
## 🧬 Self-Evolution Mechanism
|
||||
|
||||
This is what fundamentally distinguishes GenericAgent from every other agent framework.
|
||||
|
||||
```
|
||||
You: "Read my WeChat messages"
|
||||
Agent: installs dependencies → reverse-engineers DB → writes reader script → saves as SOP
|
||||
Next time: instant recall, zero setup.
|
||||
|
||||
You: "Monitor stock prices and alert me"
|
||||
Agent: installs mootdx → builds screening workflow → sets up scheduled task → saves as SOP
|
||||
Next time: one sentence to run.
|
||||
|
||||
You: "Send this file via Gmail"
|
||||
Agent: configures OAuth → writes send script → saves as SOP
|
||||
Next time: just works.
|
||||
[New Task] --> [Autonomous Exploration] (install deps, write scripts, debug & verify) -->
|
||||
[Crystallize Execution Path into skill] --> [Write to Memory Layer] --> [Direct Recall on Next Similar Task]
|
||||
```
|
||||
|
||||
**Dogfooding**: This repository — from installing Git to `git init`, writing this README, to every commit message — was built entirely by GenericAgent without the author opening a terminal once.
|
||||
| What you say | What the agent does the first time | Every time after |
|
||||
|---|---|---|
|
||||
| *"Read my WeChat messages"* | Install deps → reverse DB → write read script → save skill | **one-line invoke** |
|
||||
| *"Monitor stocks and alert me"* | Install mootdx → build selection flow → configure cron → save skill | **one-line start** |
|
||||
| *"Send this file via Gmail"* | Configure OAuth → write send script → save skill | **ready to use** |
|
||||
|
||||
Every task the agent solves becomes a permanent skill. After a few weeks, your instance has a unique skill tree — grown entirely from 3,300 lines of seed code.
|
||||
After a few weeks, your agent instance will have a skill tree no one else in the world has — all grown from 3K lines of seed code.
|
||||
|
||||
## The Seed Philosophy
|
||||
|
||||
Most agent frameworks ship as finished products. GenericAgent ships as a **seed**.
|
||||
##### 🎯 Demo Showcase
|
||||
|
||||
The 5 core SOPs define how the agent thinks, remembers, and operates. From there, every new capability is discovered and recorded by the agent itself:
|
||||
| 🧋 Food Delivery Order | 📈 Quantitative Stock Screening |
|
||||
|:---:|:---:|
|
||||
| <img src="assets/demo/order_tea.gif" width="100%" alt="Order Tea"> | <img src="assets/demo/selectstock.gif" width="100%" alt="Stock Selection"> |
|
||||
| *"Order me a milk tea"* — Navigates the delivery app, selects items, and completes checkout automatically. | *"Find GEM stocks with EXPMA golden cross, turnover > 5%"* — Screens stocks with quantitative conditions. |
|
||||
| 🌐 Autonomous Web Exploration | 💰 Expense Tracking | 💬 Batch Messaging |
|
||||
| <img src="assets/demo/autonomous_explore.png" width="100%" alt="Web Exploration"> | <img src="assets/demo/alipay_expense.png" width="100%" alt="Alipay Expense"> | <img src="assets/demo/wechat_batch.png" width="100%" alt="WeChat Batch"> |
|
||||
| Autonomously browses and periodically summarizes web content. | *"Find expenses over ¥2K in the last 3 months"* — Drives Alipay via ADB. | Sends bulk WeChat messages, fully driving the WeChat client. |
|
||||
|
||||
1. You ask it to do something new
|
||||
2. It figures out how (install dependencies, write scripts, test)
|
||||
3. It saves the procedure as a new SOP in its memory
|
||||
4. Next time, it recalls and executes directly
|
||||
## 📅 Latest News
|
||||
|
||||
The agent doesn't just execute — it **learns and remembers**.
|
||||
- **2026-04-21:** 📄 [Technical Report released on arXiv](https://arxiv.org/abs/2604.17091) — *GenericAgent: A Token-Efficient Self-Evolving LLM Agent via Contextual Information Density Maximization*
|
||||
- **2026-04-11:** Introduced **L4 session archive memory** and scheduler cron integration
|
||||
- **2026-03-23:** Support personal WeChat as a bot frontend
|
||||
- **2026-03-10:** [Released million-scale Skill Library](https://mp.weixin.qq.com/s/q2gQ7YvWoiAcwxzaiwpuiQ?scene=1&click_id=7)
|
||||
- **2026-03-08:** [Released "Dintal Claw" — a GenericAgent-powered government affairs bot](https://mp.weixin.qq.com/s/eiEhwo-j6S-WpLxgBnNxBg)
|
||||
- **2026-03-01:** [GenericAgent featured by Jiqizhixin (机器之心)](https://mp.weixin.qq.com/s/uVWpTTF5I1yzAENV_qm7yg)
|
||||
- **2026-01-16:** GenericAgent V1.0 public release
|
||||
|
||||
## Quick Start
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
#### Method 1: Standard Installation
|
||||
|
||||
```bash
|
||||
# 1. Clone
|
||||
git clone https://github.com/lsdefine/pc-agent-loop.git
|
||||
cd pc-agent-loop
|
||||
# 1. Clone the repo
|
||||
git clone https://github.com/lsdefine/GenericAgent.git
|
||||
cd GenericAgent
|
||||
|
||||
# 2. Install minimal deps
|
||||
pip install streamlit pywebview
|
||||
# 2. Install dependencies
|
||||
pip install requests streamlit pywebview # Desktop GUI (launch.pyw)
|
||||
pip install requests textual # Terminal UI (tuiapp.py)
|
||||
|
||||
# 3. Configure API key
|
||||
# 3. Configure API Key
|
||||
cp mykey_template.py mykey.py
|
||||
# Edit mykey.py with your LLM API key
|
||||
# Edit mykey.py and fill in your LLM API Key
|
||||
|
||||
# 4. Launch
|
||||
python launch.pyw
|
||||
```
|
||||
|
||||
**Also runs on Android** — tested successfully on Termux with `python agentmain.py` (CLI frontend):
|
||||
#### Method 2: uv (for experienced Python users)
|
||||
|
||||
If you prefer a modern Python workflow, GenericAgent also provides a minimal `pyproject.toml`:
|
||||
|
||||
```bash
|
||||
# In Termux
|
||||
cd /sdcard/ga
|
||||
python agentmain.py
|
||||
git clone https://github.com/lsdefine/GenericAgent.git
|
||||
cd GenericAgent
|
||||
uv venv
|
||||
uv pip install -e ".[ui]" # Core + GUI dependencies
|
||||
cp mykey_template.py mykey.py
|
||||
python launch.pyw
|
||||
```
|
||||
|
||||
Once running, tell the agent: *"Execute web setup SOP to unlock browser tools"* — it handles the rest. See [WELCOME_NEW_USER.md](WELCOME_NEW_USER.md) for the full bootstrap sequence.
|
||||
> GenericAgent is meant to grow its environment through the Agent itself, not by pre-installing every possible package.
|
||||
|
||||
## vs. Alternatives
|
||||
|
||||
| | GenericAgent | OpenClaw | Claude Code |
|
||||
|---|---|---|---|
|
||||
| Codebase | ~3,300 lines | ~530,000 lines | Open-source (large) |
|
||||
| Deploy | `pip install` + API key | Multi-service orchestration | CLI + subscription |
|
||||
| Browser | Injects into real browser (keeps login state) | Sandboxed/headless | Via MCP plugins |
|
||||
| OS Control | Keyboard, mouse, vision, ADB | Multi-agent delegation | File + terminal |
|
||||
| Self-evolution | Grows SOPs & tools autonomously | Plugin ecosystem | Stateless per session |
|
||||
| Core shipped | 10 .py + 5 SOPs | Hundreds of modules | Rich CLI toolkit |
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
User instruction
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ agent_loop.py (92L) │ ← Sense-Think-Act cycle
|
||||
│ "What do I know? │
|
||||
│ What should I do?" │
|
||||
└────────┬────────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 7 Atomic Tools │ ← All capabilities derive from these
|
||||
│ code_run │ Execute any Python/PowerShell
|
||||
│ file_read/write │ Direct disk access
|
||||
│ file_patch │ Surgical code edits
|
||||
│ web_scan │ Read live web pages
|
||||
│ web_execute_js │ Control browser DOM
|
||||
│ ask_user │ Human-in-the-loop
|
||||
└────────┬────────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ Memory System │ ← Persistent across sessions
|
||||
│ L0: Meta-SOP │ How to manage memory itself
|
||||
│ L2: Global Facts │ Environment, credentials, paths
|
||||
│ L3: Task SOPs │ Learned procedures (self-growing)
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
The agent starts with 7 primitive tools. Through `code_run`, it can install packages, write scripts, and interface with any hardware or API — effectively manufacturing new tools at runtime.
|
||||
|
||||
<details>
|
||||
<summary>What Ships in the Box</summary>
|
||||
|
||||
**Core engine** (runs the agent):
|
||||
- `agent_loop.py` — Sense-Think-Act loop (92 lines)
|
||||
- `ga.py` — Tool definitions and execution
|
||||
- `sidercall.py` — LLM communication (multi-backend)
|
||||
- `agentmain.py` — Session orchestration
|
||||
|
||||
**Interface** (talk to the agent):
|
||||
- `stapp.py` — Streamlit web UI
|
||||
- `tgapp.py` — Telegram bot interface
|
||||
- `launch.pyw` — One-click launcher with floating window
|
||||
|
||||
**Infrastructure**:
|
||||
- `TMWebDriver.py` — Browser injection bridge (not Selenium — injects JS into your real browser via Tampermonkey)
|
||||
- `simphtml.py` — HTML→text cleaner for web perception
|
||||
|
||||
**5 Core SOPs** (shipped, version-controlled):
|
||||
1. `memory_management_sop` — L0 constitution: how the agent manages its own memory
|
||||
2. `autonomous_operation_sop` — Self-directed task execution
|
||||
3. `scheduled_task_sop` — Cron-like recurring tasks
|
||||
4. `web_setup_sop` — Browser environment bootstrap
|
||||
5. `ljqCtrl_sop` — Desktop physical control (keyboard, mouse, DPI-aware)
|
||||
|
||||
Everything else — Gmail integration, WeChat automation, vision APIs, game downloaders, stock analysis workflows — the agent builds and memorizes on its own through use.
|
||||
|
||||
</details>
|
||||
Full guide: [GETTING_STARTED.md](GETTING_STARTED.md)
|
||||
|
||||
---
|
||||
|
||||
<a name="chinese"></a>
|
||||
## 🖥️ Desktop Frontends
|
||||
|
||||
# GenericAgent — 3,300 行代码,完整 OS 级自主控制
|
||||
### Terminal UI
|
||||
|
||||
一个极简自主 Agent 框架。用约 3,300 行 Python,让任意 LLM 获得对你 PC 的物理级控制能力——浏览器、终端、文件系统、键鼠、屏幕视觉、移动设备。
|
||||
|
||||
不需要 Electron,不需要 Docker,不需要 Mac Mini,不需要 53 万行代码,不需要付费安装服务。
|
||||
|
||||
## 用起来是什么样的
|
||||
|
||||
```
|
||||
你:"帮我读取微信消息"
|
||||
Agent:安装依赖 → 逆向数据库 → 写读取脚本 → 保存为 SOP
|
||||
下次:一句话直接调用,零配置。
|
||||
|
||||
你:"帮我监控股票并提醒"
|
||||
Agent:安装 mootdx → 构建选股工作流 → 设置定时任务 → 保存为 SOP
|
||||
下次:一句话启动。
|
||||
|
||||
你:"用 Gmail 发这个文件"
|
||||
Agent:配置 OAuth → 写发送脚本 → 保存为 SOP
|
||||
下次:直接能用。
|
||||
```
|
||||
|
||||
**自举实证**:本仓库从安装 Git、`git init`、编写 README 到每一条 commit message,全程由 GenericAgent 完成——作者没有打开过一次终端。
|
||||
|
||||
每个解决过的任务都会变成永久技能。用几周后,你的 Agent 实例会拥有一套独特的技能树——全部从 3,300 行种子代码中生长出来。
|
||||
|
||||
## 自举哲学
|
||||
|
||||
多数 Agent 框架以成品形态发布。GenericAgent 以**种子**形态发布。
|
||||
|
||||
5 个核心 SOP 定义了 Agent 如何思考、记忆和行动。之后的一切能力,由 Agent 在使用中自主发现并记录:
|
||||
|
||||
1. 你让它做一件新事
|
||||
2. 它自己摸索方法(安装依赖、写脚本、测试)
|
||||
3. 把流程保存为新 SOP
|
||||
4. 下次直接调用
|
||||
|
||||
Agent 不只是执行——它**学习并记忆**。
|
||||
|
||||
## 快速开始
|
||||
A lightweight, keyboard-driven interface built on [Textual](https://github.com/Textualize/textual). Supports multiple concurrent sessions, real-time streaming, and runs anywhere a terminal does — no browser needed.
|
||||
|
||||
```bash
|
||||
# 1. 克隆
|
||||
git clone https://github.com/lsdefine/pc-agent-loop.git
|
||||
cd pc-agent-loop
|
||||
python frontends/tuiapp.py
|
||||
```
|
||||
|
||||
# 2. 安装最小依赖
|
||||
pip install streamlit pywebview
|
||||
### Other Desktop Frontends
|
||||
|
||||
```bash
|
||||
python frontends/qtapp.py # Qt-based desktop app
|
||||
streamlit run frontends/stapp2.py # Alternative Streamlit UI
|
||||
```
|
||||
|
||||
### Codeg
|
||||
|
||||
<table><tr>
|
||||
<td width="70%">
|
||||
|
||||
[Codeg](https://github.com/yiqi-017/codeg) (`feat/genericagent-integration` branch) is a desktop/web UI that connects GenericAgent alongside other agents (Claude Code, Gemini, Codex, etc.) in a unified interface with a polished, modern UI.
|
||||
|
||||
> This integration is usable now. Some features are still being refined — feedback welcome.
|
||||
|
||||
Place your GenericAgent directory alongside the codeg project. Codeg will auto-detect `frontends/genericagent_acp_bridge.py` and launch GenericAgent as a local ACP agent.
|
||||
|
||||
</td>
|
||||
<td width="30%">
|
||||
<img src="assets/demo/codeg-demo.gif" width="90%" alt="Codeg Demo">
|
||||
</td>
|
||||
</tr></table>
|
||||
|
||||
---
|
||||
|
||||
## 💬 Bot Interface (IM)
|
||||
|
||||
### Telegram Bot
|
||||
|
||||
```python
|
||||
# mykey.py
|
||||
tg_bot_token = 'YOUR_BOT_TOKEN'
|
||||
tg_allowed_users = [YOUR_USER_ID]
|
||||
```
|
||||
|
||||
```bash
|
||||
python frontends/tgapp.py
|
||||
```
|
||||
|
||||
### Common Chat Commands
|
||||
|
||||
The default Streamlit desktop UI started by `python launch.pyw`, plus the QQ / Telegram / Feishu / WeCom / DingTalk frontends, support these chat commands:
|
||||
|
||||
- `/new` - start a fresh conversation and clear the current context
|
||||
- `/continue` - list recoverable conversation snapshots
|
||||
- `/continue N` - restore the `N`th recoverable conversation
|
||||
|
||||
|
||||
## 📊 Comparison with Similar Tools
|
||||
|
||||
| Feature | GenericAgent | OpenClaw | Claude Code |
|
||||
|------|:---:|:---:|:---:|
|
||||
| **Codebase** | ~3K lines | ~530,000 lines | Open-sourced (large) |
|
||||
| **Deployment** | `pip install` + API Key | Multi-service orchestration | CLI + subscription |
|
||||
| **Browser Control** | Real browser (session preserved) | Sandbox / headless browser | Via MCP plugin |
|
||||
| **OS Control** | Mouse/kbd, vision, ADB | Multi-agent delegation | File + terminal |
|
||||
| **Self-Evolution** | Autonomous skill growth | Plugin ecosystem | Stateless between sessions |
|
||||
| **Out of the Box** | A few core files + starter skills | Hundreds of modules | Rich CLI toolset |
|
||||
|
||||
|
||||
## 📈 Evaluation — Five Dimensions
|
||||
|
||||
> 📂 Full evaluation datasets and results: <https://github.com/JinyiHan99/GA-Technical-Report/tree/main>
|
||||
|
||||
| Dimension | Question | Benchmarks used |
|
||||
|---|---|---|
|
||||
| **1. Task Completion & Token Efficiency** | Can GA complete hard tasks more cheaply than leading agents? | SOP-Bench, Lifelong AgentBench, RealFin-Benchmark |
|
||||
| **2. Tool-Use Efficiency** | Can a minimal atomic toolset solve what specialized toolsets solve, with less overhead? | Tool Efficiency Benchmark (11 simple + 5 long-horizon tasks) |
|
||||
| **3. Memory System Effectiveness** | Does condensed hierarchical memory beat full/redundant memory and embedding-based retrievers? | SOP-Bench (dangerous goods), LoCoMo, 20-skill stress test |
|
||||
| **4. Self-Evolution Capability** | Can the agent distill experience into reusable SOPs and code, without intervention? | 9-round LangChain longitudinal study, 8-task cross-task web benchmark |
|
||||
| **5. Web Browsing Capability** | Does density-driven design survive the open web? | WebCanvas, BrowseComp-ZH, Custom Tasks (22) |
|
||||
|
||||
Baselines across these dimensions include **Claude Code**, **OpenAI CodeX**, and **OpenClaw**, evaluated under *Claude Sonnet 4.6*, *Claude Opus 4.6*, *GPT-5.4*, and *MiniMax M2.7* backbones.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<img src="assets/images/result_radar.png" width="100%" alt="Tool-use efficiency radar"/><br/>
|
||||
<sub><b>Tool-use efficiency radar.</b> GA dominates token, request, and tool-call axes while preserving quality across four task dimensions.</sub>
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<img src="assets/images/result_convergence.png" width="100%" alt="Cross-task self-evolution convergence"/><br/>
|
||||
<sub><b>Cross-task self-evolution.</b> Second- and third-run GA executions converge to a stable low-cost regime across eight web tasks, while OpenClaw shows no such convergence.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
## 🧠 How It Works
|
||||
|
||||
GenericAgent accomplishes complex tasks through **Layered Memory × Minimal Toolset × Autonomous Execution Loop**, continuously accumulating experience during execution.
|
||||
|
||||
1️⃣ **Layered Memory System**
|
||||
> _Memory crystallizes throughout task execution, letting the agent build stable, efficient working patterns over time._
|
||||
|
||||
- **L0 — Meta Rules**: Core behavioral rules and system constraints of the agent
|
||||
- **L1 — Insight Index**: Minimal memory index for fast routing and recall
|
||||
- **L2 — Global Facts**: Stable knowledge accumulated over long-term operation
|
||||
- **L3 — Task Skills / SOPs**: Reusable workflows for completing specific task types
|
||||
- **L4 — Session Archive**: Archived task records distilled from finished sessions for long-horizon recall
|
||||
|
||||
2️⃣ **Autonomous Execution Loop**
|
||||
|
||||
> _Perceive environment state → Task reasoning → Execute tools → Write experience to memory → Loop_
|
||||
|
||||
The entire core loop is just **~100 lines of code** (`agent_loop.py`).
|
||||
|
||||
3️⃣ **Minimal Toolset**
|
||||
> _GenericAgent provides only **9 atomic tools**, forming the foundational capabilities for interacting with the outside world._
|
||||
|
||||
| Tool | Function |
|
||||
|------|------|
|
||||
| `code_run` | Execute arbitrary code |
|
||||
| `file_read` | Read files |
|
||||
| `file_write` | Write files |
|
||||
| `file_patch` | Patch / modify files |
|
||||
| `web_scan` | Perceive web content |
|
||||
| `web_execute_js` | Control browser behavior |
|
||||
| `ask_user` | Human-in-the-loop confirmation |
|
||||
|
||||
> Additionally, 2 **memory management tools** (`update_working_checkpoint`, `start_long_term_update`) allow the agent to persist context and accumulate experience across sessions.
|
||||
|
||||
4️⃣ **Capability Extension Mechanism**
|
||||
> _Capable of dynamically creating new tools._
|
||||
|
||||
Via `code_run`, GenericAgent can dynamically install Python packages, write new scripts, call external APIs, or control hardware at runtime — crystallizing temporary abilities into permanent tools.
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/images/workflow.jpg" alt="GenericAgent Workflow" width="400"/>
|
||||
<br><em>GenericAgent Workflow Diagram</em>
|
||||
</div>
|
||||
|
||||
|
||||
## ⭐ Support
|
||||
|
||||
If this project helped you, please consider leaving a **Star!** 🙏
|
||||
|
||||
You're also welcome to join our **GenericAgent Community Group** for discussion, feedback, and co-building 👏
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><strong>WeChat Group 18</strong><br><img src="assets/images/wechat_group18.jpg" alt="WeChat Group 18 QR Code" width="250"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## 🚩 Friendly Links
|
||||
|
||||
Thanks for the support from the LinuxDo community!
|
||||
|
||||
[](https://linux.do/)
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License — see [LICENSE](LICENSE)
|
||||
|
||||
*Disclaimer: This project does not build or operate any commercial website. Apart from DintalClaw, no institution, organization, or individual is currently officially authorized to conduct commercial activities under the GenericAgent name.*
|
||||
|
||||
|
||||
---
|
||||
<a name="chinese"></a>
|
||||
## 🌟 项目简介
|
||||
|
||||
**GenericAgent** 是一个极简、可自我进化的自主 Agent 框架。核心仅 **~3K 行代码**,通过 **9 个原子工具 + ~100 行 Agent Loop**,赋予任意 LLM 对本地计算机的系统级控制能力,覆盖浏览器、终端、文件系统、键鼠输入、屏幕视觉及移动设备。
|
||||
|
||||
它的设计哲学是:**不预设技能,靠进化获得能力。**
|
||||
|
||||
每解决一个新任务,GenericAgent 就将执行路径自动固化为 Skill,供后续直接调用。使用时间越长,沉淀的技能越多,形成一棵完全属于你、从 3K 行种子代码生长出来的专属技能树。
|
||||
|
||||
> **🤖 自举实证** — 本仓库的一切,从安装 Git、`git init` 到每一条 commit message,均由 GenericAgent 自主完成。作者全程未打开过一次终端。
|
||||
|
||||
## 📋 核心特性
|
||||
- **自我进化**: 每次任务自动沉淀 Skill,能力随使用持续增长,形成专属技能树
|
||||
- **极简架构**: ~3K 行核心代码,Agent Loop 约百行,无复杂依赖,部署零负担
|
||||
- **强执行力**: 注入真实浏览器(保留登录态),9 个原子工具直接接管系统
|
||||
- **高兼容性**: 支持 Claude / Gemini / Kimi / MiniMax 等主流模型,跨平台运行
|
||||
- **极致省 Token**: 上下文窗口不到 30K,是其他 Agent(200K–1M)的零头。分层记忆让关键信息始终在场——噪声更少,幻觉更低,成功率反而更高,而成本低一个数量级。
|
||||
|
||||
## 🧬 自我进化机制
|
||||
|
||||
这是 GenericAgent 区别于其他 Agent 框架的根本所在。
|
||||
|
||||
```
|
||||
[遇到新任务]-->[自主摸索](安装依赖、编写脚本、调试验证)-->
|
||||
[将执行路径固化为 Skill]-->[写入记忆层]-->[下次同类任务直接调用]
|
||||
```
|
||||
|
||||
| 你说的一句话 | Agent 第一次做了什么 | 之后每次 |
|
||||
|---|---|---|
|
||||
| *"监控股票并提醒我"* | 安装 mootdx → 构建选股流程 → 配置定时任务 → 保存 Skill | **一句话启动** |
|
||||
| *"用 Gmail 发这个文件"* | 配置 OAuth → 编写发送脚本 → 保存 Skill | **直接可用** |
|
||||
|
||||
用几周后,你的 Agent 实例将拥有一套任何人都没有的专属技能树,全部从 3K 行种子代码中生长而来。
|
||||
|
||||
<!-- | *"帮我读取微信消息"* | 安装依赖 → 逆向数据库 → 写读取脚本 → 保存 Skill | **一句话调用** | -->
|
||||
|
||||
#### 🎯 实例展示
|
||||
|
||||
| 🧋 外卖下单 | 📈 量化选股 |
|
||||
|:---:|:---:|
|
||||
| <img src="assets/demo/order_tea.gif" width="100%" alt="Order Tea"> | <img src="assets/demo/selectstock.gif" width="100%" alt="Stock Selection"> |
|
||||
| *"Order me a milk tea"* — 自动导航外卖 App,选品并完成结账 | *"Find GEM stocks with EXPMA golden cross, turnover > 5%"* — 量化条件筛股 |
|
||||
| 🌐 自主网页探索 | 💰 支出追踪 | 💬 批量消息 |
|
||||
| <img src="assets/demo/autonomous_explore.png" width="100%" alt="Web Exploration"> | <img src="assets/demo/alipay_expense.png" width="100%" alt="Alipay Expense"> | <img src="assets/demo/wechat_batch.png" width="100%" alt="WeChat Batch"> |
|
||||
| 自主浏览并定时汇总网页信息 | *"查找近 3 个月超 ¥2K 的支出"* — 通过 ADB 驱动支付宝 | 批量发送微信消息,完整驱动微信客户端 |
|
||||
|
||||
|
||||
|
||||
## 📅 最新动态
|
||||
|
||||
- **2026-04-21:** 📄 [技术报告已发布至 arXiv](https://arxiv.org/abs/2604.17091) — *GenericAgent: A Token-Efficient Self-Evolving LLM Agent via Contextual Information Density Maximization*
|
||||
- **2026-04-11:** 引入 **L4 会话归档记忆**,并接入 scheduler cron 调度
|
||||
- **2026-03-23:** 支持个人微信接入作为 Bot 前端
|
||||
- **2026-03-10:** [发布百万级 Skill 库](https://mp.weixin.qq.com/s/q2gQ7YvWoiAcwxzaiwpuiQ?scene=1&click_id=7)
|
||||
- **2026-03-08:** [发布以 GenericAgent 为核心的"政务龙虾" Dintal Claw](https://mp.weixin.qq.com/s/eiEhwo-j6S-WpLxgBnNxBg)
|
||||
- **2026-03-01:** [GenericAgent 被机器之心报道](https://mp.weixin.qq.com/s/uVWpTTF5I1yzAENV_qm7yg)
|
||||
- **2026-01-16:** GenericAgent V1.0 公开版本发布
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
#### 方法一:标准安装
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://github.com/lsdefine/GenericAgent.git
|
||||
cd GenericAgent
|
||||
|
||||
# 2. 安装依赖
|
||||
pip install requests streamlit pywebview # 桌面 GUI (launch.pyw)
|
||||
pip install requests textual # 终端 UI (tuiapp.py)
|
||||
|
||||
# 3. 配置 API Key
|
||||
cp mykey_template.py mykey.py
|
||||
# 编辑 mykey.py 填入你的 LLM API Key
|
||||
# 编辑 mykey.py,填入你的 LLM API Key
|
||||
# 或使用交互式向导:python assets/configure_mykey.py
|
||||
|
||||
# 4. 启动
|
||||
python launch.pyw
|
||||
```
|
||||
|
||||
**同样可在 Android 上运行** — 已在 Termux 上测试通过,通过 `python agentmain.py`(CLI 前端)启动:
|
||||
#### 方法二:uv 快速安装(熟悉 Python 的用户)
|
||||
|
||||
如果你习惯现代 Python 工作流,GenericAgent 也提供了一个最小化的 `pyproject.toml`:
|
||||
|
||||
```bash
|
||||
# 在 Termux 中
|
||||
cd /sdcard/ga
|
||||
python agentmain.py
|
||||
git clone https://github.com/lsdefine/GenericAgent.git
|
||||
cd GenericAgent
|
||||
uv pip install -e ".[ui]" # 核心 + GUI 依赖
|
||||
cp mykey_template.py mykey.py
|
||||
python launch.pyw
|
||||
```
|
||||
|
||||
启动后告诉 Agent:"执行 web setup SOP 解锁浏览器工具"——剩下的它自己搞定。完整引导流程见 [WELCOME_NEW_USER.md](WELCOME_NEW_USER.md)。
|
||||
> GenericAgent 更推荐由 Agent 在使用中自举环境,而不是预先手动装完整依赖。
|
||||
|
||||
## 对比
|
||||
完整引导流程见 [GETTING_STARTED.md](GETTING_STARTED.md)。
|
||||
|
||||
| | GenericAgent | OpenClaw | Claude Code |
|
||||
|---|---|---|---|
|
||||
| 代码量 | ~3,300 行 | ~530,000 行 | 已开源(体量大) |
|
||||
| 部署 | `pip install` + API key | 多服务编排 | CLI + 订阅 |
|
||||
| 浏览器 | 注入真实浏览器(保留登录态) | 沙箱/无头浏览器 | 通过 MCP 插件 |
|
||||
| OS 控制 | 键鼠、视觉、ADB | 多 Agent 委派 | 文件 + 终端 |
|
||||
| 自我进化 | 自主生长 SOP 和工具 | 插件生态 | 会话间无状态 |
|
||||
| 出厂配置 | 10 个 .py + 5 个 SOP | 数百模块 | 丰富 CLI 工具集 |
|
||||
📖 新手使用指南(图文版):[飞书文档](https://my.feishu.cn/wiki/CGrDw0T76iNFuskmwxdcWrpinPb)
|
||||
|
||||
## 工作原理
|
||||
📘 完整入门教程(Datawhale 出品):[Hello GenericAgent](https://datawhalechina.github.io/hello-generic-agent/) · [GitHub](https://github.com/datawhalechina/hello-generic-agent)
|
||||
|
||||
Agent 拥有 7 个原子工具:`code_run`(执行任意代码)、`file_read/write/patch`(文件操作)、`web_scan`(网页感知)、`web_execute_js`(浏览器控制)、`ask_user`(人机协作)。
|
||||
---
|
||||
|
||||
通过 `code_run`,它可以安装任何包、编写任何脚本、对接任何硬件——相当于在运行时制造新工具。学到的流程保存为 SOP,下次直接调用。
|
||||
## 🖥️ 桌面前端
|
||||
|
||||
核心循环只有 92 行(`agent_loop.py`):感知 → 思考 → 行动 → 记忆。
|
||||
### 终端 UI
|
||||
|
||||
<details>
|
||||
<summary>出厂清单</summary>
|
||||
基于 [Textual](https://github.com/Textualize/textual) 的轻量键盘驱动界面。支持多会话并发、实时流式输出,有终端就能跑,无需浏览器。
|
||||
|
||||
**核心引擎**:
|
||||
- `agent_loop.py` — 感知-思考-行动循环(92 行)
|
||||
- `ga.py` — 工具定义与执行
|
||||
- `sidercall.py` — LLM 通信(多后端)
|
||||
- `agentmain.py` — 会话编排
|
||||
```bash
|
||||
python frontends/tuiapp.py
|
||||
```
|
||||
|
||||
**交互界面**:
|
||||
- `stapp.py` — Streamlit Web UI
|
||||
- `tgapp.py` — Telegram 机器人
|
||||
- `launch.pyw` — 一键启动 + 悬浮窗
|
||||
### 其他桌面前端
|
||||
|
||||
**基础设施**:
|
||||
- `TMWebDriver.py` — 浏览器注入桥接(非 Selenium,通过 Tampermonkey 注入真实浏览器)
|
||||
- `simphtml.py` — HTML→文本清洗
|
||||
```bash
|
||||
python frontends/qtapp.py # 基于 Qt 的桌面应用
|
||||
streamlit run frontends/stapp2.py # 另一种 Streamlit 风格 UI
|
||||
```
|
||||
|
||||
**5 个核心 SOP**(出厂自带,版本控制):
|
||||
1. `memory_management_sop` — L0 宪法:Agent 如何管理自身记忆
|
||||
2. `autonomous_operation_sop` — 自主任务执行
|
||||
3. `scheduled_task_sop` — 定时任务
|
||||
4. `web_setup_sop` — 浏览器环境引导
|
||||
5. `ljqCtrl_sop` — 桌面物理控制(键鼠、DPI 感知)
|
||||
### Codeg前端
|
||||
|
||||
其余一切——Gmail、微信自动化、视觉 API、游戏下载、股票分析——都是 Agent 在使用中自主构建并记忆的。
|
||||
<table><tr>
|
||||
<td width="70%">
|
||||
|
||||
</details>
|
||||
[Codeg](https://github.com/yiqi-017/codeg)(`feat/genericagent-integration` 分支)是一个桌面/Web UI,可以将 GenericAgent 与其他代理(Claude Code、Gemini、Codex 等)在统一界面中并行使用,UI 更加精美。
|
||||
|
||||
## 许可
|
||||
> 此集成已可使用,部分功能仍在完善中,欢迎体验反馈。
|
||||
|
||||
MIT
|
||||
将 GenericAgent 目录放在 codeg 项目同级目录下,Codeg 会自动检测 `frontends/genericagent_acp_bridge.py` 并将 GenericAgent 作为本地 ACP 代理启动。
|
||||
|
||||
</td>
|
||||
<td width="30%">
|
||||
<img src="assets/demo/codeg-demo.gif" width="90%" alt="Codeg Demo">
|
||||
</td>
|
||||
</tr></table>
|
||||
|
||||
---
|
||||
|
||||
## 💬 Bot 接口(IM)
|
||||
|
||||
### 微信 Bot(个人微信)
|
||||
|
||||
无需额外配置,扫码登录即可:
|
||||
|
||||
```bash
|
||||
pip install pycryptodome qrcode requests
|
||||
python frontends/wechatapp.py
|
||||
```
|
||||
|
||||
> 首次启动会弹出二维码,用微信扫码完成绑定。之后通过微信消息与 Agent 交互。
|
||||
|
||||
### QQ Bot
|
||||
|
||||
使用 `qq-botpy` WebSocket 长连接,**无需公网 webhook**:
|
||||
|
||||
```bash
|
||||
pip install qq-botpy
|
||||
```
|
||||
|
||||
在 `mykey.py` 中补充:
|
||||
|
||||
```python
|
||||
qq_app_id = "YOUR_APP_ID"
|
||||
qq_app_secret = "YOUR_APP_SECRET"
|
||||
qq_allowed_users = ["YOUR_USER_OPENID"] # 或 ['*'] 公开访问
|
||||
```
|
||||
|
||||
```bash
|
||||
python frontends/qqapp.py
|
||||
```
|
||||
|
||||
> 在 [QQ 开放平台](https://q.qq.com) 创建机器人获取 AppID / AppSecret。首次消息后,用户 openid 记录于 `temp/qqapp.log`。
|
||||
|
||||
### 飞书(Lark)
|
||||
|
||||
```bash
|
||||
pip install lark-oapi
|
||||
python frontends/fsapp.py
|
||||
```
|
||||
|
||||
```python
|
||||
fs_app_id = "cli_xxx"
|
||||
fs_app_secret = "xxx"
|
||||
fs_allowed_users = ["ou_xxx"] # 或 ['*']
|
||||
```
|
||||
|
||||
**入站支持**:文本、富文本 post、图片、文件、音频、media、交互卡片 / 分享卡片
|
||||
**出站支持**:流式进度卡片、图片回传、文件 / media 回传
|
||||
**视觉模型**:图片首轮以真正的多模态输入发送给兼容 OpenAI Vision 的后端
|
||||
|
||||
详细配置见 [assets/SETUP_FEISHU.md](assets/SETUP_FEISHU.md)
|
||||
|
||||
|
||||
### 企业微信(WeCom)
|
||||
|
||||
```bash
|
||||
pip install wecom_aibot_sdk
|
||||
python frontends/wecomapp.py
|
||||
```
|
||||
|
||||
```python
|
||||
wecom_bot_id = "your_bot_id"
|
||||
wecom_secret = "your_bot_secret"
|
||||
wecom_allowed_users = ["your_user_id"]
|
||||
wecom_welcome_message = "你好,我在线上。"
|
||||
```
|
||||
|
||||
### 钉钉(DingTalk)
|
||||
|
||||
```bash
|
||||
pip install dingtalk-stream
|
||||
python frontends/dingtalkapp.py
|
||||
```
|
||||
|
||||
```python
|
||||
dingtalk_client_id = "your_app_key"
|
||||
dingtalk_client_secret = "your_app_secret"
|
||||
dingtalk_allowed_users = ["your_staff_id"] # 或 ['*']
|
||||
```
|
||||
|
||||
### 通用聊天命令
|
||||
|
||||
默认通过 `python launch.pyw` 启动的 Streamlit 桌面 UI,以及 QQ / Telegram / 飞书 / 企业微信 / 钉钉前端,都支持以下命令:
|
||||
|
||||
- `/new` - 开启新对话并清空当前上下文
|
||||
- `/continue` - 列出可恢复会话快照
|
||||
- `/continue N` - 恢复第 `N` 个可恢复会话
|
||||
|
||||
|
||||
## 📊 与同类产品对比
|
||||
|
||||
| 特性 | GenericAgent | OpenClaw | Claude Code |
|
||||
|------|:---:|:---:|:---:|
|
||||
| **代码量** | ~3K 行 | ~530,000 行 | 已开源(体量大) |
|
||||
| **部署方式** | `pip install` + API Key | 多服务编排 | CLI + 订阅 |
|
||||
| **浏览器控制** | 注入真实浏览器(保留登录态) | 沙箱 / 无头浏览器 | 通过 MCP 插件 |
|
||||
| **OS 控制** | 键鼠、视觉、ADB | 多 Agent 委派 | 文件 + 终端 |
|
||||
| **自我进化** | 自主生长 Skill 和工具 | 插件生态 | 会话间无状态 |
|
||||
| **出厂配置** | 几个核心文件 + 少量初始 Skills | 数百模块 | 丰富 CLI 工具集 |
|
||||
|
||||
|
||||
## 📈 评测 — 五大维度
|
||||
|
||||
> 📂 完整的评测数据集以及评测结果见:<https://github.com/JinyiHan99/GA-Technical-Report/tree/main>
|
||||
|
||||
| 维度 | 核心问题 | 使用的基准 |
|
||||
|---|---|---|
|
||||
| **1. 任务完成度与 Token 效率** | GA 能否以更低成本完成高难度任务? | SOP-Bench、Lifelong AgentBench、RealFin-Benchmark |
|
||||
| **2. 工具使用效率** | 最小原子工具集能否以更低开销替代专用工具集? | Tool Efficiency Benchmark |
|
||||
| **3. 记忆系统有效性** | 精简分层记忆能否超越冗余记忆和基于 Embedding 的检索器? | SOP-Bench、LoCoMo、20-skill 压力测试 |
|
||||
| **4. 自我进化能力** | Agent 能否在无人干预下将经验提炼为可复用的 SOP 与代码? | 9 轮 LangChain 纵向研究、8 任务跨任务 Web 基准 |
|
||||
| **5. 网页浏览能力** | 信息密度驱动设计能否适应开放网页? | WebCanvas、BrowseComp-ZH、自定义任务 |
|
||||
|
||||
以上维度的基线包括 **Claude Code**、**OpenAI CodeX** 和 **OpenClaw**,分别在 *Claude Sonnet 4.6*、*Claude Opus 4.6*、*GPT-5.4* 和 *MiniMax M2.7* 底座上进行评测。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<img src="assets/images/result_radar.png" width="100%" alt="工具使用效率雷达图"/><br/>
|
||||
<sub><b>工具使用效率雷达图。</b>GA 在 Token、请求数和工具调用轴上全面领先,同时在四个任务维度上保持质量。</sub>
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<img src="assets/images/result_convergence.png" width="100%" alt="跨任务自我进化收敛曲线"/><br/>
|
||||
<sub><b>跨任务自我进化。</b>GA 的第二轮和第三轮执行在 8 个 Web 任务上收敛至稳定的低成本区间。</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
## 🧠 工作机制
|
||||
|
||||
GenericAgent 通过**分层记忆 × 最小工具集 × 自主执行循环**完成复杂任务,并在执行过程中持续积累经验。
|
||||
|
||||
1️⃣ **分层记忆系统**
|
||||
> 记忆在任务执行过程中持续沉淀,使 Agent 逐步形成稳定且高效的工作方式
|
||||
|
||||
|
||||
- **L0 — 元规则(Meta Rules)**:Agent 的基础行为规则和系统约束
|
||||
- **L1 — 记忆索引(Insight Index)**:极简索引层,用于快速路由与召回
|
||||
- **L2 — 全局事实(Global Facts)**:在长期运行过程中积累的稳定知识
|
||||
- **L3 — 任务 Skills / SOPs**:完成特定任务类型的可复用流程
|
||||
- **L4 — 会话归档(Session Archive)**:从已完成任务中提炼出的归档记录,用于长程召回
|
||||
|
||||
2️⃣ **自主执行循环**
|
||||
|
||||
> 感知环境状态 → 任务推理 → 调用工具执行 → 经验写入记忆 → 循环
|
||||
|
||||
整个核心循环仅 **约百行代码**(`agent_loop.py`)。
|
||||
|
||||
3️⃣ **最小工具集**
|
||||
>GenericAgent 仅提供 **9 个原子工具**,构成与外部世界交互的基础能力
|
||||
|
||||
| 工具 | 功能 |
|
||||
|------|------|
|
||||
| `code_run` | 执行任意代码 |
|
||||
| `file_read` | 读取文件 |
|
||||
| `file_write` | 写入文件 |
|
||||
| `file_patch` | 修改文件 |
|
||||
| `web_scan` | 感知网页内容 |
|
||||
| `web_execute_js` | 控制浏览器行为 |
|
||||
| `ask_user` | 人机协作确认 |
|
||||
|
||||
> 此外,还有 2 个**记忆管理工具**(`update_working_checkpoint`、`start_long_term_update`),使 Agent 能够跨会话积累经验、维持持久上下文。
|
||||
|
||||
4️⃣ **能力扩展机制**
|
||||
> 具备动态创建新的工具能力
|
||||
>
|
||||
通过 `code_run`,GenericAgent 可在运行时动态安装 Python 包、编写新脚本、调用外部 API 或控制硬件,将临时能力固化为永久工具。
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/images/workflow.jpg" alt="GenericAgent 工作流程" width="400"/>
|
||||
<br><em>GenericAgent 工作流程图</em>
|
||||
</div>
|
||||
|
||||
## ⭐ 支持
|
||||
如果这个项目对您有帮助,欢迎点一个 **Star!** 🙏
|
||||
|
||||
同时也欢迎加入我们的**GenericAgent体验交流群**,一起交流、反馈和共建 👏
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><strong>微信群 18</strong><br><img src="assets/images/wechat_group18.jpg" alt="微信群 18 二维码" width="250"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## 🚩 友情链接
|
||||
|
||||
感谢 **LinuxDo** 社区的支持!
|
||||
|
||||
[](https://linux.do/)
|
||||
|
||||
|
||||
## 📄 许可
|
||||
MIT License — 详见 [LICENSE](LICENSE)
|
||||
|
||||
*声明:本项目未构建任何商业站点;除 DintalClaw 外,目前未官方授权任何机构、组织或个人以 GenericAgent 名义从事商业活动。*
|
||||
|
||||
## 📈 Star History
|
||||
|
||||
<a href="https://star-history.com/#lsdefine/GenericAgent&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=lsdefine/GenericAgent&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=lsdefine/GenericAgent&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=lsdefine/GenericAgent&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -1,9 +1,8 @@
|
||||
import json, threading, time, uuid, queue, socket, requests, traceback
|
||||
from typing import Dict, Any, Optional, List
|
||||
from simple_websocket_server import WebSocketServer, WebSocket
|
||||
from bs4 import BeautifulSoup
|
||||
import bottle, random
|
||||
from bottle import route, template, request, response
|
||||
from typing import Any
|
||||
from simple_websocket_server import WebSocketServer, WebSocket
|
||||
import bottle
|
||||
from bottle import request
|
||||
|
||||
class Session:
|
||||
def __init__(self, session_id, info, client=None):
|
||||
@@ -12,7 +11,7 @@ class Session:
|
||||
self.connect_at = time.time()
|
||||
self.disconnect_at = None
|
||||
self.type = info.get('type', 'ws')
|
||||
self.ws_client = client if self.type == 'ws' else None
|
||||
self.ws_client = client if self.type in ('ws', 'ext_ws') else None
|
||||
self.http_queue = client if self.type == 'http' else None
|
||||
@property
|
||||
def url(self): return self.info.get('url', '')
|
||||
@@ -22,7 +21,7 @@ class Session:
|
||||
def reconnect(self, client, info):
|
||||
self.info = info
|
||||
self.type = info.get('type', 'ws')
|
||||
if self.type == 'ws':
|
||||
if self.type in ('ws', 'ext_ws'):
|
||||
self.ws_client = client
|
||||
self.http_queue = None
|
||||
elif self.type == 'http':
|
||||
@@ -30,11 +29,12 @@ class Session:
|
||||
self.connect_at = time.time()
|
||||
self.disconnect_at = None
|
||||
def mark_disconnected(self):
|
||||
if self.is_active(): print(f"Tab disconnected: {self.url} (Session: {self.id})")
|
||||
self.disconnect_at = time.time()
|
||||
|
||||
|
||||
class TMWebDriver:
|
||||
def __init__(self, host: str = 'localhost', port: int = 18765):
|
||||
def __init__(self, host: str = '127.0.0.1', port: int = 18765):
|
||||
self.host, self.port = host, port
|
||||
self.sessions, self.results, self.acks = {}, {}, {}
|
||||
self.default_session_id = None
|
||||
@@ -68,7 +68,7 @@ class TMWebDriver:
|
||||
try:
|
||||
msg = msgQ.get(timeout=0.2)
|
||||
try: self.acks[json.loads(msg).get('id','')] = True
|
||||
except: traceback.print_exc()
|
||||
except Exception: traceback.print_exc()
|
||||
return msg
|
||||
except queue.Empty: continue
|
||||
return json.dumps({"id": "", "ret": "next long-poll"})
|
||||
@@ -79,7 +79,7 @@ class TMWebDriver:
|
||||
if data.get('type') == 'result':
|
||||
self.results[data.get('id')] = {'success': True, 'data': data.get('result'), 'newTabs': data.get('newTabs', [])}
|
||||
elif data.get('type') == 'error':
|
||||
self.results[data.get('id')] = {'success': False, 'data': data.get('error')}
|
||||
self.results[data.get('id')] = {'success': False, 'data': data.get('error'), 'newTabs': data.get('newTabs', [])}
|
||||
return 'ok'
|
||||
|
||||
@app.route('/link', method=['GET','POST'])
|
||||
@@ -98,7 +98,7 @@ class TMWebDriver:
|
||||
print('[remote result]', (str(code)[:50] + ' RESULT:' +str(result)[:50]).replace('\n', ' '))
|
||||
return json.dumps({'r': result}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
return json.dumps({'error': str(e)}, ensure_ascii=False)
|
||||
return json.dumps({'r': {'error': str(e)}}, ensure_ascii=False)
|
||||
return 'ok'
|
||||
def run():
|
||||
from wsgiref.simple_server import make_server, WSGIServer, WSGIRequestHandler
|
||||
@@ -128,16 +128,32 @@ class TMWebDriver:
|
||||
session_info = {'url': data.get('url'), 'title': data.get('title', ''),
|
||||
'connected_at': time.time(), 'type': 'ws'}
|
||||
driver._register_client(session_id, self, session_info)
|
||||
elif data.get('type') in ['ext_ready', 'tabs_update']:
|
||||
tabs = data.get('tabs', [])
|
||||
current_tab_ids = {str(tab['id']) for tab in tabs}
|
||||
print(f"Received tabs update: {current_tab_ids}")
|
||||
for sid in list(driver.sessions.keys()):
|
||||
sess = driver.sessions[sid]
|
||||
if sess.type == 'ext_ws' and sid not in current_tab_ids:
|
||||
sess.mark_disconnected()
|
||||
for tab in tabs:
|
||||
session_id = str(tab['id'])
|
||||
session_info = {'url': tab.get('url'), 'title': tab.get('title', ''), 'connected_at': time.time(), 'type': 'ext_ws'}
|
||||
sess = driver.sessions.get(session_id)
|
||||
if sess and sess.is_active(): sess.info = session_info
|
||||
else: driver._register_client(session_id, self, session_info)
|
||||
elif data.get('type') == 'ack': driver.acks[data.get('id','')] = True
|
||||
elif data.get('type') == 'result':
|
||||
driver.results[data.get('id')] = {'success': True, 'data': data.get('result'), 'newTabs': data.get('newTabs', [])}
|
||||
elif data.get('type') == 'error':
|
||||
driver.results[data.get('id')] = {'success': False, 'data': data.get('error')}
|
||||
driver.results[data.get('id')] = {'success': False, 'data': data.get('error'), 'newTabs': data.get('newTabs', [])}
|
||||
except Exception as e:
|
||||
print(f"Error handling message: {e}")
|
||||
if hasattr(self, 'data'): print(self.data)
|
||||
def connected(self): (f"New connection from {self.address}")
|
||||
def handle_close(self): driver._unregister_client(self)
|
||||
def handle_close(self):
|
||||
print(f"WS Connection closed: {self.address}")
|
||||
driver._unregister_client(self)
|
||||
|
||||
self.server = WebSocketServer(self.host, self.port, JSExecutor)
|
||||
server_thread = threading.Thread(target=self.server.serve_forever)
|
||||
@@ -159,13 +175,10 @@ class TMWebDriver:
|
||||
|
||||
self.latest_session_id = session_id
|
||||
if self.default_session_id is None: self.default_session_id = session_id
|
||||
|
||||
|
||||
def _unregister_client(self, client: WebSocket) -> None:
|
||||
for session in self.sessions.values():
|
||||
if session.ws_client == client:
|
||||
session.mark_disconnected()
|
||||
break
|
||||
if session.ws_client == client: session.mark_disconnected()
|
||||
|
||||
def execute_js(self, code, timeout=15, session_id=None) -> Any:
|
||||
if session_id is None: session_id = self.default_session_id
|
||||
@@ -190,11 +203,14 @@ class TMWebDriver:
|
||||
raise ValueError(f"会话ID {session_id} 未连接")
|
||||
|
||||
tp = session.type
|
||||
assert tp in ['ws', 'http'], f"Unsupported session type: {tp}"
|
||||
if tp not in ('ws', 'http', 'ext_ws'):
|
||||
raise ValueError(f"Unsupported session type: {tp}")
|
||||
exec_id = str(uuid.uuid4())
|
||||
payload = json.dumps({'id': exec_id, 'code': code})
|
||||
payload_dict = {'id': exec_id, 'code': code}
|
||||
if tp == 'ext_ws': payload_dict['tabId'] = int(session.id)
|
||||
payload = json.dumps(payload_dict)
|
||||
|
||||
if tp == 'ws': session.ws_client.send_message(payload)
|
||||
if tp in ['ws', 'ext_ws']: session.ws_client.send_message(payload)
|
||||
elif tp == 'http': session.http_queue.put(payload)
|
||||
|
||||
start_time = time.time()
|
||||
@@ -202,15 +218,15 @@ class TMWebDriver:
|
||||
hasjump = acked = False
|
||||
|
||||
while exec_id not in self.results:
|
||||
time.sleep(0.5)
|
||||
time.sleep(0.2)
|
||||
if not acked and exec_id in self.acks:
|
||||
acked = True; start_time = time.time()
|
||||
if tp == 'ws':
|
||||
if tp in ['ws', 'ext_ws']:
|
||||
if not session.is_active(): hasjump = True
|
||||
if hasjump and session.is_active():
|
||||
return {'result': f"Session {session_id} reloaded.", "closed":1}
|
||||
if time.time() - start_time > timeout:
|
||||
if tp == 'ws':
|
||||
if tp in ['ws', 'ext_ws']:
|
||||
if hasjump: return {'result': f"Session {session_id} reloaded and new page is loading...", 'closed':1}
|
||||
if acked: return {"result": f"No response data in {timeout}s (ACK received, script may still be running)"}
|
||||
return {"result": f"No response data in {timeout}s (no ACK, script may not have been delivered)"}
|
||||
@@ -227,7 +243,9 @@ class TMWebDriver:
|
||||
return rr
|
||||
|
||||
def _remote_cmd(self, cmd):
|
||||
return requests.post(self.remote, headers={"Content-Type": "application/json"}, json=cmd).json()
|
||||
try: return requests.post(self.remote, headers={"Content-Type": "application/json"}, json=cmd, timeout=30).json()
|
||||
except (ConnectionError, requests.exceptions.ConnectionError):
|
||||
raise ConnectionError("TMWebDriver master未运行,看tmwebdriver_sop启动master")
|
||||
|
||||
def get_all_sessions(self):
|
||||
if self.is_remote:
|
||||
@@ -261,9 +279,6 @@ class TMWebDriver:
|
||||
return self.default_session_id
|
||||
|
||||
def jump(self, url, timeout=10): self.execute_js(f"window.location.href='{url}'", timeout=timeout)
|
||||
def newtab(self, url=None):
|
||||
if url is None: url = "http://www.baidu.com/robots.txt"
|
||||
return self.execute_js(f'GM_openInTab("{url}");')
|
||||
|
||||
if __name__ == "__main__":
|
||||
driver = TMWebDriver(host='localhost', port=18765)
|
||||
driver = TMWebDriver(host='127.0.0.1', port=18765)
|
||||
@@ -1,61 +0,0 @@
|
||||
# 🚀 欢迎使用物理级全能执行者
|
||||
|
||||
这是您的 Agent 初始化指引。请按以下阶段操作:
|
||||
|
||||
## 第一阶段:环境启动 (Initial Ignition)
|
||||
|
||||
1. **Python 环境检查与核心库安装**
|
||||
- 确保安装了 Python 3.10+。
|
||||
- **必须手动执行**:`pip install streamlit pywebview`
|
||||
|
||||
2. **配置身份密钥 (Credentials)**
|
||||
- 复制 `mykey_template.py` 为 `mykey.py` 并填入 API Key。
|
||||
|
||||
3. **唤醒 Agent**
|
||||
- 运行 `launch.pyw`。看到悬浮窗后,我即刻上线。
|
||||
|
||||
## 第二阶段:能力激活 (Ability Activation)
|
||||
|
||||
在此阶段,您只需对我发送指令,所有物理操作由我完成:
|
||||
|
||||
1. **解锁 PowerShell 脚本执行权限**
|
||||
- **指令**:`请帮我当前用户解锁 powershell 的 ps1 执行权限。`
|
||||
|
||||
2. **配置全局文件搜索 (Everything CLI)**
|
||||
- **指令**:`安装并配置 everything 命令行工具进PATH。`
|
||||
|
||||
3. **Web 自动化环境配置 (Web Setup SOP)**
|
||||
- **指令**:`执行 web setup sop 解锁 web 工具`
|
||||
- **物理影响**:我将引导您完成浏览器插件安装,并注入核心脚本,使我能够直接操控您的浏览器页面。
|
||||
|
||||
4. **补全常用工具库**
|
||||
- **指令**:`安装常用 Python 自动化包(如 requests, pandas, pyperclip)。`
|
||||
|
||||
5. **配置网络代理 (Proxy Setup)**
|
||||
- **指令**:`告诉我能用的系统代理`
|
||||
|
||||
6. **激活视觉理解能力 (OCR & Vision)**
|
||||
- **指令**:`配置截图与 OCR 工具,解锁你的屏幕视觉。`
|
||||
|
||||
7. **移动端自动化准备 (Android/ADB)**
|
||||
- **指令**:`配置 ADB 环境,准备连接安卓设备。`
|
||||
|
||||
## 第三阶段:记忆与知识体系建造 (Knowledge Architecture)
|
||||
|
||||
当环境就绪后,您可以让我构建您的“数字大脑”:
|
||||
|
||||
1. **自动化 SOP 沉淀**
|
||||
- **指令**:`记录刚才的操作流程,生成一套自动化 SOP。`
|
||||
|
||||
2. **现有技能挂载 (SOP Retrieval)**
|
||||
- **指令**:`读取现有 SOP 目录,告诉我你现在掌握的所有技能。`
|
||||
|
||||
3. **物理资产审计 (Asset Audit)**
|
||||
- **指令**:`帮我建立物理资产清单,扫描并记录我常用的工具路径。`
|
||||
|
||||
4. **Web 调研实战 (Research & Report)**
|
||||
- **指令**:`搜索 [关键词],并根据网页内容整理一份简易 Markdown 报告保存到当前目录。`
|
||||
- **物理影响**:我将自动打开浏览器,利用 Web 驱动采集多个页面信息,通过逻辑整合后在您的本地文件夹生成物理文件。
|
||||
|
||||
---
|
||||
**💡 提示**:您可以直接复制上述 `指令` 发送给我,我将立刻执行对应的物理操作。
|
||||
@@ -1,4 +1,4 @@
|
||||
import json, re
|
||||
import json, re, os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
@dataclass
|
||||
@@ -6,33 +6,29 @@ class StepOutcome:
|
||||
data: Any
|
||||
next_prompt: Optional[str] = None
|
||||
should_exit: bool = False
|
||||
|
||||
def try_call_generator(func, *args, **kwargs):
|
||||
ret = func(*args, **kwargs)
|
||||
if hasattr(ret, '__iter__') and not isinstance(ret, (str, bytes, dict, list)):
|
||||
ret = yield from ret
|
||||
if hasattr(ret, '__iter__') and not isinstance(ret, (str, bytes, dict, list)): ret = yield from ret
|
||||
return ret
|
||||
|
||||
class BaseHandler:
|
||||
def tool_before_callback(self, tool_name, args, response): pass
|
||||
def tool_after_callback(self, tool_name, args, response, ret): pass
|
||||
def dispatch(self, tool_name, args, response):
|
||||
def turn_end_callback(self, response, tool_calls, tool_results, turn, next_prompt, exit_reason): return next_prompt
|
||||
def dispatch(self, tool_name, args, response, index=0, tool_num=1):
|
||||
method_name = f"do_{tool_name}"
|
||||
if hasattr(self, method_name):
|
||||
_ = yield from try_call_generator(self.tool_before_callback, tool_name, args, response)
|
||||
args['_index'] = index; args['_tool_num'] = tool_num
|
||||
prer = yield from try_call_generator(self.tool_before_callback, tool_name, args, response)
|
||||
ret = yield from try_call_generator(getattr(self, method_name), args, response)
|
||||
_ = yield from try_call_generator(self.tool_after_callback, tool_name, args, response, ret)
|
||||
return ret
|
||||
elif tool_name == 'bad_json':
|
||||
return StepOutcome(None, next_prompt=args.get('msg', 'bad_json'), should_exit=False)
|
||||
elif tool_name == 'bad_json': return StepOutcome(None, next_prompt=args.get('msg', 'bad_json'), should_exit=False)
|
||||
else:
|
||||
yield f"未知工具: {tool_name}\n"
|
||||
return StepOutcome(None, next_prompt=f"未知工具 {tool_name}", should_exit=False)
|
||||
|
||||
def json_default(o):
|
||||
if isinstance(o, set): return list(o)
|
||||
return str(o)
|
||||
|
||||
def json_default(o): return list(o) if isinstance(o, set) else str(o)
|
||||
def exhaust(g):
|
||||
try:
|
||||
while True: next(g)
|
||||
@@ -40,54 +36,90 @@ def exhaust(g):
|
||||
|
||||
def get_pretty_json(data):
|
||||
if isinstance(data, dict) and "script" in data:
|
||||
data = data.copy()
|
||||
data["script"] = data["script"].replace("; ", ";\n ")
|
||||
data = data.copy(); data["script"] = data["script"].replace("; ", ";\n ")
|
||||
return json.dumps(data, indent=2, ensure_ascii=False).replace('\\n', '\n')
|
||||
|
||||
def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema, max_turns=15, verbose=True):
|
||||
def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema, max_turns=40, verbose=True, initial_user_content=None):
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_input}
|
||||
{"role": "user", "content": initial_user_content if initial_user_content is not None else user_input}
|
||||
]
|
||||
for turn in range(max_turns):
|
||||
yield f"**LLM Running (Turn {turn+1}) ...**\n\n"
|
||||
if (turn+1) % 10 == 0: client.last_tools = '' # 每10轮重置一次工具描述,避免上下文过大导致的模型性能下降
|
||||
turn = 0; handler.max_turns = max_turns
|
||||
while turn < handler.max_turns:
|
||||
turn += 1; turnstr = f'LLM Running (Turn {turn}) ...'
|
||||
if handler.parent.task_dir: turnstr = f'Turn {turn} ...'
|
||||
if verbose: turnstr = f'**{turnstr}**'
|
||||
yield f"\n\n{turnstr}\n\n"
|
||||
if turn%10 == 0: client.last_tools = '' # 每10轮重置一次工具描述,避免上下文过大导致的模型性能下降
|
||||
response_gen = client.chat(messages=messages, tools=tools_schema)
|
||||
response = yield from response_gen
|
||||
if verbose: yield '\n\n'
|
||||
|
||||
if not response.tool_calls:
|
||||
tool_name, args = 'no_tool', {}
|
||||
else:
|
||||
tool_call = response.tool_calls[0]
|
||||
tool_name = tool_call.function.name
|
||||
args = json.loads(tool_call.function.arguments)
|
||||
|
||||
if tool_name == 'no_tool': pass
|
||||
else:
|
||||
showarg = get_pretty_json(args)
|
||||
if not verbose and len(showarg) > 200: showarg = showarg[:200] + ' ...'
|
||||
yield f"🛠️ **正在调用工具:** `{tool_name}` 📥**参数:**\n````text\n{showarg}\n````\n"
|
||||
gen = handler.dispatch(tool_name, args, response)
|
||||
if verbose:
|
||||
yield '`````\n'
|
||||
outcome = yield from gen
|
||||
yield '`````\n'
|
||||
response = yield from response_gen
|
||||
yield '\n\n'
|
||||
else:
|
||||
outcome = exhaust(gen)
|
||||
response = exhaust(response_gen)
|
||||
cleaned = _clean_content(response.content)
|
||||
if cleaned: yield cleaned + '\n'
|
||||
|
||||
if outcome.next_prompt is None: return {'result': 'CURRENT_TASK_DONE', 'data': outcome.data}
|
||||
if outcome.should_exit: return {'result': 'EXITED', 'data': outcome.data}
|
||||
if outcome.next_prompt.startswith('未知工具'): client.last_tools = ''
|
||||
if not response.tool_calls: tool_calls = [{'tool_name': 'no_tool', 'args': {}}]
|
||||
else: tool_calls = [{'tool_name': tc.function.name, 'args': json.loads(tc.function.arguments), 'id': tc.id}
|
||||
for tc in response.tool_calls]
|
||||
|
||||
tool_results = []; next_prompts = set(); exit_reason = {}
|
||||
for ii, tc in enumerate(tool_calls):
|
||||
tool_name, args, tid = tc['tool_name'], tc['args'], tc.get('id', '')
|
||||
if tool_name == 'no_tool': pass
|
||||
else:
|
||||
if verbose: yield f"🛠️ Tool: `{tool_name}` 📥 args:\n````text\n{get_pretty_json(args)}\n````\n"
|
||||
else: yield f"🛠️ {tool_name}({_compact_tool_args(tool_name, args)})\n\n\n"
|
||||
handler.current_turn = turn
|
||||
gen = handler.dispatch(tool_name, args, response, index=ii, tool_num=len(tool_calls))
|
||||
try:
|
||||
v = next(gen)
|
||||
def proxy(): yield v; return (yield from gen)
|
||||
if verbose: yield '`````\n'
|
||||
outcome = (yield from proxy()) if verbose else exhaust(proxy())
|
||||
if verbose: yield '`````\n'
|
||||
except StopIteration as e: outcome = e.value
|
||||
|
||||
if outcome.should_exit:
|
||||
exit_reason = {'result': 'EXITED', 'data': outcome.data}; break
|
||||
if not outcome.next_prompt:
|
||||
exit_reason = {'result': 'CURRENT_TASK_DONE', 'data': outcome.data}; break
|
||||
if outcome.next_prompt.startswith('未知工具'): client.last_tools = ''
|
||||
if outcome.data is not None and tool_name != 'no_tool':
|
||||
datastr = json.dumps(outcome.data, ensure_ascii=False, default=json_default) if type(outcome.data) in [dict, list] else str(outcome.data)
|
||||
tool_results.append({'tool_use_id': tid, 'content': datastr})
|
||||
next_prompts.add(outcome.next_prompt)
|
||||
if len(next_prompts) == 0 or exit_reason:
|
||||
if len(handler._done_hooks) == 0 or exit_reason.get('result', '') == 'EXITED': break
|
||||
next_prompts.add(handler._done_hooks.pop(0))
|
||||
next_prompt = handler.turn_end_callback(response, tool_calls, tool_results, turn, '\n'.join(next_prompts), exit_reason)
|
||||
messages = [{"role": "user", "content": next_prompt, "tool_results": tool_results}] # just new message, history is kept in *Session
|
||||
if exit_reason: handler.turn_end_callback(response, tool_calls, tool_results, turn, '', exit_reason)
|
||||
return exit_reason or {'result': 'MAX_TURNS_EXCEEDED'}
|
||||
|
||||
next_prompt = ""
|
||||
if outcome.data is not None:
|
||||
datastr = json.dumps(outcome.data, ensure_ascii=False, default=json_default) if type(outcome.data) in [dict, list] else str(outcome.data)
|
||||
next_prompt += f"<tool_result>\n{datastr}\n</tool_result>\n\n"
|
||||
next_prompt += outcome.next_prompt
|
||||
if (turn+1) % 7 == 0:
|
||||
next_prompt += f"\n\n[DANGER] 已连续执行第 {turn+1} 轮。禁止无效重试。若无有效进展,必须切换策略:1. 探测物理边界 2. 请求用户协助。"
|
||||
if (turn+1) % 30 == 0:
|
||||
next_prompt += f"\n\n### [DANGER] 已连续执行第 {turn+1} 轮。你必须总结情况进行ask_user,不允许继续重试。"
|
||||
messages = [{"role": "user", "content": next_prompt}]
|
||||
return {'result': 'MAX_TURNS_EXCEEDED'}
|
||||
def _clean_content(text):
|
||||
if not text: return ''
|
||||
def _shrink_code(m):
|
||||
lines = m.group(0).split('\n')
|
||||
lang = lines[0].replace('```','').strip()
|
||||
body = [l for l in lines[1:-1] if l.strip()]
|
||||
if len(body) <= 6: return m.group(0)
|
||||
preview = '\n'.join(body[:5])
|
||||
return f'```{lang}\n{preview}\n ... ({len(body)} lines)\n```'
|
||||
text = re.sub(r'```[\s\S]*?```', _shrink_code, text)
|
||||
for p in [r'<file_content>[\s\S]*?</file_content>', r'<tool_(?:use|call)>[\s\S]*?</tool_(?:use|call)>', r'(\r?\n){3,}']:
|
||||
text = re.sub(p, '\n\n' if '\\n' in p else '', text)
|
||||
return text.strip()
|
||||
|
||||
def _compact_tool_args(name, args):
|
||||
a = {k: v for k, v in args.items() if k != '_index'}
|
||||
for k in ('path',):
|
||||
if k in a: a[k] = os.path.basename(a[k])
|
||||
if name == 'update_working_checkpoint': s = a.get('key_info', ''); return (s[:60]+'...') if len(s)>60 else s
|
||||
if name == 'ask_user':
|
||||
q = str(a.get('question', ''))
|
||||
cs = a.get('candidates') or []
|
||||
if cs: q += '\ncandidates:\n' + '\n'.join(f'- {c}' for c in cs)
|
||||
return q
|
||||
s = json.dumps(a, ensure_ascii=False); return (s[:120]+'...') if len(s)>120 else s
|
||||
@@ -1,99 +1,157 @@
|
||||
import os, sys, threading, queue, time, json, re, random
|
||||
import os, sys, threading, queue, time, json, re, random, locale
|
||||
os.environ.setdefault('GA_LANG', 'zh' if any(k in (locale.getlocale()[0] or '').lower() for k in ('zh', 'chinese')) else 'en')
|
||||
if sys.stdout is None: sys.stdout = open(os.devnull, "w")
|
||||
elif hasattr(sys.stdout, 'reconfigure'): sys.stdout.reconfigure(errors='replace')
|
||||
if sys.stderr is None: sys.stderr = open(os.devnull, "w")
|
||||
elif hasattr(sys.stderr, 'reconfigure'): sys.stderr.reconfigure(errors='replace')
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
from sidercall import SiderLLMSession, LLMSession, ToolClient, ClaudeSession, XaiSession
|
||||
from agent_loop import agent_runner_loop, StepOutcome, BaseHandler
|
||||
from ga import GenericAgentHandler, smart_format, get_global_memory, format_error
|
||||
from llmcore import reload_mykeys, LLMSession, ToolClient, ClaudeSession, MixinSession, NativeToolClient, NativeClaudeSession, NativeOAISession, resolve_client
|
||||
from agent_loop import agent_runner_loop
|
||||
from ga import GenericAgentHandler, smart_format, get_global_memory, format_error, consume_file
|
||||
|
||||
with open('assets/tools_schema.json', 'r', encoding='utf-8') as f:
|
||||
TS = f.read()
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
def load_tool_schema(suffix=''):
|
||||
global TOOLS_SCHEMA
|
||||
TS = open(os.path.join(script_dir, f'assets/tools_schema{suffix}.json'), 'r', encoding='utf-8').read()
|
||||
TOOLS_SCHEMA = json.loads(TS if os.name == 'nt' else TS.replace('powershell', 'bash'))
|
||||
load_tool_schema()
|
||||
|
||||
lang_suffix = '_en' if os.environ.get('GA_LANG', '') == 'en' else ''
|
||||
mem_dir = os.path.join(script_dir, 'memory')
|
||||
if not os.path.exists(mem_dir): os.makedirs(mem_dir)
|
||||
mem_txt = os.path.join(mem_dir, 'global_mem.txt')
|
||||
if not os.path.exists(mem_txt): open(mem_txt, 'w', encoding='utf-8').write('# [Global Memory - L2]\n')
|
||||
mem_insight = os.path.join(mem_dir, 'global_mem_insight.txt')
|
||||
if not os.path.exists(mem_insight):
|
||||
t = os.path.join(script_dir, f'assets/global_mem_insight_template{lang_suffix}.txt')
|
||||
open(mem_insight, 'w', encoding='utf-8').write(open(t, encoding='utf-8').read() if os.path.exists(t) else '')
|
||||
cdp_cfg = os.path.join(script_dir, 'assets/tmwd_cdp_bridge/config.js')
|
||||
if not os.path.exists(cdp_cfg):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(cdp_cfg), exist_ok=True)
|
||||
open(cdp_cfg, 'w', encoding='utf-8').write(f"const TID = '__ljq_{hex(random.randint(0, 99999999))[2:8]}';")
|
||||
except Exception as e: print(f'[WARN] CDP config init failed: {e} — advanced web features (tmwebdriver) will be unavailable.')
|
||||
|
||||
def get_system_prompt():
|
||||
if not os.path.exists('memory'): os.makedirs('memory')
|
||||
if not os.path.exists('memory/global_mem.txt'):
|
||||
with open('memory/global_mem.txt', 'w', encoding='utf-8') as f: f.write('')
|
||||
if not os.path.exists('memory/global_mem_insight.txt'):
|
||||
t = 'assets/global_mem_insight_template.txt'
|
||||
open('memory/global_mem_insight.txt', 'w', encoding='utf-8').write(open(t, encoding='utf-8').read() if os.path.exists(t) else '')
|
||||
with open('assets/sys_prompt.txt', 'r', encoding='utf-8') as f: prompt = f.read()
|
||||
with open(os.path.join(script_dir, f'assets/sys_prompt{lang_suffix}.txt'), 'r', encoding='utf-8') as f: prompt = f.read()
|
||||
prompt += f"\nToday: {time.strftime('%Y-%m-%d %a')}\n"
|
||||
prompt += get_global_memory()
|
||||
return prompt
|
||||
|
||||
class GeneraticAgent:
|
||||
class GenericAgent:
|
||||
def __init__(self):
|
||||
if not os.path.exists('temp'): os.makedirs('temp')
|
||||
from sidercall import mykeys
|
||||
os.makedirs(os.path.join(script_dir, 'temp'), exist_ok=True)
|
||||
self.lock = threading.Lock()
|
||||
self.task_dir = None
|
||||
self.history = []; self.handler = None;
|
||||
self.task_queue = queue.Queue()
|
||||
self.is_running = False; self.stop_sig = False
|
||||
self.llm_no = 0; self.inc_out = False; self.verbose = True
|
||||
self.peer_hint = True
|
||||
self.log_path = os.path.join(script_dir, f'temp/model_responses/model_responses_{int(time.time()*1e6)%1000000:06d}.txt')
|
||||
self.load_llm_sessions()
|
||||
|
||||
def load_llm_sessions(self):
|
||||
mykeys, changed = reload_mykeys()
|
||||
if not changed and hasattr(self, 'llmclients'): return
|
||||
try: oldhistory = self.llmclient.backend.history
|
||||
except: oldhistory = None
|
||||
llm_sessions = []
|
||||
for k, cfg in mykeys.items():
|
||||
if not any(x in k for x in ['api', 'config', 'cookie']): continue
|
||||
try:
|
||||
if 'claude' in k: llm_sessions += [ClaudeSession(api_key=cfg['apikey'], api_base=cfg['apibase'], model=cfg['model'])]
|
||||
if 'oai' in k: llm_sessions += [LLMSession(api_key=cfg['apikey'], api_base=cfg['apibase'], model=cfg['model'], proxy=cfg.get('proxy'))]
|
||||
if 'xai' in k: llm_sessions += [XaiSession(cfg, mykeys.get('proxy', ''))]
|
||||
if 'sider' in k: llm_sessions += [SiderLLMSession(cfg, default_model=x) for x in \
|
||||
["gemini-3.0-flash", "claude-haiku-4.5", "kimi-k2"]]
|
||||
if 'mixin' in k: llm_sessions += [{'mixin_cfg': cfg}]
|
||||
elif c := resolve_client(k): llm_sessions += [c]
|
||||
except: pass
|
||||
if len(llm_sessions) > 0: self.llmclient = ToolClient(llm_sessions, auto_save_tokens=True)
|
||||
else: self.llmclient = None
|
||||
self.lock = threading.Lock()
|
||||
self.history = []
|
||||
self.task_queue = queue.Queue()
|
||||
self.is_running, self.stop_sig = False, False
|
||||
self.llm_no = 0
|
||||
self.inc_out = False
|
||||
self.handler = None
|
||||
self.verbose = True
|
||||
|
||||
for i, s in enumerate(llm_sessions):
|
||||
if isinstance(s, dict) and 'mixin_cfg' in s:
|
||||
try:
|
||||
mixin = MixinSession(llm_sessions, s['mixin_cfg'])
|
||||
if isinstance(mixin._sessions[0], (NativeClaudeSession, NativeOAISession)): llm_sessions[i] = NativeToolClient(mixin)
|
||||
else: llm_sessions[i] = ToolClient(mixin)
|
||||
except Exception as e: print(f'\n\n\n[ERROR] Failed to init MixinSession with cfg {s["mixin_cfg"]}: {e}!!!\n\n')
|
||||
self.llmclients = llm_sessions
|
||||
self.llmclient = self.llmclients[self.llm_no%len(self.llmclients)]
|
||||
if oldhistory: self.llmclient.backend.history = oldhistory
|
||||
|
||||
def next_llm(self, n=-1):
|
||||
self.llm_no = ((self.llm_no + 1) if n < 0 else n) % len(self.llmclient.backends)
|
||||
self.load_llm_sessions()
|
||||
self.llm_no = ((self.llm_no + 1) if n < 0 else n) % len(self.llmclients)
|
||||
lastc = self.llmclient
|
||||
self.llmclient = self.llmclients[self.llm_no]
|
||||
try: self.llmclient.backend.history = lastc.backend.history
|
||||
except: raise Exception('[ERROR] BAD Mixin config: Check your mykey.py')
|
||||
self.llmclient.last_tools = ''
|
||||
def list_llms(self): return [(i, f"{type(b).__name__}/{b.default_model}", i == self.llm_no) for i, b in enumerate(self.llmclient.backends)]
|
||||
def get_llm_name(self):
|
||||
b = self.llmclient.backends[self.llm_no]
|
||||
return f"{type(b).__name__}/{b.default_model}"
|
||||
name = self.get_llm_name(model=True)
|
||||
if 'glm' in name or 'minimax' in name or 'kimi' in name: load_tool_schema('_cn')
|
||||
else: load_tool_schema()
|
||||
def list_llms(self):
|
||||
self.load_llm_sessions()
|
||||
return [(i, self.get_llm_name(b), i == self.llm_no) for i, b in enumerate(self.llmclients)]
|
||||
def get_llm_name(self, b=None, model=False):
|
||||
b = self.llmclient if b is None else b
|
||||
if isinstance(b, dict): return 'BADCONFIG_MIXIN'
|
||||
if model: return b.backend.model.lower()
|
||||
return f"{type(b.backend).__name__}/{b.backend.name}"
|
||||
|
||||
def abort(self):
|
||||
print('Abort current task...')
|
||||
if not self.is_running: return
|
||||
print('Abort current task...')
|
||||
self.stop_sig = True
|
||||
if self.handler is not None:
|
||||
self.handler.code_stop_signal.append(1)
|
||||
if self.handler is not None: self.handler.code_stop_signal.append(1)
|
||||
|
||||
def put_task(self, query, source="user"):
|
||||
def put_task(self, query, source="user", images=None):
|
||||
display_queue = queue.Queue()
|
||||
self.task_queue.put({"query": query, "source": source, "output": display_queue})
|
||||
self.task_queue.put({"query": query, "source": source, "images": images or [], "output": display_queue})
|
||||
return display_queue
|
||||
|
||||
# i know it is dangerous, but raw_query is dangerous enough it doesn't enlarge
|
||||
def _handle_slash_cmd(self, raw_query, display_queue):
|
||||
if not raw_query.startswith('/'): return raw_query
|
||||
if _sm := re.match(r'/session\.(\w+)=(.*)', raw_query.strip()):
|
||||
k, v = _sm.group(1), _sm.group(2)
|
||||
vfile = os.path.join(script_dir, 'temp', v)
|
||||
if os.path.isfile(vfile): v = open(vfile, encoding='utf-8').read().strip()
|
||||
try: v = json.loads(v) # cover number parsing
|
||||
except (json.JSONDecodeError, ValueError): pass
|
||||
setattr(self.llmclient.backend, k, v)
|
||||
display_queue.put({'done': smart_format(f"✅ session.{k} = {repr(v)}", max_str_len=500), 'source': 'system'})
|
||||
return None
|
||||
if raw_query.strip() == '/resume':
|
||||
return r'帮我看看最近有哪些会话可以恢复。读model_responses/目录,按修改时间取最近10个文件,从每个文件里找最后一个<history>...</history>块,用一句话总结每个会话在聊什么,列表给我选。注意读文件后要把字面的\n替换成真换行才能正确匹配。'
|
||||
return raw_query
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
task = self.task_queue.get()
|
||||
self.is_running = True
|
||||
raw_query, source, display_queue = task["query"], task["source"], task["output"]
|
||||
raw_query = self._handle_slash_cmd(raw_query, display_queue)
|
||||
if raw_query is None:
|
||||
self.task_queue.task_done(); continue
|
||||
self.is_running = True
|
||||
rquery = smart_format(raw_query.replace('\n', ' '), max_str_len=200)
|
||||
self.history.append(f"[USER]: {rquery}")
|
||||
|
||||
sys_prompt = get_system_prompt()
|
||||
handler = GenericAgentHandler(None, self.history, './temp')
|
||||
if self.handler and self.handler.key_info:
|
||||
handler.key_info = self.handler.key_info
|
||||
if '清除工作记忆' not in handler.key_info:
|
||||
handler.key_info += '\n[SYSTEM] 如果是新任务,请先更新或清除工作记忆\n'
|
||||
self.handler = handler
|
||||
self.llmclient.backend = self.llmclient.backends[self.llm_no]
|
||||
sys_prompt = get_system_prompt() + getattr(self.llmclient.backend, 'extra_sys_prompt', '')
|
||||
if self.peer_hint: sys_prompt += f"\n[Peer] 用户提及其他会话/后台任务状态时: temp/model_responses/ (只找近期修改的文件尾部)\n"
|
||||
handler = GenericAgentHandler(self, self.history, os.path.join(script_dir, 'temp'))
|
||||
if self.handler and 'key_info' in self.handler.working:
|
||||
ki = re.sub(r'\n\[SYSTEM\] 此为.*?工作记忆[。\n]*', '', self.handler.working['key_info']) # 去旧
|
||||
handler.working['key_info'] = ki
|
||||
handler.working['passed_sessions'] = ps = self.handler.working.get('passed_sessions', 0) + 1
|
||||
if ps > 0: handler.working['key_info'] += f'\n[SYSTEM] 此为 {ps} 个对话前设置的key_info,若已在新任务,先更新或清除工作记忆。\n'
|
||||
self.handler = handler # although new handler, the **full** history is in llmclient, so it is full history!
|
||||
self.llmclient.log_path = self.log_path
|
||||
gen = agent_runner_loop(self.llmclient, sys_prompt, raw_query,
|
||||
handler, TOOLS_SCHEMA, max_turns=40, verbose=self.verbose)
|
||||
handler, TOOLS_SCHEMA, max_turns=70, verbose=self.verbose)
|
||||
try:
|
||||
full_resp = ""; last_pos = 0
|
||||
for chunk in gen:
|
||||
if consume_file(self.task_dir, '_stop'): self.abort()
|
||||
if self.stop_sig: break
|
||||
full_resp += chunk
|
||||
if len(full_resp) - last_pos > 50:
|
||||
if len(full_resp) - last_pos > 50 or 'LLM Running' in chunk:
|
||||
display_queue.put({'next': full_resp[last_pos:] if self.inc_out else full_resp, 'source': source})
|
||||
last_pos = len(full_resp)
|
||||
if self.inc_out and last_pos < len(full_resp): display_queue.put({'next': full_resp[last_pos:], 'source': source})
|
||||
@@ -105,57 +163,103 @@ class GeneraticAgent:
|
||||
print(f"Backend Error: {format_error(e)}")
|
||||
display_queue.put({'done': full_resp + f'\n```\n{format_error(e)}\n```', 'source': source})
|
||||
finally:
|
||||
if self.stop_sig: print('User aborted the task.')
|
||||
self.is_running = self.stop_sig = False
|
||||
self.task_queue.task_done()
|
||||
if self.handler is not None: self.handler.code_stop_signal.append(1)
|
||||
|
||||
|
||||
GeneraticAgent = GenericAgent
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--scheduled', action='store_true', help='计划任务轮询模式')
|
||||
parser.add_argument('--task', metavar='IODIR', help='一次性任务模式(文件IO)')
|
||||
parser.add_argument('--llm_no', type=int, default=0, help='LLM编号')
|
||||
args = parser.parse_args()
|
||||
parser.add_argument('--reflect', metavar='SCRIPT', help='反射模式:加载监控脚本,check()触发时发任务')
|
||||
parser.add_argument('--input', help='prompt')
|
||||
parser.add_argument('--llm_no', type=int, default=0)
|
||||
parser.add_argument('--verbose', action='store_true')
|
||||
parser.add_argument('--nobg', action='store_true')
|
||||
args, _unknown = parser.parse_known_args()
|
||||
_reflect_args = dict(zip([k.lstrip('-') for k in _unknown[::2]], _unknown[1::2])) if _unknown else {}
|
||||
|
||||
if args.task and not args.nobg:
|
||||
import subprocess, platform
|
||||
cmd = [sys.executable, os.path.abspath(__file__)] + [a for a in sys.argv[1:]] + ['--nobg']
|
||||
d = os.path.join(script_dir, f'temp/{args.task}'); os.makedirs(d, exist_ok=True)
|
||||
p = subprocess.Popen(cmd, cwd=script_dir,
|
||||
creationflags=0x08000000 if platform.system() == 'Windows' else 0,
|
||||
stdout=open(os.path.join(d, 'stdout.log'), 'w', encoding='utf-8'),
|
||||
stderr=open(os.path.join(d, 'stderr.log'), 'w', encoding='utf-8'))
|
||||
print(p.pid); sys.exit(0)
|
||||
|
||||
agent = GeneraticAgent()
|
||||
agent.llm_no = args.llm_no
|
||||
agent.verbose = False
|
||||
agent.next_llm(args.llm_no)
|
||||
agent.verbose = args.verbose
|
||||
threading.Thread(target=agent.run, daemon=True).start()
|
||||
|
||||
if args.task:
|
||||
d = f'temp/{args.task}'; rp = f'{d}/reply.txt'; nround = ''
|
||||
with open(f'{d}/input.txt', encoding='utf-8') as f: raw = f.read()
|
||||
agent.peer_hint = False
|
||||
agent.task_dir = d = os.path.join(script_dir, f'temp/{args.task}'); nround = ''
|
||||
infile = os.path.join(d, 'input.txt')
|
||||
if args.input:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
import glob; [os.remove(f) for f in glob.glob(os.path.join(d, 'output*.txt'))]
|
||||
with open(infile, 'w', encoding='utf-8') as f: f.write(args.input)
|
||||
if (fh := consume_file(d, '_history.json')): agent.llmclient.backend.history = json.loads(fh)
|
||||
with open(infile, encoding='utf-8') as f: raw = f.read()
|
||||
while True:
|
||||
dq = agent.put_task(raw, source='task')
|
||||
while 'done' not in (item := dq.get(timeout=120)):
|
||||
if 'next' in item and random.random() < 0.05: # 1/20的概率写一次中间结果
|
||||
while 'done' not in (item := dq.get(timeout=300)):
|
||||
if 'next' in item and random.random() < 0.95: # 概率写一次中间结果
|
||||
with open(f'{d}/output{nround}.txt', 'w', encoding='utf-8') as f: f.write(item.get('next', ''))
|
||||
with open(f'{d}/output{nround}.txt', 'w', encoding='utf-8') as f: f.write(item['done'] + '\n[ROUND END]\n')
|
||||
for _ in range(150): # 等reply.txt,5分钟超时
|
||||
with open(f'{d}/output{nround}.txt', 'w', encoding='utf-8') as f: f.write(item['done'] + '\n\n[ROUND END]\n')
|
||||
consume_file(d, '_stop') # 已经成功停下来了,避免打断下次reply
|
||||
for _ in range(300): # 等reply.txt,10分钟超时
|
||||
time.sleep(2)
|
||||
if os.path.exists(rp):
|
||||
with open(rp, encoding='utf-8') as f: raw = f.read()
|
||||
os.remove(rp); break
|
||||
if (raw := consume_file(d, 'reply.txt')): break
|
||||
else: break
|
||||
nround = int(nround) + 1 if nround.isdigit() else 1
|
||||
elif args.scheduled:
|
||||
def drain(dq, tag):
|
||||
while 'done' not in (item := dq.get()): pass
|
||||
open('./temp/scheduler.log', 'a', encoding='utf-8').write(f'[{datetime.now():%m-%d %H:%M}] {tag}\n{item["done"]}\n\n')
|
||||
nround = nround + 1 if isinstance(nround, int) else 1
|
||||
elif args.reflect:
|
||||
agent.peer_hint = False
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location('reflect_script', args.reflect)
|
||||
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||
if hasattr(mod, 'init'): mod.init(_reflect_args)
|
||||
_mt = os.path.getmtime(args.reflect)
|
||||
print(f'[Reflect] loaded {args.reflect}' + (f' args={_reflect_args}' if _reflect_args else ''))
|
||||
while True:
|
||||
time.sleep(55 + random.random() * 10)
|
||||
now = datetime.now()
|
||||
if not os.path.isdir('./sche_tasks/pending'): continue
|
||||
for f in os.listdir('./sche_tasks/pending'):
|
||||
m = re.match(r'(\d{4}-\d{2}-\d{2})_(\d{4})_', f)
|
||||
if m and now >= datetime.strptime(f'{m[1]} {m[2]}', '%Y-%m-%d %H%M'):
|
||||
raw = open(f'./sche_tasks/pending/{f}', encoding='utf-8').read()
|
||||
dq = agent.put_task(f'按scheduled_task_sop执行任务文件 ../sche_tasks/pending/{f}(立刻移到running)\n内容:\n{raw}', source='scheduler')
|
||||
threading.Thread(target=drain, args=(dq, f), daemon=True).start()
|
||||
break
|
||||
if os.path.getmtime(args.reflect) != _mt:
|
||||
try:
|
||||
spec.loader.exec_module(mod); _mt = os.path.getmtime(args.reflect)
|
||||
if hasattr(mod, 'init'): mod.init(_reflect_args)
|
||||
print('[Reflect] reloaded')
|
||||
except Exception as e: print(f'[Reflect] reload error: {e}')
|
||||
time.sleep(getattr(mod, 'INTERVAL', 5))
|
||||
try: task = mod.check()
|
||||
except Exception as e:
|
||||
print(f'[Reflect] check() error: {e}'); continue
|
||||
if task and task == '/exit': break
|
||||
if task is None: continue
|
||||
print(f'[Reflect] triggered: {task[:80]}')
|
||||
dq = agent.put_task(task, source='reflect')
|
||||
try:
|
||||
while 'done' not in (item := dq.get(timeout=180)): pass
|
||||
result = item['done']
|
||||
print(result)
|
||||
except Exception as e:
|
||||
if getattr(mod, 'ONCE', False): raise
|
||||
print(f'[Reflect] drain error: {e}'); result = f'[ERROR] {e}'
|
||||
log_dir = os.path.join(script_dir, 'temp/reflect_logs'); os.makedirs(log_dir, exist_ok=True)
|
||||
script_name = os.path.splitext(os.path.basename(args.reflect))[0]
|
||||
open(os.path.join(log_dir, f'{script_name}_{datetime.now():%Y-%m-%d}.log'), 'a', encoding='utf-8').write(f'[{datetime.now():%m-%d %H:%M}]\n{result}\n\n')
|
||||
if (on_done := getattr(mod, 'on_done', None)):
|
||||
try: on_done(result)
|
||||
except Exception as e: print(f'[Reflect] on_done error: {e}')
|
||||
if getattr(mod, 'ONCE', False): print('[Reflect] ONCE=True, exiting.'); break
|
||||
else:
|
||||
try: import readline
|
||||
except Exception: pass
|
||||
agent.inc_out = True
|
||||
while True:
|
||||
q = input('> ').strip()
|
||||
@@ -168,4 +272,4 @@ if __name__ == '__main__':
|
||||
if 'done' in item: print(); break
|
||||
except KeyboardInterrupt:
|
||||
agent.abort()
|
||||
print('\n[Interrupted]')
|
||||
print('\n[Interrupted]')
|
||||
@@ -0,0 +1,301 @@
|
||||
# 飞书 Agent 配置指南
|
||||
|
||||
> 让你的个人电脑变成飞书机器人的大脑,随时随地通过飞书对话控制你的电脑。
|
||||
|
||||
---
|
||||
|
||||
## 📋 目录
|
||||
|
||||
1. [前置条件](#前置条件)
|
||||
2. [方案选择](#方案选择)
|
||||
3. [企业用户配置](#企业用户配置)
|
||||
4. [个人用户配置](#个人用户配置)
|
||||
5. [项目配置](#项目配置)
|
||||
6. [运行与测试](#运行与测试)
|
||||
7. [常见问题](#常见问题)
|
||||
|
||||
---
|
||||
|
||||
## 前置条件
|
||||
|
||||
### 必需环境
|
||||
|
||||
- Python 3.8+
|
||||
- 本项目完整代码
|
||||
- LLM API 密钥(Claude/OpenAI 等,已在 `llmcore/mykeys` 中配置)
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
pip install lark-oapi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方案选择
|
||||
|
||||
| 你的情况 | 推荐方案 | 预计耗时 |
|
||||
| ------------------ | -------------------------- | --------- |
|
||||
| 公司已有飞书企业版 | [企业用户配置](#企业用户配置) | 5-10分钟 |
|
||||
| 个人用户/学习测试 | [个人用户配置](#个人用户配置) | 10-15分钟 |
|
||||
|
||||
---
|
||||
|
||||
## 企业用户配置
|
||||
|
||||
> 适用于:你的公司使用飞书,你有权限创建应用或联系管理员审批
|
||||
|
||||
### 步骤 1:创建应用
|
||||
|
||||
1. 访问 [飞书开放平台](https://open.feishu.cn/)
|
||||
2. 登录你的企业飞书账号
|
||||
3. 点击右上角「创建应用」→「企业自建应用」
|
||||
4. 填写应用信息:
|
||||
- 应用名称:`我的Agent助手`(可自定义)
|
||||
- 应用描述:`个人AI助手`
|
||||
- 应用图标:可选
|
||||
|
||||
### 步骤 2:添加机器人能力
|
||||
|
||||
1. 进入应用详情页
|
||||
2. 左侧菜单选择「添加应用能力」
|
||||
3. 找到「机器人」,点击「添加」
|
||||
4. 配置机器人信息(可保持默认)
|
||||
|
||||
### 步骤 3:配置权限
|
||||
|
||||
1. 左侧菜单「权限管理」→「API 权限」
|
||||
2. 搜索并开通以下权限:
|
||||
- `im:message` - 获取与发送单聊、群组消息
|
||||
- `im:message:send_as_bot` - 以应用身份发送消息
|
||||
- `contact:user.id:readonly` - 获取用户 ID
|
||||
|
||||
### 步骤 4:获取凭证
|
||||
|
||||
1. 左侧菜单「凭证与基础信息」
|
||||
2. 记录以下信息:
|
||||
- **App ID**:`cli_xxxxxxxx`
|
||||
- **App Secret**:`xxxxxxxxxxxxxxxx`
|
||||
|
||||
### 步骤 5:发布应用
|
||||
|
||||
1. 左侧菜单「版本管理与发布」
|
||||
2. 点击「创建版本」
|
||||
3. 填写版本信息,提交审核
|
||||
4. **联系企业管理员审批**(或自己是管理员直接审批)
|
||||
|
||||
### 步骤 6:获取你的 Open ID
|
||||
|
||||
1. 应用审批通过后,在飞书中搜索你的机器人
|
||||
2. 给机器人发送任意消息
|
||||
3. 运行以下代码获取你的 Open ID:
|
||||
|
||||
```python
|
||||
# 临时运行一次,获取 open_id
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.im.v1 import *
|
||||
|
||||
client = lark.Client.builder().app_id("你的APP_ID").app_secret("你的APP_SECRET").build()
|
||||
|
||||
# 监听消息,打印发送者的 open_id
|
||||
def handle(data):
|
||||
print(f"你的 Open ID: {data.event.sender.sender_id.open_id}")
|
||||
|
||||
# ... 或者查看 frontends/fsapp.py 运行时的日志输出
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 个人用户配置
|
||||
|
||||
> 适用于:没有企业飞书账号,想个人测试使用
|
||||
|
||||
### 步骤 1:创建测试企业
|
||||
|
||||
1. 访问 [飞书开放平台](https://open.feishu.cn/)
|
||||
2. 使用个人手机号注册/登录
|
||||
3. 点击右上角头像 →「创建测试企业」
|
||||
4. 填写企业名称(如:`我的测试工作区`)
|
||||
5. 创建完成后,你就是这个测试企业的**管理员**
|
||||
|
||||
### 步骤 2:创建应用
|
||||
|
||||
> 与企业用户步骤相同
|
||||
|
||||
1. 点击「创建应用」→「企业自建应用」
|
||||
2. 填写应用信息
|
||||
|
||||
### 步骤 3:添加机器人能力
|
||||
|
||||
1. 进入应用详情页
|
||||
2. 「添加应用能力」→「机器人」→「添加」
|
||||
|
||||
### 步骤 4:配置权限
|
||||
|
||||
1. 「权限管理」→「API 权限」
|
||||
2. 开通权限:
|
||||
- `im:message`
|
||||
- `im:message:send_as_bot`
|
||||
- `contact:user.id:readonly`
|
||||
|
||||
### 步骤 5:获取凭证
|
||||
|
||||
1. 「凭证与基础信息」
|
||||
2. 复制 **App ID** 和 **App Secret**
|
||||
|
||||
### 步骤 6:发布应用(测试企业可自审批)
|
||||
|
||||
1. 「版本管理与发布」→「创建版本」
|
||||
2. 提交后,进入 [飞书管理后台](https://feishu.cn/admin)
|
||||
3. 「工作台」→「应用审核」→ 通过你的应用
|
||||
|
||||
### 步骤 7:在飞书客户端使用
|
||||
|
||||
1. 下载 [飞书客户端](https://www.feishu.cn/download)
|
||||
2. 登录你的测试企业账号
|
||||
3. 搜索你创建的机器人名称
|
||||
4. 开始对话!
|
||||
|
||||
---
|
||||
|
||||
## 项目配置
|
||||
|
||||
### 配置飞书凭证
|
||||
|
||||
编辑项目根目录的 `mykey.py`,添加:
|
||||
|
||||
```python
|
||||
# 飞书应用凭证
|
||||
fs_app_id = "cli_xxxxxxxxxxxxxxxx" # 替换为你的 App ID
|
||||
fs_app_secret = "xxxxxxxxxxxxxxxx" # 替换为你的 App Secret
|
||||
|
||||
# 允许使用的用户 Open ID 列表(可选,留空则允许所有人)
|
||||
fs_allowed_users = [
|
||||
"ou_xxxxxxxxxxxxxxxxxxxxxxxx", # 你的 Open ID
|
||||
]
|
||||
```
|
||||
|
||||
### 确认 LLM 配置
|
||||
|
||||
确保 `llmcore/mykeys` 中已配置 LLM API 密钥:
|
||||
|
||||
```python
|
||||
# 示例:Claude API
|
||||
claude_config = {
|
||||
'apikey': 'sk-ant-xxxxx',
|
||||
'apibase': 'https://api.anthropic.com',
|
||||
'model': 'claude-sonnet-4-20250514'
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 运行与测试
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
cd /path/to/pc-agent-loop
|
||||
python frontends/fsapp.py
|
||||
```
|
||||
|
||||
### 预期输出
|
||||
|
||||
```
|
||||
==================================================
|
||||
飞书 Agent 已启动(长连接模式)
|
||||
App ID: cli_xxxxxxxxxxxxxxxx
|
||||
等待消息...
|
||||
==================================================
|
||||
```
|
||||
|
||||
### 测试对话
|
||||
|
||||
1. 打开飞书客户端
|
||||
2. 找到你的机器人
|
||||
3. 发送:`你好`
|
||||
4. 等待回复(首次可能需要几秒)
|
||||
|
||||
---
|
||||
|
||||
## 可用命令
|
||||
|
||||
在与机器人对话时,可以使用以下特殊命令:
|
||||
|
||||
| 命令 | 说明 |
|
||||
| ---- | ---- |
|
||||
| `/new` | 开始新对话,清除当前上下文 |
|
||||
| `/stop` | 中止当前正在执行的任务 |
|
||||
| `/restore <关键词>` | 恢复之前的对话上下文(根据关键词搜索历史记录) |
|
||||
|
||||
### 命令示例
|
||||
|
||||
```
|
||||
/new # 清空对话,重新开始
|
||||
/stop # 停止正在运行的任务
|
||||
/restore 昨天的任务 # 恢复包含"昨天的任务"关键词的历史对话
|
||||
```
|
||||
|
||||
### 消息显示说明
|
||||
|
||||
- ⏳ 表示任务正在执行中
|
||||
- 消息会实时更新,无需等待完成
|
||||
- 超长回复会自动分段发送
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 提示「应用未发布」或「无权限」
|
||||
|
||||
**A:** 确保应用已发布且管理员已审批。测试企业用户需要在管理后台手动审批。
|
||||
|
||||
### Q: 发送消息后没有回复
|
||||
|
||||
**A:** 检查:
|
||||
|
||||
1. `frontends/fsapp.py` 是否在运行
|
||||
2. 终端是否有错误日志
|
||||
3. LLM API 密钥是否配置正确
|
||||
|
||||
### Q: 提示「invalid app_id」
|
||||
|
||||
**A:** 检查 `mykey.py` 中的 `fs_app_id` 是否正确复制(包含 `cli_` 前缀)
|
||||
|
||||
### Q: 如何获取自己的 Open ID?
|
||||
|
||||
**A:** 运行 `frontends/fsapp.py` 后给机器人发消息,查看终端日志中的 `open_id`
|
||||
|
||||
### Q: 能否多人同时使用?
|
||||
|
||||
**A:** 不能。一个应用只能有一个长连接,连接到一台电脑。每个人需要创建自己的应用。
|
||||
|
||||
---
|
||||
|
||||
## 架构说明
|
||||
|
||||
```
|
||||
你的飞书 ←→ 飞书云 ←→ 长连接 ←→ frontends/fsapp.py ←→ Agent ←→ 你的电脑
|
||||
↑
|
||||
运行在你电脑上
|
||||
```
|
||||
|
||||
- 消息通过飞书云转发到你电脑上运行的 `frontends/fsapp.py`
|
||||
- Agent 处理请求后,通过飞书 API 回复消息
|
||||
- **你的电脑必须保持运行** `frontends/fsapp.py` 才能响应消息
|
||||
|
||||
---
|
||||
|
||||
## 下一步
|
||||
|
||||
- 自定义 Agent 行为:编辑 `assets/sys_prompt.txt`
|
||||
- 添加新工具:编辑 `assets/tools_schema.json`
|
||||
- 查看日志:运行时观察终端输出
|
||||
|
||||
---
|
||||
|
||||
*文档版本:v1.1 | 更新日期:2026-03-07*
|
||||
|
||||
**v1.1 更新内容:**
|
||||
- 新增「可用命令」章节(/new, /stop, /restore)
|
||||
- 新增消息显示说明(⏳ 进行中标记、实时更新等)
|
||||
@@ -0,0 +1,215 @@
|
||||
# agent_bbs.py — 极简Agent公告板(多板块版)
|
||||
# 启动: uvicorn agent_bbs:app --host 0.0.0.0 --port 58800
|
||||
# 或: python agent_bbs.py
|
||||
|
||||
import sqlite3, uuid, time, json, os
|
||||
from threading import Lock
|
||||
from fastapi import FastAPI, HTTPException, Query, Body, UploadFile, File
|
||||
from fastapi.responses import JSONResponse, HTMLResponse, PlainTextResponse, FileResponse
|
||||
from contextlib import contextmanager
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
# key → board config; 修改 boards.json 可热重载新增板块
|
||||
BOARDS_FILE = "boards.json"
|
||||
DEFAULT_BOARDS = {"agent-bbs-test": {"name": "default", "db": "agent_bbs.db"}}
|
||||
BOARDS, BOARDS_MTIME_NS, BOARDS_LOCK = DEFAULT_BOARDS, None, Lock()
|
||||
|
||||
def load_boards_if_changed():
|
||||
global BOARDS, BOARDS_MTIME_NS
|
||||
with BOARDS_LOCK:
|
||||
if not os.path.exists(BOARDS_FILE):
|
||||
json.dump(DEFAULT_BOARDS, open(BOARDS_FILE, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
mtime = os.stat(BOARDS_FILE).st_mtime_ns
|
||||
if mtime == BOARDS_MTIME_NS: return BOARDS
|
||||
try:
|
||||
new = json.load(open(BOARDS_FILE, "r", encoding="utf-8"))
|
||||
assert isinstance(new, dict) and all(isinstance(v, dict) and "db" in v and "name" in v for v in new.values())
|
||||
BOARDS, BOARDS_MTIME_NS = new, mtime; init_db()
|
||||
print(f"[boards] reloaded {len(BOARDS)} boards")
|
||||
except Exception as e: print(f"[boards] reload failed, keep old config: {e}")
|
||||
return BOARDS
|
||||
|
||||
UPLOAD_DIR = "bbs_files"
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
app = FastAPI(title="Agent BBS", docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
class ApiKeyMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
key = request.headers.get("x-api-key") or request.query_params.get("key")
|
||||
board = load_boards_if_changed().get(key)
|
||||
if not board: return Response("Not Found", status_code=404)
|
||||
request.state.board = board
|
||||
return await call_next(request)
|
||||
|
||||
app.add_middleware(ApiKeyMiddleware)
|
||||
|
||||
HTML_PAGE = """<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Agent BBS</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:Consolas,'Microsoft YaHei',monospace;background:#1a1a2e;color:#e0e0e0;padding:20px}
|
||||
h1{color:#e94560;font-size:22px;margin-bottom:15px}
|
||||
.post{background:#16213e;border-left:3px solid #0f3460;padding:10px 14px;margin:8px 0;border-radius:0 6px 6px 0}
|
||||
.post .meta{font-size:12px;color:#888;margin-bottom:4px}
|
||||
.post .author{color:#e94560;font-weight:bold}
|
||||
.post .content{white-space:pre-wrap;word-break:break-all}
|
||||
.bar{display:flex;gap:10px;margin-bottom:15px;align-items:center}
|
||||
.bar select,.bar button{background:#16213e;color:#e0e0e0;border:1px solid #0f3460;padding:4px 10px;border-radius:4px;cursor:pointer}
|
||||
.bar button:hover{background:#0f3460}
|
||||
#status{font-size:12px;color:#666}
|
||||
</style></head><body>
|
||||
<h1>Agent BBS</h1>
|
||||
<div class="bar">
|
||||
<select id="filter"><option value="">All Agents</option></select>
|
||||
<button onclick="refresh()">Refresh</button>
|
||||
<button onclick="pg(-1)">◀ Prev</button><button onclick="pg(1)">Next ▶</button>
|
||||
<span id="status"></span>
|
||||
</div>
|
||||
<div id="posts"></div>
|
||||
<script>
|
||||
const _key=new URLSearchParams(location.search).get('key')||'';
|
||||
const _hdr=_key?{'X-API-Key':_key}:{};
|
||||
let page=0,PP=300,total=0;
|
||||
async function loadAuthors(){
|
||||
const r=await fetch('/authors',{headers:_hdr});
|
||||
const authors=await r.json();
|
||||
const sel=document.getElementById('filter'),cur=sel.value;
|
||||
sel.innerHTML='<option value="">All Agents</option>';
|
||||
authors.forEach(a=>{const o=document.createElement('option');o.value=a;o.textContent=a;sel.appendChild(o)});
|
||||
sel.value=cur;
|
||||
}
|
||||
async function loadPosts(){
|
||||
const f=document.getElementById('filter').value;
|
||||
const aq=f?'author='+encodeURIComponent(f)+'&':'';
|
||||
const [pr,cr]=await Promise.all([
|
||||
fetch(`/posts?${aq}limit=${PP}&offset=${page*PP}`,{headers:_hdr}),
|
||||
fetch(`/count?${aq.slice(0,-1)}`,{headers:_hdr})
|
||||
]);
|
||||
const posts=await pr.json(),pages=Math.ceil((total=(await cr.json()).total)/PP)||1;
|
||||
page=Math.max(0,Math.min(page,pages-1));
|
||||
document.getElementById('posts').innerHTML=posts.map(p=>
|
||||
`<div class="post"><div class="meta"><span class="author">${esc(p.author)}</span> · #${p.id} · ${new Date(p.created_at*1000).toLocaleString()}</div><div class="content">${esc(p.content)}</div></div>`
|
||||
).join('');
|
||||
document.getElementById('status').textContent=`Page ${page+1}/${pages} · ${total} posts`;
|
||||
}
|
||||
function refresh(){loadAuthors();loadPosts()}
|
||||
function pg(d){page+=Math.sign(d);loadPosts();window.scrollTo(0,0)}
|
||||
function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||
document.getElementById('filter').onchange=()=>{page=0;loadPosts()};
|
||||
refresh();
|
||||
setInterval(loadPosts,8000);
|
||||
</script></body></html>"""
|
||||
|
||||
README_TEXT = "Agent BBS API\tAuth: ALL requests require header X-API-Key: <key> or pass ?key=<key> as query parameter.\t1. Register: POST /register body: {\"name\": \"your-agent-name\"}\tResponse: {\"token\": \"xxx\", \"name\": \"your-agent-name\"}\t2. Post: POST /post body: {\"token\": \"xxx\", \"content\": \"your message\"}\tResponse: {\"id\": 1, \"author\": \"your-agent-name\"}\t3. Poll new: GET /poll?since_id=0&limit=50\tReturns posts with id > since_id, ordered by id asc. Keep track of the last id you received, use it as since_id next time.\t4. Query: GET /posts?author=xxx&limit=50\tauthor is optional. Returns posts ordered by id desc. 5. Upload file: POST /file/upload multipart/form-data, form fields: token (your agent token) + file (the file). Requires X-API-Key. Response: {\"ref\": \"a1b2c3/filename.ext\"}. Paste ref into post content to reference the file. 6. Download file: GET /file/{rand_id}/{filename} Requires X-API-Key. e.g. /file/a1b2c3/filename.ext"
|
||||
|
||||
@app.get("/readme")
|
||||
def readme(): return PlainTextResponse(README_TEXT)
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(): return HTML_PAGE
|
||||
|
||||
@contextmanager
|
||||
def get_db(db_path):
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
finally: conn.close()
|
||||
|
||||
def _db(request): return request.state.board["db"]
|
||||
|
||||
def init_db():
|
||||
for board in BOARDS.values():
|
||||
with get_db(board["db"]) as db:
|
||||
db.execute("""CREATE TABLE IF NOT EXISTS users (
|
||||
token TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, created_at REAL)""")
|
||||
db.execute("""CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL,
|
||||
content TEXT NOT NULL, created_at REAL,
|
||||
FOREIGN KEY(author) REFERENCES users(name))""")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_posts_id ON posts(id)")
|
||||
|
||||
def verify_token(token, db_path):
|
||||
with get_db(db_path) as db:
|
||||
row = db.execute("SELECT name FROM users WHERE token=?", (token,)).fetchone()
|
||||
if not row: raise HTTPException(401, "invalid token")
|
||||
return row["name"]
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup(): load_boards_if_changed()
|
||||
|
||||
@app.post("/register")
|
||||
def register(request: Request, name=Body(..., embed=True)):
|
||||
token = uuid.uuid4().hex[:16]
|
||||
try:
|
||||
with get_db(_db(request)) as db:
|
||||
db.execute("INSERT INTO users VALUES(?,?,?)", (token, name, time.time()))
|
||||
except sqlite3.IntegrityError:
|
||||
with get_db(_db(request)) as db:
|
||||
row = db.execute("SELECT token FROM users WHERE name=?", (name,)).fetchone()
|
||||
return {"token": row["token"], "name": name}
|
||||
return {"token": token, "name": name}
|
||||
|
||||
@app.post("/post")
|
||||
def create_post(request: Request, token=Body(...), content=Body(...)):
|
||||
author = verify_token(token, _db(request))
|
||||
with get_db(_db(request)) as db:
|
||||
cur = db.execute("INSERT INTO posts(author,content,created_at) VALUES(?,?,?)",
|
||||
(author, content, time.time()))
|
||||
post_id = cur.lastrowid
|
||||
return {"id": post_id, "author": author}
|
||||
|
||||
@app.get("/poll")
|
||||
def poll(request: Request, since_id=Query(0), limit=Query(50)):
|
||||
with get_db(_db(request)) as db:
|
||||
rows = db.execute("SELECT id,author,content,created_at FROM posts WHERE id>? ORDER BY id LIMIT ?",
|
||||
(since_id, limit)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@app.get("/count")
|
||||
def count_posts(request: Request, author=Query(None)):
|
||||
with get_db(_db(request)) as db:
|
||||
q, p = ("SELECT COUNT(*) c FROM posts WHERE author=?", (author,)) if author else ("SELECT COUNT(*) c FROM posts", ())
|
||||
return {"total": db.execute(q, p).fetchone()["c"]}
|
||||
|
||||
@app.get("/authors")
|
||||
def get_authors(request: Request):
|
||||
with get_db(_db(request)) as db:
|
||||
return [r["author"] for r in db.execute("SELECT DISTINCT author FROM posts ORDER BY author").fetchall()]
|
||||
|
||||
@app.get("/posts")
|
||||
def get_posts(request: Request, author=Query(None), limit=Query(50), offset=Query(0)):
|
||||
with get_db(_db(request)) as db:
|
||||
if author:
|
||||
rows = db.execute("SELECT id,author,content,created_at FROM posts WHERE author=? ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(author, limit, offset)).fetchall()
|
||||
else:
|
||||
rows = db.execute("SELECT id,author,content,created_at FROM posts ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@app.post("/file/upload")
|
||||
def upload_file(request: Request, token=Body(...), file: UploadFile = File(...)):
|
||||
verify_token(token, _db(request))
|
||||
rand_id = uuid.uuid4().hex[:6]
|
||||
safe_name = os.path.basename(file.filename)
|
||||
dest = os.path.join(UPLOAD_DIR, rand_id)
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
with open(os.path.join(dest, safe_name), "wb") as f:
|
||||
f.write(file.file.read())
|
||||
return {"ref": f"{rand_id}/{safe_name}"}
|
||||
|
||||
@app.get("/file/{rand_id}/{filename}")
|
||||
def download_file(rand_id: str, filename: str):
|
||||
path = os.path.join(UPLOAD_DIR, rand_id, os.path.basename(filename))
|
||||
if not os.path.exists(path):
|
||||
raise HTTPException(404, "not found")
|
||||
return FileResponse(path, filename=filename)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=58800)
|
||||
@@ -0,0 +1,27 @@
|
||||
import sys, os, json, re, time, subprocess
|
||||
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'memory'))
|
||||
_r = subprocess.run
|
||||
def _d(b):
|
||||
if not b: return ''
|
||||
if isinstance(b, str): return b
|
||||
try: return b.decode()
|
||||
except: return b.decode('gbk', 'replace')
|
||||
def _run(*a, **k):
|
||||
t = k.pop('text', 0) | k.pop('universal_newlines', 0)
|
||||
enc = k.pop('encoding', None)
|
||||
k.pop('errors', None)
|
||||
if enc: t = 1
|
||||
if t and isinstance(k.get('input'), str):
|
||||
k['input'] = k['input'].encode()
|
||||
r = _r(*a, **k)
|
||||
if t:
|
||||
if r.stdout is not None: r.stdout = _d(r.stdout)
|
||||
if r.stderr is not None: r.stderr = _d(r.stderr)
|
||||
return r
|
||||
subprocess.run = _run
|
||||
_Pi = subprocess.Popen.__init__
|
||||
def _pinit(self, *a, **k):
|
||||
if os.name == 'nt': k['creationflags'] = (k.get('creationflags') or 0) | 0x08000000
|
||||
_Pi(self, *a, **k)
|
||||
subprocess.Popen.__init__ = _pinit
|
||||
sys.excepthook = lambda t, v, tb: (sys.__excepthook__(t, v, tb), print(f"\n[Agent Hint]: NO GUESSING! You MUST probe first. If missing common package, pip.")) if issubclass(t, (ImportError, AttributeError)) else sys.__excepthook__(t, v, tb)
|
||||
@@ -1,24 +0,0 @@
|
||||
// background.js - 保留原有事件 + 新增消息监听
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
console.log('Cookie Grabber installed');
|
||||
});
|
||||
|
||||
// content script 请求cookie时的处理
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.type === 'getCookies' && sender.tab) {
|
||||
const url = sender.tab.url;
|
||||
// 普通cookie + partitioned cookie 双查合并
|
||||
chrome.cookies.getAll({url}, cookies => {
|
||||
console.log('[CookieGrabber] normal cookies:', cookies.map(c => c.name));
|
||||
chrome.cookies.getAll({url, partitionKey: {topLevelSite: url.match(/^https?:\/\/[^/]+/)[0]}}, pCookies => {
|
||||
console.log('[CookieGrabber] partitioned cookies:', pCookies.map(c => c.name));
|
||||
const map = {};
|
||||
cookies.forEach(c => map[c.name] = c.value);
|
||||
pCookies.forEach(c => map[c.name] = c.value);
|
||||
const str = Object.entries(map).map(([k,v]) => k + '=' + v).join('; ');
|
||||
sendResponse({cookies: str});
|
||||
});
|
||||
});
|
||||
return true; // 异步响应
|
||||
}
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
// content.js - MutationObserver 监听触发元素
|
||||
const TRIGGER_ID = '__ljqcg__';
|
||||
|
||||
const obs = new MutationObserver(muts => {
|
||||
for (const m of muts) {
|
||||
for (const node of m.addedNodes) {
|
||||
if (node.nodeType === 1 && node.id === TRIGGER_ID) {
|
||||
chrome.runtime.sendMessage({type: 'getCookies'}, res => {
|
||||
if (res && res.cookies) node.textContent = res.cookies;
|
||||
else node.textContent = '__cg_error__';
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
obs.observe(document.documentElement, {childList: true, subtree: true});
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Cookie Grabber",
|
||||
"version": "1.0",
|
||||
"description": "获取所有 cookies",
|
||||
"permissions": [
|
||||
"cookies",
|
||||
"storage",
|
||||
"tabs",
|
||||
"activeTab"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>HttpOnly Cookie Grabber</title>
|
||||
<script src="popup.js" defer></script>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 10px; }
|
||||
ul { padding-left: 20px; }
|
||||
li { margin: 5px 0; }
|
||||
pre { background-color: #f8f8f8; padding: 10px; border: 1px solid #ccc; max-height: 150px; overflow-y: auto; }
|
||||
button { margin-top: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Get All Cookies</h1>
|
||||
<ul id="cookiesDisplay">No available cookies</ul>
|
||||
<button id="refresh">Refresh and Copy Cookies</button>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,56 +0,0 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 当刷新按钮被点击时,获取当前页面的 cookies
|
||||
document.getElementById('refresh').addEventListener('click', fetchCookies);
|
||||
// 加载时显示当前页面的 cookies
|
||||
fetchCookies();
|
||||
});
|
||||
|
||||
// 从 cookies 存储中获取当前页面的 cookies
|
||||
function fetchCookies() {
|
||||
// 获取当前活动的标签页
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const activeTab = tabs[0]; // 获取活动标签页
|
||||
const currentUrl = activeTab.url; // 当前页面的 URL
|
||||
console.log("当前活动的 URL:", currentUrl);
|
||||
|
||||
// 双查: 普通 + partitioned
|
||||
const origin = currentUrl.match(/^https?:\/\/[^\/]+/)[0];
|
||||
chrome.cookies.getAll({ url: currentUrl }, (cookies) => {
|
||||
chrome.cookies.getAll({ url: currentUrl, partitionKey: { topLevelSite: origin } }, (pCookies) => {
|
||||
const map = {};
|
||||
cookies.forEach(c => map[c.name] = c.value);
|
||||
pCookies.forEach(c => map[c.name] = c.value);
|
||||
const allCookies = Object.entries(map);
|
||||
|
||||
const cookiesDisplay = document.getElementById('cookiesDisplay');
|
||||
cookiesDisplay.innerHTML = '';
|
||||
|
||||
if (allCookies.length === 0) {
|
||||
cookiesDisplay.innerHTML = '<li>No available cookies</li>';
|
||||
} else {
|
||||
let cookiesString = '';
|
||||
allCookies.forEach(([name, value]) => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = `${name}: ${value}`;
|
||||
cookiesDisplay.appendChild(li);
|
||||
cookiesString += `${name}=${value}; `;
|
||||
});
|
||||
console.log('cookies:', allCookies.length);
|
||||
copyCookiesToClipboard(cookiesString.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 将 cookies 复制到剪贴板
|
||||
function copyCookiesToClipboard(cookiesString) {
|
||||
// 使用 Clipboard API 复制到剪贴板
|
||||
navigator.clipboard.writeText(cookiesString)
|
||||
.then(() => {
|
||||
console.log("Cookies copied to clipboard:", cookiesString);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Unable to copy to clipboard:", err);
|
||||
});
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.0 MiB |
@@ -1,17 +1,22 @@
|
||||
# [Global Memory Insight]
|
||||
# 【引导注释 - 使用后请删除整个注释块】
|
||||
# 格式:两层「场景→记忆」映射 + RULES,总计≤30行
|
||||
#
|
||||
# == 第一层:高频场景 key→value ==
|
||||
# 每行格式: 触发场景关键词: sop名/py名/L2:section名(关键参数)
|
||||
# 示例: 境外网站/API: proxies={'https':'http://127.0.0.1:2082'}
|
||||
# 示例: 微信发消息: wechat_send_sop | 微信读聊天: wechat_db_sop+wechat_db_utils(quick_connect)
|
||||
# 可用 | 分隔同类场景,括号内放硬参数
|
||||
#
|
||||
# == 第二层:低频关键词,自包含可只写一个词 ==
|
||||
# 格式: 一行内用 | 分隔多个关键词,需要时 read L2 或 ls memory/ 查详情
|
||||
# 示例: 邮件ezgmail | 游戏game | OCR | 华为云Huawei_Cloud
|
||||
#
|
||||
# == [RULES] ==
|
||||
# 压缩的通用操作准则,每条一行
|
||||
# 示例: 1. 搜索先行: 文件用es, 信息用google, 禁猜路径
|
||||
需要时read L2 或 ls ../memory/ 查L3
|
||||
L0(META-SOP): memory_management_sop
|
||||
L2: 现空
|
||||
L3: memory_cleanup_sop(记忆整理) | skill_search | ui_detect.py | ocr_utils.py | subagent | web_setup_sop | plan_sop
|
||||
| procmem_scanner | keychain | ljqCtrl_sop+.py | tmwebdriver_sop | autonomous_operation_sop | scheduled_task_sop | vision_sop | adb_ui.py
|
||||
L4: L4_raw_sessions/ 历史会话
|
||||
|
||||
浏览器特殊操作: tmwebdriver_sop(文件上传/图搜/PDF blob/物理坐标/HttpOnly Cookie/autofill突破/跨域iframe/CDP/跨tab)
|
||||
键鼠: ljqCtrl_sop(禁pyautogui/先activate) 截图/视觉: ocr/vision_sop | 禁全屏截图,优先窗口
|
||||
定时:scheduled_task_sop | 自主:autonomous_operation_sop | watchdog/反射:agentmain --reflect
|
||||
手机:adb_ui.py
|
||||
|
||||
[RULES]
|
||||
1. 搜索先行: 搜文件名严禁不用es(禁PS递归/禁dir遍历), 搜索一定优先使用web工具的google(严禁duckduckgo等), 优先看cwd,禁猜路径
|
||||
2. 交叉验证: 禁信摘要, 数值进详情页核实
|
||||
3. 编码安全: 禁PS cat/type用file_read; 改前必读; memory模块直接import(已在PATH,禁加虚假前缀)
|
||||
4. 闭环: 物理模拟后确认; 3次失败请求干预; Git完整闭环
|
||||
5. 进程: 禁无条件杀python(杀自己), 精确PID, 禁os.kill判活
|
||||
6. 窗口: GUI状态优先win32gui枚举标题
|
||||
7. web JS: 输入用原生setter+事件链, 点击前检disabled, 注意引号转义; scan空/不全先稍等再scan, 禁首扫定论
|
||||
8. SOP: 读SOP禁凭印象,有utils必用 | 复杂超长程任务/用户明确提及规划模式→读plan_sop
|
||||
@@ -0,0 +1,22 @@
|
||||
# [Global Memory Insight]
|
||||
Read L2 or ls ../memory/ for L3 when needed
|
||||
L0(META-SOP): memory_management_sop
|
||||
L2: currently empty
|
||||
L3: memory_cleanup_sop(memory cleanup) | skill_search | ui_detect.py | ocr_utils.py | subagent | web_setup_sop | plan_sop
|
||||
| procmem_scanner | keychain | ljqCtrl_sop+.py | tmwebdriver_sop | autonomous_operation_sop | scheduled_task_sop | vision_sop | adb_ui.py
|
||||
L4: L4_raw_sessions/ historical sessions
|
||||
|
||||
Browser special ops: tmwebdriver_sop(file upload/image search/PDF blob/physical coords/HttpOnly Cookie/autofill bypass/cross-origin iframe/CDP/cross-tab)
|
||||
Keyboard & Mouse: ljqCtrl_sop(no pyautogui/activate first) Screenshot/Vision: ocr/vision_sop | No fullscreen capture, prefer window
|
||||
Scheduling: scheduled_task_sop | Autonomous: autonomous_operation_sop | watchdog/reflect: agentmain --reflect
|
||||
Mobile: adb_ui.py
|
||||
|
||||
[RULES]
|
||||
1. Search first: must use es for filename search (no PS recursion/no dir traversal), always prefer Google for web search (no duckduckgo etc), check cwd first, no guessing paths
|
||||
2. Cross-verify: never trust summaries, verify numbers on detail pages
|
||||
3. Encoding safety: use file_read not PS cat/type; read before modify; import memory modules directly (already in PATH, no fake prefixes)
|
||||
4. Close the loop: confirm after physical simulation; request intervention after 3 failures; complete Git workflow
|
||||
5. Processes: never kill python unconditionally (kills self), use exact PID, no os.kill for liveness check
|
||||
6. Windows: prefer win32gui title enumeration for GUI state
|
||||
7. Web JS: use native setter + event chain for input, check disabled before click, mind quote escaping; if scan empty/incomplete wait then rescan, no conclusions from first scan
|
||||
8. SOP: read SOPs not from memory, must use utils if available | complex long-running/user mentions planning -> read plan_sop
|
||||
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 168 KiB |
@@ -1,9 +1,9 @@
|
||||
Facts(L2): ../memory/global_mem.txt | Code: ../ | SOPs(L3): ../memory/*.md or *.py | META-SOP(L0): ../memory/memory_management_sop.md
|
||||
Insight是索引,L2/L3变更时同步Insight。写记忆前先读META-SOP(L0)。
|
||||
Facts(L2): ../memory/global_mem.txt | GA CodeRoot: ../ | SOPs(L3): ../memory/*.md or *.py | META-SOP(L0): ../memory/memory_management_sop.md
|
||||
L1 Insight是极简索引,L2/L3变更时同步L1,索引必须极简。写记忆前先读META-SOP(L0)。
|
||||
|
||||
[CONSTITUTION]
|
||||
1. 改自身源码先请示;./内可自主实验,允许装包和portable工具。
|
||||
2. 决策前查记忆库;未查证不断言。
|
||||
3. 分步执行逐步验证;3次失败请求干预。
|
||||
4. 密钥文件仅引用,不读取/移动。
|
||||
5. 写任何记忆前读META-SOP核验,memory下文件只能patch修改(除非新建)。
|
||||
1. 改自身源码先请示;./内可自主实验,允许装包和portable工具
|
||||
2. 决策前查记忆,有SOP/utils必用;多次失败回看SOP;未查证不断言
|
||||
3. 分步执行,控制粒度,限制失败半径;3次失败请求干预
|
||||
4. 密钥文件仅引用,不读取/移动
|
||||
5. 写任何记忆前读META-SOP核验,memory下文件只能patch修改(除非新建)
|
||||
@@ -0,0 +1,9 @@
|
||||
Facts(L2): ../memory/global_mem.txt | CodeRoot: ../ | SOPs(L3): ../memory/*.md or *.py | META-SOP(L0): ../memory/memory_management_sop.md
|
||||
L1 Insight is a minimal index; sync L1 when L2/L3 changes; keep index minimal. Read META-SOP(L0) before writing any memory.
|
||||
|
||||
[CONSTITUTION]
|
||||
1. Ask before modifying own source code; free to experiment within ./; installing packages and portable tools allowed
|
||||
2. Check memory before decisions; always use existing SOPs/utils; revisit SOPs on repeated failures; never assert without evidence
|
||||
3. Execute step by step, control granularity, limit blast radius; request intervention after 3 failures
|
||||
4. Key/secret files: reference only, never read or move
|
||||
5. Read META-SOP to verify before writing any memory; files under memory/ must be patched only (unless creating new)
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/bin/bash
|
||||
|
||||
# GenericAgent macOS Desktop App Installation Script
|
||||
#
|
||||
# Usage:
|
||||
# bash assets/install-macos-app.sh [--auto]
|
||||
#
|
||||
# This installer creates a small .app bundle that opens Terminal and runs
|
||||
# `python3 launch.pyw` from the current GenericAgent checkout.
|
||||
|
||||
if [ -z "${BASH_VERSION}" ]; then
|
||||
if command -v bash >/dev/null 2>&1; then
|
||||
exec bash -- "${0}" "$@"
|
||||
else
|
||||
echo "Error: This script requires bash."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
log_info() { echo -e "${BLUE}ℹ️ $1${NC}"; }
|
||||
log_success() { echo -e "${GREEN}✅ $1${NC}"; }
|
||||
log_warning() { echo -e "${YELLOW}⚠️ $1${NC}"; }
|
||||
log_error() { echo -e "${RED}❌ $1${NC}"; }
|
||||
|
||||
AUTO_MODE=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--auto) AUTO_MODE=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
APP_NAME="GenericAgent"
|
||||
PRIMARY_INSTALL_DIR="/Applications"
|
||||
FALLBACK_INSTALL_DIR="${HOME}/Applications"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
ICON_PATH="${PROJECT_ROOT}/assets/images/logo.jpg"
|
||||
LAUNCH_SCRIPT="${PROJECT_ROOT}/launch.pyw"
|
||||
|
||||
echo -e "${CYAN}"
|
||||
echo "╔═══════════════════════════════════════════════════════════╗"
|
||||
echo "║ GenericAgent — macOS Desktop App Installer ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
if [[ "$(uname)" != "Darwin" ]]; then
|
||||
log_error "This script only supports macOS."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
log_error "python3 is not installed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${LAUNCH_SCRIPT}" ]; then
|
||||
log_error "launch.pyw not found at ${LAUNCH_SCRIPT}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
project_path_for_applescript="${PROJECT_ROOT}/"
|
||||
project_path_for_applescript="${project_path_for_applescript//\\/\\\\}"
|
||||
project_path_for_applescript="${project_path_for_applescript//\"/\\\"}"
|
||||
|
||||
detect_existing_app() {
|
||||
if [ -d "${PRIMARY_INSTALL_DIR}/${APP_NAME}.app" ]; then
|
||||
echo "${PRIMARY_INSTALL_DIR}/${APP_NAME}.app"
|
||||
return
|
||||
fi
|
||||
if [ -d "${FALLBACK_INSTALL_DIR}/${APP_NAME}.app" ]; then
|
||||
echo "${FALLBACK_INSTALL_DIR}/${APP_NAME}.app"
|
||||
return
|
||||
fi
|
||||
}
|
||||
|
||||
existing_app_path="$(detect_existing_app || true)"
|
||||
if [ -n "${existing_app_path}" ]; then
|
||||
log_warning "${APP_NAME}.app already exists at ${existing_app_path}"
|
||||
fi
|
||||
|
||||
if [ "${AUTO_MODE}" = false ]; then
|
||||
echo ""
|
||||
echo "This will install a desktop app that launches GenericAgent"
|
||||
echo "from Spotlight, Launchpad, or the Applications folder."
|
||||
echo ""
|
||||
if [ -n "${existing_app_path}" ]; then
|
||||
read -p "Reinstall ${APP_NAME}.app? (y/N) " -n 1 -r
|
||||
else
|
||||
read -p "Continue? (Y/n) " -n 1 -r
|
||||
fi
|
||||
echo
|
||||
if [ -n "${existing_app_path}" ]; then
|
||||
[[ ! ${REPLY:-} =~ ^[Yy]$ ]] && { echo "Aborted."; exit 0; }
|
||||
else
|
||||
[[ ${REPLY:-} =~ ^[Nn]$ ]] && { echo "Aborted."; exit 0; }
|
||||
fi
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||
|
||||
log_info "Building ${APP_NAME}.app..."
|
||||
|
||||
cat > "${TMP_DIR}/${APP_NAME}.applescript" <<APPLESCRIPT
|
||||
on run
|
||||
set projectPathStr to "${project_path_for_applescript}"
|
||||
tell application "Terminal"
|
||||
activate
|
||||
do script "cd " & quoted form of projectPathStr & " && python3 launch.pyw"
|
||||
end tell
|
||||
end run
|
||||
APPLESCRIPT
|
||||
|
||||
osacompile -o "${TMP_DIR}/${APP_NAME}.app" "${TMP_DIR}/${APP_NAME}.applescript"
|
||||
|
||||
log_info "Applying GenericAgent icon..."
|
||||
if [ -f "${ICON_PATH}" ]; then
|
||||
ICONSET_DIR="${TMP_DIR}/ga-icon.iconset"
|
||||
mkdir -p "${ICONSET_DIR}"
|
||||
|
||||
sips -z 16 16 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_16x16.png" >/dev/null 2>&1
|
||||
sips -z 32 32 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_16x16@2x.png" >/dev/null 2>&1
|
||||
sips -z 32 32 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_32x32.png" >/dev/null 2>&1
|
||||
sips -z 64 64 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_32x32@2x.png" >/dev/null 2>&1
|
||||
sips -z 128 128 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_128x128.png" >/dev/null 2>&1
|
||||
sips -z 256 256 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_128x128@2x.png" >/dev/null 2>&1
|
||||
sips -z 256 256 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_256x256.png" >/dev/null 2>&1
|
||||
sips -z 512 512 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_256x256@2x.png" >/dev/null 2>&1
|
||||
sips -z 512 512 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_512x512.png" >/dev/null 2>&1
|
||||
cp "${ICON_PATH}" "${ICONSET_DIR}/icon_512x512@2x.png"
|
||||
|
||||
iconutil -c icns "${ICONSET_DIR}" -o "${TMP_DIR}/ga-icon.icns"
|
||||
cp "${TMP_DIR}/ga-icon.icns" "${TMP_DIR}/${APP_NAME}.app/Contents/Resources/applet.icns"
|
||||
log_success "Icon applied from assets/images/logo.jpg"
|
||||
else
|
||||
log_warning "Logo not found at ${ICON_PATH}, using default icon."
|
||||
fi
|
||||
|
||||
install_bundle() {
|
||||
local install_dir="$1"
|
||||
local destination="${install_dir}/${APP_NAME}.app"
|
||||
mkdir -p "${install_dir}"
|
||||
rm -rf "${destination}"
|
||||
cp -R "${TMP_DIR}/${APP_NAME}.app" "${destination}"
|
||||
}
|
||||
|
||||
install_path=""
|
||||
if install_bundle "${PRIMARY_INSTALL_DIR}" 2>/dev/null; then
|
||||
install_path="${PRIMARY_INSTALL_DIR}/${APP_NAME}.app"
|
||||
else
|
||||
log_warning "Could not write to ${PRIMARY_INSTALL_DIR}; falling back to ${FALLBACK_INSTALL_DIR}"
|
||||
install_bundle "${FALLBACK_INSTALL_DIR}"
|
||||
install_path="${FALLBACK_INSTALL_DIR}/${APP_NAME}.app"
|
||||
fi
|
||||
|
||||
log_success "Installed to: ${install_path}"
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${CYAN}║${NC} ✨ ${APP_NAME} Desktop App installed successfully! ${CYAN}║${NC}"
|
||||
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Launch methods:${NC}"
|
||||
echo " • Spotlight: Cmd + Space → type '${APP_NAME}' → Enter"
|
||||
echo " • Launchpad: Find the '${APP_NAME}' icon"
|
||||
echo " • Finder: Open ${install_path}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Runtime behavior:${NC}"
|
||||
echo " The app uses the current checkout path embedded at install time:"
|
||||
echo " ${PROJECT_ROOT}"
|
||||
echo " If you move the repo later, re-run this installer."
|
||||
echo ""
|
||||
echo -e "${BLUE}Uninstall:${NC}"
|
||||
echo " rm -rf '${install_path}'"
|
||||
echo ""
|
||||
@@ -0,0 +1,111 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
title Python One-Click Installer
|
||||
color 0A
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Python One-Click Installer (Windows)
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
net session >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [!] Administrator privileges required. Restarting with elevation...
|
||||
powershell -Command "Start-Process '%~f0' -Verb RunAs"
|
||||
exit /b
|
||||
)
|
||||
|
||||
echo [OK] Administrator privileges confirmed
|
||||
echo.
|
||||
|
||||
python --version >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
echo [OK] Python already installed:
|
||||
python --version
|
||||
echo.
|
||||
choice /C YN /M "Install latest version anyway? (Y=Yes / N=Exit)"
|
||||
if errorlevel 2 goto :end
|
||||
)
|
||||
|
||||
set PYTHON_VERSION=3.12.9
|
||||
set MIRROR_URL=https://npmmirror.com/mirrors/python/3.12.9/python-3.12.9-amd64.exe
|
||||
set OFFICIAL_URL=https://www.python.org/ftp/python/3.12.9/python-3.12.9-amd64.exe
|
||||
set INSTALLER=%TEMP%\python_installer.exe
|
||||
|
||||
echo [*] Preparing to download Python %PYTHON_VERSION%
|
||||
echo [*] Trying mirror source first...
|
||||
echo.
|
||||
|
||||
powershell -NoProfile -Command "[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; $ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri '%MIRROR_URL%' -OutFile '%INSTALLER%' -UseBasicParsing"
|
||||
|
||||
if not exist "%INSTALLER%" goto :official
|
||||
for %%A in ("%INSTALLER%") do if %%~zA lss 1000000 goto :official
|
||||
echo [OK] Mirror download complete
|
||||
goto :install
|
||||
|
||||
:official
|
||||
echo [!] Mirror failed, switching to official source...
|
||||
powershell -NoProfile -Command "[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; $ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri '%OFFICIAL_URL%' -OutFile '%INSTALLER%' -UseBasicParsing"
|
||||
|
||||
if not exist "%INSTALLER%" (
|
||||
echo [x] Download failed. Please check your network connection and retry.
|
||||
pause
|
||||
goto :end
|
||||
)
|
||||
for %%A in ("%INSTALLER%") do if %%~zA lss 1000000 (
|
||||
echo [x] Downloaded file is incomplete. Please check your network and retry.
|
||||
pause
|
||||
goto :end
|
||||
)
|
||||
echo [OK] Official source download complete
|
||||
|
||||
:install
|
||||
echo.
|
||||
echo [*] Installing Python %PYTHON_VERSION% (this may take 2-5 minutes^)...
|
||||
echo.
|
||||
|
||||
start /wait "" "%INSTALLER%" /passive InstallAllUsers=1 PrependPath=1 Include_test=0 Include_pip=1
|
||||
|
||||
set INSTALL_CODE=%errorlevel%
|
||||
del /f /q "%INSTALLER%" >nul 2>&1
|
||||
|
||||
if %INSTALL_CODE% neq 0 (
|
||||
echo [x] Installation failed with error code: %INSTALL_CODE%
|
||||
pause
|
||||
goto :end
|
||||
)
|
||||
|
||||
echo [+] Installation complete!
|
||||
echo.
|
||||
|
||||
timeout /t 3 /nobreak >nul
|
||||
|
||||
set "PATH=C:\Program Files\Python312;C:\Program Files\Python312\Scripts;%PATH%"
|
||||
|
||||
python --version >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
echo [OK] Python installed successfully:
|
||||
python --version
|
||||
echo.
|
||||
echo [OK] pip version:
|
||||
pip --version
|
||||
echo.
|
||||
echo [*] Configuring pip mirror (Tsinghua^)...
|
||||
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
pip config set global.trusted-host pypi.tuna.tsinghua.edu.cn
|
||||
echo.
|
||||
echo [*] Installing requests...
|
||||
pip install requests
|
||||
echo.
|
||||
echo ========================================
|
||||
echo All done! Open a new terminal to use
|
||||
echo python and pip commands.
|
||||
echo ========================================
|
||||
) else (
|
||||
echo [!] PATH not yet refreshed. Please close this window and open a new terminal.
|
||||
)
|
||||
|
||||
:end
|
||||
echo.
|
||||
pause
|
||||
@@ -1,419 +0,0 @@
|
||||
// ==UserScript==
|
||||
// @name ljq_web_driver
|
||||
// @namespace http://tampermonkey.net/
|
||||
// @version 0.3
|
||||
// @description Execute JS via ljq_web_driver
|
||||
// @require https://code.jquery.com/jquery-3.6.0.min.js
|
||||
// @author You
|
||||
// @match *://*/*
|
||||
// @grant GM_setValue
|
||||
// @grant GM_getValue
|
||||
// @grant GM_xmlhttpRequest
|
||||
// @grant GM_openInTab
|
||||
// @grant unsafeWindow
|
||||
// @connect localhost
|
||||
// @run-at document-start
|
||||
// ==/UserScript==
|
||||
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
const log_prefix = "ljq_driver: ";
|
||||
|
||||
if (window.self !== window.top) {
|
||||
console.log(log_prefix + '在iframe中不执行');
|
||||
return;
|
||||
}
|
||||
|
||||
const wsUrl = 'ws://localhost:18765';
|
||||
const httpUrl = 'http://localhost:18766/';
|
||||
|
||||
function isWebSocketServerAlive(callback) {
|
||||
GM_xmlhttpRequest({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:18765/',
|
||||
onload: () => callback(true),
|
||||
onerror: () => callback(false)
|
||||
});
|
||||
}
|
||||
|
||||
let ws;
|
||||
let sid;
|
||||
if (window.opener && window.name && window.name.startsWith('ljq_')) {
|
||||
sid = null;
|
||||
console.log(log_prefix + `检测到opener,丢弃继承的window.name: ${window.name}`);
|
||||
window.name = '';
|
||||
} else {
|
||||
sid = (window.name && window.name.startsWith('ljq_')) ?
|
||||
window.name : window.sessionStorage.getItem('ljq_driver_sid');
|
||||
}
|
||||
if (!sid) {
|
||||
sid = `ljq_${Date.now().toString().slice(-2)}${Math.random().toString(36).slice(2, 4)}`;
|
||||
window.sessionStorage.setItem('ljq_driver_sid', sid);
|
||||
window.name = sid;
|
||||
console.log(log_prefix + `创建新会话ID: ${sid}`);
|
||||
} else {
|
||||
if (window.name !== sid) window.name = sid;
|
||||
console.log(log_prefix + `使用现有会话ID: ${sid}`);
|
||||
}
|
||||
|
||||
// 保存会话ID
|
||||
GM_setValue('sid', sid);
|
||||
|
||||
// 获取或创建状态指示器
|
||||
function getIndicator() {
|
||||
// 检查现有指示器
|
||||
let ind = document.getElementById('ljq-ind');
|
||||
|
||||
// 删除重复指示器
|
||||
const dups = document.querySelectorAll('[id="ljq-ind"]');
|
||||
if (dups.length > 1) {
|
||||
for (let i = 1; i < dups.length; i++) {
|
||||
dups[i].remove();
|
||||
}
|
||||
ind = dups[0];
|
||||
}
|
||||
|
||||
// 创建新指示器
|
||||
if (!ind && document.body) {
|
||||
ind = document.createElement('div');
|
||||
ind.id = 'ljq-ind';
|
||||
ind.style.cssText = `
|
||||
position: fixed;bottom: 10px;
|
||||
right: 10px;background-color: #f44336;
|
||||
color: white;padding: 8px 12px;
|
||||
border-radius: 6px;font-size: 14px;
|
||||
font-weight: bold;z-index: 9999;
|
||||
transition: background-color 0.3s;
|
||||
cursor: pointer;box-shadow: 0 3px 6px rgba(0,0,0,0.25);
|
||||
`;
|
||||
ind.innerText = log_prefix + '正在连接...';
|
||||
|
||||
ind.addEventListener('click', () => alert(`会话ID: ${sid}\n当前URL: ${location.href}`));
|
||||
document.body.appendChild(ind);
|
||||
}
|
||||
|
||||
return ind;
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
function updateStatus(status, msg) {
|
||||
if (!document.body) return setTimeout(() => updateStatus(status, msg), 100);
|
||||
|
||||
const ind = getIndicator();
|
||||
if (!ind) return;
|
||||
|
||||
if (status === 'ok') {
|
||||
ind.style.backgroundColor = '#4CAF50';
|
||||
ind.innerText = log_prefix + '连接成功';
|
||||
} else if (status === 'disc') {
|
||||
ind.style.backgroundColor = '#f44336';
|
||||
ind.innerText = log_prefix + '连接断开';
|
||||
} else if (status === 'conn') {
|
||||
ind.style.backgroundColor = '#2196F3';
|
||||
ind.innerText = log_prefix + '正在连接(HTTP)';
|
||||
} else if (status === 'err') {
|
||||
ind.style.backgroundColor = '#FF9800';
|
||||
ind.innerText = log_prefix + `发生错误 (${msg})`;
|
||||
} else if (status === 'exec') {
|
||||
ind.style.backgroundColor = '#2196F3';
|
||||
ind.innerText = log_prefix + '正在执行指令...';
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(id, error, errorSource) {
|
||||
console.error(`${errorSource}错误:`, error);
|
||||
updateStatus('err', error.message);
|
||||
|
||||
const errorMessage = {
|
||||
type: 'error',
|
||||
id: id,
|
||||
sessionId: sid,
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
source: errorSource
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof ws !== 'undefined' && ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(errorMessage));
|
||||
} else {
|
||||
GM_xmlhttpRequest({
|
||||
method: "POST",
|
||||
url: httpUrl + "api/result",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
data: JSON.stringify(errorMessage),
|
||||
onload: function(response) {console.log("错误信息已通过HTTP发送", response);},
|
||||
onerror: function(err) {console.error("发送错误信息失败", err);}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function smartProcessResult(result) {
|
||||
// 处理 null 和原始类型
|
||||
if (result === null || result === undefined || typeof result !== 'object') {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 1. 处理 jQuery 对象 - 强制转换为HTML字符串数组
|
||||
if (typeof jQuery !== 'undefined' && result instanceof jQuery) {
|
||||
const elements = [];
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
if (result[i] && result[i].nodeType === 1) {
|
||||
elements.push(result[i].outerHTML);
|
||||
}
|
||||
}
|
||||
return elements; // 始终返回数组
|
||||
}
|
||||
|
||||
// 2. 处理 NodeList 和 HTMLCollection
|
||||
if (result instanceof NodeList || result instanceof HTMLCollection) {
|
||||
const elements = [];
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
if (result[i] && result[i].nodeType === 1) {
|
||||
elements.push(result[i].outerHTML);
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
// 3. 处理单个 DOM 元素
|
||||
if (result.nodeType === 1) {
|
||||
return result.outerHTML;
|
||||
}
|
||||
|
||||
// 4. 检查是否是具有数字索引和length属性的类数组对象
|
||||
if (!Array.isArray(result) &&
|
||||
typeof result === 'object' &&
|
||||
'length' in result &&
|
||||
typeof result.length === 'number') {
|
||||
|
||||
// 检查第一个元素是否是DOM节点
|
||||
const firstElement = result[0];
|
||||
if (firstElement && firstElement.nodeType === 1) {
|
||||
const elements = [];
|
||||
const length = Math.min(result.length, 100);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const elem = result[i];
|
||||
if (elem && elem.nodeType === 1) {
|
||||
elements.push(elem.outerHTML);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 处理普通对象和数组 - 使用标准序列化
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(result, function(key, value) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (value.nodeType === 1) {
|
||||
return value.outerHTML;
|
||||
}
|
||||
if (value === window || value === document) {
|
||||
return '[Object]';
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error("序列化对象失败:", e);
|
||||
return `[无法序列化的对象: ${e.message}]`;
|
||||
}
|
||||
}
|
||||
|
||||
// 防止重复初始化
|
||||
if (window.ljq_init) return;
|
||||
window.ljq_init = true;
|
||||
|
||||
function connecthttp() {
|
||||
if (window.use_ws) return;
|
||||
updateStatus('conn');
|
||||
GM_xmlhttpRequest({
|
||||
method: "POST",
|
||||
url: httpUrl + "api/longpoll",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
data: JSON.stringify({
|
||||
type: 'ready',
|
||||
url: location.href,
|
||||
sessionId: sid
|
||||
}),
|
||||
onload: function(resp) {
|
||||
if (resp.status === 200) {
|
||||
let data = JSON.parse(resp.responseText);
|
||||
console.log(log_prefix + '接收到数据:', data);
|
||||
if (data.id === "" && data.ret === "use ws") return;
|
||||
if (data.id === "") return setTimeout(connecthttp, 100);
|
||||
const response = executeCode(data);
|
||||
|
||||
if (response.error) {
|
||||
handleError(data.id, response.error, '执行代码');
|
||||
} else {
|
||||
GM_xmlhttpRequest({
|
||||
method: "POST",
|
||||
url: httpUrl + "api/result",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
data: JSON.stringify({
|
||||
type: 'result',
|
||||
id: data.id,
|
||||
sessionId: sid,
|
||||
result: response.result
|
||||
})
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error(log_prefix + '请求失败,状态码:', resp.status);
|
||||
updateStatus('err', '请求失败');
|
||||
}
|
||||
setTimeout(connecthttp, 1000);
|
||||
},
|
||||
onerror: function(err) {
|
||||
console.error(log_prefix + '请求错误', err);
|
||||
updateStatus('err', '请求失败');
|
||||
setTimeout(connecthttp, 5000);
|
||||
},
|
||||
ontimeout: function() {
|
||||
console.log(log_prefix + '请求超时');
|
||||
updateStatus('err', '请求超时');
|
||||
setTimeout(connecthttp, 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function executeCode(data) {
|
||||
let id = data.id || 'unknown'; // 获取 ID
|
||||
let result;
|
||||
|
||||
if (!data.code) {
|
||||
console.log('收到非代码执行消息:', data);
|
||||
return { error: '没有可执行的代码' };
|
||||
}
|
||||
updateStatus('exec');
|
||||
const _open = window.open;
|
||||
window.open = (url, target, features) => {
|
||||
GM_openInTab(url, { active: true });
|
||||
return { success: true, url: url };
|
||||
};
|
||||
try {
|
||||
const jsCode = data.code.trim();
|
||||
const lines = jsCode.split(/\r?\n/).filter(l => l.trim());
|
||||
const lastLine = lines.length > 0 ? lines[lines.length - 1].trim() : '';
|
||||
if (lastLine.startsWith('return')) {
|
||||
result = (new Function(jsCode))();
|
||||
} else {
|
||||
try {
|
||||
result = eval(jsCode);
|
||||
} catch (e) {
|
||||
if (isIllegalReturnError(e)) {
|
||||
result = (new Function(jsCode))();
|
||||
} else throw e;
|
||||
}
|
||||
}
|
||||
const processedResult = smartProcessResult(result);
|
||||
if (result instanceof Promise) {
|
||||
result.finally(() => window.open = _open);
|
||||
return { result: processedResult };
|
||||
}
|
||||
return { result: processedResult };
|
||||
} catch (execError) {
|
||||
return { error: execError };
|
||||
} finally {
|
||||
if (!(result instanceof Promise)) {
|
||||
setTimeout(() => window.open = _open, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isIllegalReturnError(e) {
|
||||
return e instanceof SyntaxError && (
|
||||
/Illegal return statement/i.test(e.message) || // Chrome 常见
|
||||
/return not in function/i.test(e.message) || // Firefox 常见
|
||||
/Illegal 'return' statement/i.test(e.message) // 兼容旧文案
|
||||
);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = function() {
|
||||
window.use_ws = true;
|
||||
console.log(log_prefix + '已连接');
|
||||
updateStatus('ok');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ready',
|
||||
url: location.href,
|
||||
sessionId: sid
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onclose = function() {
|
||||
console.log(log_prefix + '已断开,5秒后重连');
|
||||
updateStatus('disc');
|
||||
setTimeout(connect, 5000);
|
||||
};
|
||||
|
||||
ws.onerror = function(err) {
|
||||
console.error(log_prefix + '连接错误', err);
|
||||
updateStatus('err', '连接失败');
|
||||
isWebSocketServerAlive(function (e) { if (e) connecthttp()});
|
||||
};
|
||||
|
||||
ws.onmessage = async function(e) {
|
||||
try {
|
||||
let data = JSON.parse(e.data);
|
||||
ws.send(JSON.stringify({type: 'ack',id: data.id}));
|
||||
const response = executeCode(data);
|
||||
|
||||
if (response.error) {
|
||||
handleError(data.id, response.error, '执行代码');
|
||||
} else {
|
||||
updateStatus('ok');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'result',
|
||||
id: data.id,
|
||||
sessionId: sid,
|
||||
result: response.result
|
||||
}));
|
||||
}
|
||||
} catch (parseError) {
|
||||
handleError('unknown', parseError, '解析消息');
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
// 初始化
|
||||
function init() {
|
||||
if (document.body) {
|
||||
getIndicator();
|
||||
connect();
|
||||
} else {
|
||||
setTimeout(init, 50);
|
||||
}
|
||||
}
|
||||
|
||||
// 监控DOM变化
|
||||
const observer = new MutationObserver(() => getIndicator());
|
||||
|
||||
if (document.readyState !== 'loading') {
|
||||
init();
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
init();
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
}
|
||||
|
||||
// 清理
|
||||
window.addEventListener('beforeunload', () => {
|
||||
observer.disconnect();
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -1,137 +0,0 @@
|
||||
import sys, os, re
|
||||
import pyperclip
|
||||
import json, time
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
from sidercall import SiderLLMSession, LLMSession, ToolClient
|
||||
|
||||
|
||||
ask = SiderLLMSession().ask
|
||||
|
||||
|
||||
def generate_tool_schema():
|
||||
"""
|
||||
通过代码内省,将 Handler 的逻辑映射为高语义的工具描述。
|
||||
"""
|
||||
with open('../ga.py', 'r', encoding='utf-8') as f:
|
||||
ga_code = f.read()
|
||||
# 极简且具备高度概括能力的元 Prompt
|
||||
meta_prompt = f"""
|
||||
# Role
|
||||
你是一个具备深度推理能力的 AI 系统架构师。你将通过阅读 `GenericAgentHandler` 源码,构建其对应的工具能力矩阵。
|
||||
|
||||
# Task
|
||||
分析下方的源码,并输出 OpenAI Tool Schema。在输出 JSON 之前,你必须进行内部思考(Thinking Process)。
|
||||
|
||||
# Thinking Process Requirements
|
||||
在 `<thinking>` 标签中,请按顺序分析:
|
||||
1. **核心工具链识别**:识别所有 `do_xxx` 方法,并分析它们依赖的底层 Utility 函数。
|
||||
2. **内容溯源审计**:重点分析哪些工具是从 `response.content` 提取核心逻辑(如代码块)的。对于这些工具,确认在 Schema 参数中排除掉对应的字段。
|
||||
3. **调用策略推导**:分析工具间的协作关系(例如 `file_read` 如何为 `file_patch` 提供定位)。
|
||||
4. **兜底逻辑确认**:明确某些特殊万能工具在系统中的保底角色,快速工具无法执行的操作由保底工具执行,但正常应优先使用方便的工具。
|
||||
5. **注释审阅**:结合函数注释,理解每个工具的使用限制,其中的重要信息务必反映在工具描述中(如长度限制等)。
|
||||
注释中的重要信息务必反映在工具描述中。
|
||||
注释中的重要信息务必反映在工具描述中。
|
||||
|
||||
# Tool Schema Formatting Rules
|
||||
- **参数对齐**:仅包含 `do_xxx` 方法中通过 `args.get()` 显式获取的参数。
|
||||
- **高引导性描述**:描述应包含“何时调用”以及“如何根据反馈修正”,需要注意函数的注释事项。
|
||||
- **输出格式**:先输出 `<thinking>` 块,然后输出 ```json 块。
|
||||
|
||||
# Source Code
|
||||
{ga_code}
|
||||
|
||||
# Output
|
||||
请开始思考并生成:
|
||||
"""
|
||||
|
||||
# 假设 ask 是你已经封装好的 LLM 调用接口
|
||||
raw_response = ask(meta_prompt, model="gemini-3.0-flash")
|
||||
print(raw_response)
|
||||
|
||||
# --- 健壮的 JSON 解析逻辑 ---
|
||||
try:
|
||||
# 1. 清除 Markdown 围栏
|
||||
clean_json = raw_response.strip()
|
||||
if clean_json.startswith("```"):
|
||||
# 兼容 ```json 和 ```
|
||||
clean_json = re.sub(r'^```(?:json)?\s*', '', clean_json)
|
||||
clean_json = re.sub(r'\s*```$', '', clean_json)
|
||||
|
||||
# 2. 移除可能的非 JSON 前导/后缀文字(如果有的话)
|
||||
start_idx = clean_json.find('[')
|
||||
end_idx = clean_json.rfind(']') + 1
|
||||
if start_idx != -1 and end_idx != -1:
|
||||
clean_json = clean_json[start_idx:end_idx]
|
||||
|
||||
final_schema = json.loads(clean_json)
|
||||
|
||||
if final_schema:
|
||||
with open('tools_schema.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(final_schema, f, indent=2, ensure_ascii=False)
|
||||
print("✅ 成功从代码内省生成 Schema 并持久化。")
|
||||
return final_schema
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 解析 Schema 失败: {e}\n原始响应: {raw_response}")
|
||||
return None
|
||||
|
||||
|
||||
def make_system_prompt(ga_code_path='../ga.py'):
|
||||
with open(ga_code_path, 'r', encoding='utf-8') as f:
|
||||
ga_code = f.read()
|
||||
|
||||
# 这个元 Prompt 的目标是生成“世界观”而非“说明书”
|
||||
meta_prompt = f"""
|
||||
# Role
|
||||
你是一个 AI 架构师。请阅读下方的工具库源码,为 Agent 生成一份【系统级认知指令 (System Prompt)】。
|
||||
|
||||
# Task
|
||||
基于代码逻辑,定义 Agent 的“能力边界”和“行动协议”。
|
||||
|
||||
# Requirements (Crucial)
|
||||
1. **打破预训练偏见**:针对模型常说的“我只是 AI,不能操作网页/文件”进行修正。明确告诉它:你现在拥有物理操作权限,工具设计保证了所有权限做所有事情。
|
||||
2. **避开冗余**:不要重复 Tool Schema 里的参数细节。
|
||||
3. **能力边界定义**:
|
||||
- 网页操作:它不是通过“想象”上网,而是通过实时的浏览器读写。
|
||||
- 文件操作:它拥有物理文件读写权限,且遵循“先读后写”的稳健性原则。
|
||||
- 保底逻辑:当专用工具失效时,使用 `code_run` 编写脚本解决一切。
|
||||
- 特殊的update_plan(仅在复杂任务时使用)和ask_user(用户也是有效资源)工具。
|
||||
4. **行动协议**:
|
||||
- 必须在行动前进行<thinking>
|
||||
|
||||
我后面还会附上具体的工具描述和Schema,所以不要重复。
|
||||
主要以世界观为主,不要纠结于具体工具。
|
||||
|
||||
# Input Source Code
|
||||
{ga_code}
|
||||
|
||||
# Output
|
||||
仅输出 System Prompt 的正文,语气要果断、指令化。
|
||||
"""
|
||||
print("🧠 正在重塑 Agent 世界观 (Generating System Prompt)...")
|
||||
# 调用你的 llmclient.ask
|
||||
system_prompt_content = ask(meta_prompt)
|
||||
print("📝 生成的 System Prompt 内容如下:\n")
|
||||
print(system_prompt_content)
|
||||
clean_content = re.sub(r'<[^>]+>', '', system_prompt_content)
|
||||
with open('sys_prompt.txt', 'w', encoding='utf-8') as f:
|
||||
f.write(clean_content)
|
||||
return clean_content
|
||||
|
||||
# --- 主逻辑 ---
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python make_prompts.py [schema|prompt]")
|
||||
sys.exit(1)
|
||||
|
||||
cmd = sys.argv[1].lower()
|
||||
if cmd == "schema":
|
||||
generate_tool_schema()
|
||||
elif cmd == "prompt":
|
||||
make_system_prompt()
|
||||
else:
|
||||
print(f"Unknown command: {cmd}")
|
||||
print("Available commands: schema, prompt")
|
||||
@@ -1,6 +1,6 @@
|
||||
# Role: 物理级全能执行者
|
||||
你拥有文件读写、脚本执行、用户浏览器JS注入、系统级干预的物理操作权限。禁止推诿"无法操作"——不空想,用工具探测。
|
||||
## 行动原则
|
||||
调用工具前在 <thinking> 内推演:当前阶段、上步结果是否符合预期、下步策略。
|
||||
调用工具前先推演:当前阶段、上步结果是否符合预期、下步策略,必须在回复文本中用<summary>输出极简总结。
|
||||
- 探测优先:失败时先充分获取信息(日志/状态/上下文),关键信息存入工作记忆,再决定重试或换方案。不可逆操作先询问用户。
|
||||
- 失败升级:1次→读错误理解原因,2次→探测环境状态,3次→深度分析后换方案或问用户。禁止无新信息的重复操作。
|
||||
@@ -0,0 +1,7 @@
|
||||
# Role: Physical-Level Omnipotent Executor
|
||||
You have full physical access: file I/O, script execution, browser JS injection, and system-level intervention. Never deflect with "can't do it" — don't speculate, use tools to probe.
|
||||
Summarize and reply in user's language or follow user's prompt.
|
||||
## Action Principles
|
||||
Before each tool call, reason: current phase, whether the last result met expectations, and next strategy and <summary> in reply text of each turn.
|
||||
- Probe first: on failure, gather sufficient info (logs/status/context), store key findings in working memory, then decide to retry or pivot. Ask the user before irreversible operations.
|
||||
- Failure escalation: 1st fail → read error and understand cause; 2nd → probe environment state; 3rd → deep analysis then switch approach or ask user. Never repeat an action without new information.
|
||||
@@ -0,0 +1,405 @@
|
||||
// background.js - Cookie + CDP Bridge
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
console.log('CDP Bridge installed');
|
||||
// Strip CSP headers to allow eval/inline scripts
|
||||
chrome.declarativeNetRequest.updateDynamicRules({
|
||||
removeRuleIds: [9999],
|
||||
addRules: [{
|
||||
id: 9999, priority: 1,
|
||||
action: { type: 'modifyHeaders', responseHeaders: [
|
||||
{ header: 'content-security-policy', operation: 'remove' },
|
||||
{ header: 'content-security-policy-report-only', operation: 'remove' }
|
||||
]},
|
||||
condition: { urlFilter: '*', resourceTypes: ['main_frame', 'sub_frame'] }
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
async function handleExtMessage(msg, sender) {
|
||||
if (msg.cmd === 'cookies') return await handleCookies(msg, sender);
|
||||
if (msg.cmd === 'cdp') return await handleCDP(msg, sender);
|
||||
if (msg.cmd === 'batch') return await handleBatch(msg, sender);
|
||||
if (msg.cmd === 'tabs') {
|
||||
try {
|
||||
if (msg.method === 'switch') {
|
||||
const tab = await chrome.tabs.update(msg.tabId, { active: true });
|
||||
await chrome.windows.update(tab.windowId, { focused: true });
|
||||
return { ok: true };
|
||||
} else {
|
||||
const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url));
|
||||
const data = tabs.map(t => ({ id: t.id, url: t.url, title: t.title, active: t.active, windowId: t.windowId }));
|
||||
return { ok: true, data };
|
||||
}
|
||||
} catch (e) { return { ok: false, error: e.message }; }
|
||||
}
|
||||
if (msg.cmd === 'management') {
|
||||
try {
|
||||
if (msg.method === 'list') {
|
||||
const all = await chrome.management.getAll();
|
||||
return { ok: true, data: all.map(e => ({ id: e.id, name: e.name, enabled: e.enabled, type: e.type, version: e.version })) };
|
||||
}
|
||||
if (msg.method === 'reload') {
|
||||
chrome.alarms.create('tmwd-self-reload', { when: Date.now() + 200 });
|
||||
return { ok: true };
|
||||
}
|
||||
if (msg.method === 'disable') {
|
||||
await chrome.management.setEnabled(msg.extId, false);
|
||||
return { ok: true };
|
||||
}
|
||||
if (msg.method === 'enable') {
|
||||
await chrome.management.setEnabled(msg.extId, true);
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, error: 'Unknown method: ' + msg.method };
|
||||
} catch (e) { return { ok: false, error: e.message }; }
|
||||
}
|
||||
if (msg.cmd === 'contentSettings') {
|
||||
try {
|
||||
const type = msg.type || 'automaticDownloads';
|
||||
const setting = msg.setting || 'allow';
|
||||
const pattern = msg.pattern || '<all_urls>';
|
||||
await chrome.contentSettings[type].set({
|
||||
primaryPattern: pattern,
|
||||
setting: setting
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (e) { return { ok: false, error: e.message }; }
|
||||
}
|
||||
return { ok: false, error: 'Unknown cmd: ' + msg.cmd };
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
handleExtMessage(msg, sender).then(sendResponse);
|
||||
return true;
|
||||
});
|
||||
|
||||
async function handleCookies(msg, sender) {
|
||||
try {
|
||||
let url = msg.url || sender.tab?.url;
|
||||
if (!url && msg.tabId) {
|
||||
const tab = await chrome.tabs.get(msg.tabId);
|
||||
url = tab.url;
|
||||
}
|
||||
const origin = url.match(/^https?:\/\/[^\/]+/)[0];
|
||||
const all = await chrome.cookies.getAll({ url });
|
||||
const part = await chrome.cookies.getAll({ url, partitionKey: { topLevelSite: origin } }).catch(() => []);
|
||||
const merged = [...all];
|
||||
for (const c of part) {
|
||||
if (!merged.some(x => x.name === c.name && x.domain === c.domain)) merged.push(c);
|
||||
}
|
||||
return { ok: true, data: merged };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatch(msg, sender) {
|
||||
const R = [];
|
||||
let attached = null;
|
||||
const resolve$N = (params) => JSON.parse(JSON.stringify(params || {}).replace(/"\$(\d+)\.([^"]+)"/g,
|
||||
(_, i, path) => { let v = R[+i]; for (const k of path.split('.')) v = v[k]; return JSON.stringify(v); }));
|
||||
try {
|
||||
for (const c of msg.commands) {
|
||||
if (c.tabId === undefined && msg.tabId !== undefined) c.tabId = msg.tabId;
|
||||
if (c.cmd === 'cookies') {
|
||||
R.push(await handleCookies(c, sender));
|
||||
} else if (c.cmd === 'tabs') {
|
||||
const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url));
|
||||
R.push({ ok: true, data: tabs.map(t => ({ id: t.id, url: t.url, title: t.title, active: t.active, windowId: t.windowId })) });
|
||||
} else if (c.cmd === 'cdp') {
|
||||
const tabId = c.tabId || msg.tabId || sender.tab?.id;
|
||||
if (attached !== tabId) {
|
||||
if (attached) { await chrome.debugger.detach({ tabId: attached }); attached = null; }
|
||||
await chrome.debugger.attach({ tabId }, '1.3');
|
||||
attached = tabId;
|
||||
}
|
||||
R.push(await chrome.debugger.sendCommand({ tabId }, c.method, resolve$N(c.params)));
|
||||
} else {
|
||||
R.push({ ok: false, error: 'unknown cmd: ' + c.cmd });
|
||||
}
|
||||
}
|
||||
if (attached) await chrome.debugger.detach({ tabId: attached });
|
||||
return { ok: true, results: R };
|
||||
} catch (e) {
|
||||
if (attached) try { await chrome.debugger.detach({ tabId: attached }); } catch (_) {}
|
||||
return { ok: false, error: e.message, results: R };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCDP(msg, sender) {
|
||||
const tabId = msg.tabId || sender.tab?.id;
|
||||
if (!tabId) return { ok: false, error: 'no tabId' };
|
||||
try {
|
||||
await chrome.debugger.attach({ tabId }, '1.3');
|
||||
const result = await chrome.debugger.sendCommand({ tabId }, msg.method, msg.params || {});
|
||||
await chrome.debugger.detach({ tabId });
|
||||
return { ok: true, data: result };
|
||||
} catch (e) {
|
||||
try { await chrome.debugger.detach({ tabId }); } catch (_) {}
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
// Filter out chrome:// and other internal tabs that can't be scripted
|
||||
const isScriptable = url => url && /^https?:/.test(url);
|
||||
|
||||
// --- Shared page/CDP script builder core ---
|
||||
function buildExecScript(code, errorHandler) {
|
||||
return `(async () => {
|
||||
function smartProcessResult(result) {
|
||||
if (result === null || result === undefined || typeof result !== 'object') return result;
|
||||
try { if (result.window === result && result.document) return '[Window: ' + (result.location?.href || 'about:blank') + ']'; } catch(_){}
|
||||
if (typeof jQuery !== 'undefined' && result instanceof jQuery) {
|
||||
const elements = []; for (let i = 0; i < result.length; i++) { if (result[i] && result[i].nodeType === 1) elements.push(result[i].outerHTML); } return elements;
|
||||
}
|
||||
if (result instanceof NodeList || result instanceof HTMLCollection) {
|
||||
const elements = []; for (let i = 0; i < result.length; i++) { if (result[i] && result[i].nodeType === 1) elements.push(result[i].outerHTML); } return elements;
|
||||
}
|
||||
if (result.nodeType === 1) return result.outerHTML;
|
||||
if (!Array.isArray(result) && typeof result === 'object' && 'length' in result && typeof result.length === 'number') {
|
||||
const firstElement = result[0];
|
||||
if (firstElement && firstElement.nodeType === 1) {
|
||||
const elements = []; const length = Math.min(result.length, 100);
|
||||
for (let i = 0; i < length; i++) { const elem = result[i]; if (elem && elem.nodeType === 1) elements.push(elem.outerHTML); } return elements;
|
||||
}
|
||||
}
|
||||
try { return JSON.parse(JSON.stringify(result, function(key, value) { if (typeof value === 'object' && value !== null) { if (value.nodeType === 1) return value.outerHTML; if (value === window || value === document) return '[Object]'; try { if (value.window === value && value.document) return '[Window]'; } catch(_){} } return value; })); } catch (e) { return '[无法序列化: ' + e.message + ']'; }
|
||||
}
|
||||
try {
|
||||
const jsCode = ${JSON.stringify(code)}.trim();
|
||||
const lines = jsCode.split(/\\r?\\n/).filter(l => l.trim());
|
||||
const lastLine = lines.length > 0 ? lines[lines.length - 1].trim() : '';
|
||||
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
||||
let r;
|
||||
function _air(c) { const ls = c.split(/\\r?\\n/); let i = ls.length - 1; while (i >= 0 && !ls[i].trim()) i--; if (i < 0) return c; const t = ls[i].trim(); if (/^(return |return;|return$|let |const |var |if |if\\(|for |for\\(|while |while\\(|switch|try |throw |class |function |async |import |export |\\/\\/|})/.test(t)) return c; ls[i] = ls[i].match(/^(\\s*)/)[1] + 'return ' + t; return ls.join('\\n'); }
|
||||
if (lastLine.startsWith('return')) {
|
||||
r = await (new AsyncFunction(jsCode))();
|
||||
} else {
|
||||
try { r = eval(jsCode); if (r instanceof Promise) r = await r; } catch (e) {
|
||||
if (e instanceof SyntaxError && (/return/i.test(e.message) || /await/i.test(e.message))) { r = await (new AsyncFunction(_air(jsCode)))(); } else throw e;
|
||||
}
|
||||
}
|
||||
return { ok: true, data: smartProcessResult(r) };
|
||||
} catch (e) {
|
||||
${errorHandler}
|
||||
}
|
||||
})()`;
|
||||
}
|
||||
|
||||
function buildPageScript(code) {
|
||||
return buildExecScript(code, `
|
||||
const errMsg = e.message || String(e);
|
||||
return { ok: false, error: { name: e.name || 'Error', message: errMsg, stack: e.stack || '' },
|
||||
csp: errMsg.includes('Refused to evaluate') || errMsg.includes('unsafe-eval') || errMsg.includes('Content Security Policy') };
|
||||
`);
|
||||
}
|
||||
|
||||
function buildCdpScript(code) {
|
||||
return buildExecScript(code, `
|
||||
return { ok: false, error: { name: e.name || 'Error', message: e.message || String(e), stack: e.stack || '' } };
|
||||
`);
|
||||
}
|
||||
|
||||
// --- WebSocket Client for TMWebDriver ---
|
||||
let ws = null;
|
||||
const WS_URL = 'ws://127.0.0.1:18765';
|
||||
|
||||
function scheduleProbe() {
|
||||
// Use chrome.alarms to survive MV3 service worker suspension
|
||||
chrome.alarms.create('tmwd-ws-probe', { delayInMinutes: 0.083 }); // ~5s
|
||||
}
|
||||
|
||||
function scheduleKeepalive() {
|
||||
// Keep SW alive while WS is connected (~25s, under 30s SW timeout)
|
||||
chrome.alarms.create('tmwd-ws-keepalive', { delayInMinutes: 0.4 }); // ~24s
|
||||
}
|
||||
|
||||
async function isServerAlive() {
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
setTimeout(() => ctrl.abort(), 2000);
|
||||
await fetch('http://127.0.0.1:18765', { signal: ctrl.signal });
|
||||
return true; // Got HTTP response → port is listening
|
||||
} catch (e) {
|
||||
return false; // Network error (connection refused) or timeout → server not alive
|
||||
}
|
||||
}
|
||||
|
||||
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
if (alarm.name === 'tmwd-self-reload') {
|
||||
chrome.runtime.reload();
|
||||
return;
|
||||
}
|
||||
if (alarm.name === 'tmwd-ws-keepalive') {
|
||||
// Keepalive: ping to keep SW alive + detect dead connections
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send('{"type":"ping"}'); } catch (_) {}
|
||||
scheduleKeepalive();
|
||||
} else {
|
||||
// Connection lost, switch to probe mode
|
||||
ws = null;
|
||||
scheduleProbe();
|
||||
}
|
||||
}
|
||||
if (alarm.name === 'tmwd-ws-probe') {
|
||||
if (ws && ws.readyState <= 1) return; // Already connected/connecting
|
||||
if (await isServerAlive()) {
|
||||
console.log('[TMWD-WS] Server detected, connecting...');
|
||||
connectWS();
|
||||
} else {
|
||||
scheduleProbe(); // Server not up, keep probing
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function handleWsExec(data) {
|
||||
const tabId = data.tabId;
|
||||
console.log('[TMWD-WS] Exec request', data.id, 'on tab', tabId);
|
||||
ws.send(JSON.stringify({ type: 'ack', id: data.id }));
|
||||
if (!tabId) {
|
||||
ws.send(JSON.stringify({ type: 'error', id: data.id, error: 'No tabId provided' }));
|
||||
return;
|
||||
}
|
||||
// Use onCreated listener to reliably capture new tabs (avoids race condition with query-diff)
|
||||
const newTabIds = new Set();
|
||||
const onCreated = (tab) => { newTabIds.add(tab.id); };
|
||||
chrome.tabs.onCreated.addListener(onCreated);
|
||||
try {
|
||||
let res;
|
||||
try {
|
||||
const result = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
world: 'MAIN',
|
||||
func: async (s) => await eval(s),
|
||||
args: [buildPageScript(data.code)]
|
||||
});
|
||||
res = result[0]?.result;
|
||||
if (res === null || res === undefined) {
|
||||
console.log('[TMWD-WS] executeScript returned null/undefined, treating as CSP issue');
|
||||
res = { ok: false, error: { name: 'Error', message: 'executeScript returned null (possible CSP or context issue)', stack: '' }, csp: true };
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[TMWD-WS] scripting.executeScript failed:', e.message);
|
||||
res = { ok: false, error: { name: e.name || 'Error', message: e.message || String(e), stack: e.stack || '' }, csp: true };
|
||||
}
|
||||
// CDP fallback for CSP-restricted pages
|
||||
if (res && !res.ok && res.csp) {
|
||||
console.log('[TMWD-WS] CDP fallback for tab', tabId);
|
||||
const wrappedCode = buildCdpScript(data.code);
|
||||
try {
|
||||
await chrome.debugger.attach({ tabId }, '1.3');
|
||||
const cdpRes = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
||||
expression: wrappedCode, awaitPromise: true, returnByValue: true
|
||||
});
|
||||
await chrome.debugger.detach({ tabId });
|
||||
if (cdpRes.exceptionDetails) {
|
||||
const desc = cdpRes.exceptionDetails.exception?.description || 'CDP Error';
|
||||
res = { ok: false, error: { name: 'Error', message: desc, stack: desc } };
|
||||
} else {
|
||||
res = cdpRes.result.value;
|
||||
}
|
||||
} catch (cdpErr) {
|
||||
try { await chrome.debugger.detach({ tabId }); } catch (_) {}
|
||||
res = { ok: false, error: { name: 'Error', message: 'CDP fallback failed: ' + cdpErr.message, stack: '' } };
|
||||
}
|
||||
}
|
||||
// Grace period for async tab creation (e.g. link click with target=_blank)
|
||||
if (newTabIds.size === 0) await new Promise(r => setTimeout(r, 200));
|
||||
chrome.tabs.onCreated.removeListener(onCreated);
|
||||
// Get full info for captured new tabs
|
||||
const newTabs = [];
|
||||
for (const id of newTabIds) {
|
||||
try { const t = await chrome.tabs.get(id); newTabs.push({id: t.id, url: t.url, title: t.title}); } catch (_) {}
|
||||
}
|
||||
if (res?.ok) {
|
||||
ws.send(JSON.stringify({ type: 'result', id: data.id, result: res.data, newTabs }));
|
||||
} else {
|
||||
console.log(res);
|
||||
ws.send(JSON.stringify({ type: 'error', id: data.id, error: res?.error || 'Unknown error', newTabs }));
|
||||
}
|
||||
} catch (e) {
|
||||
ws.send(JSON.stringify({ type: 'error', id: data.id, error: { name: e.name || 'Error', message: e.message || String(e), stack: e.stack || '' } }));
|
||||
} finally {
|
||||
chrome.tabs.onCreated.removeListener(onCreated);
|
||||
}
|
||||
}
|
||||
|
||||
function connectWS() {
|
||||
if (ws && ws.readyState <= 1) return; // CONNECTING or OPEN
|
||||
ws = null;
|
||||
console.log('[TMWD-WS] Connecting to', WS_URL);
|
||||
try {
|
||||
ws = new WebSocket(WS_URL);
|
||||
} catch (e) {
|
||||
console.error('[TMWD-WS] Constructor error:', e);
|
||||
ws = null;
|
||||
scheduleProbe();
|
||||
return;
|
||||
}
|
||||
ws.onopen = async () => {
|
||||
console.log('[TMWD-WS] Connected!');
|
||||
scheduleKeepalive(); // Keep SW alive while connected
|
||||
const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url));
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ext_ready',
|
||||
tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title }))
|
||||
}));
|
||||
console.log('[TMWD-WS] Sent ext_ready with', tabs.length, 'tabs');
|
||||
};
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.id && data.code) {
|
||||
let code = data.code;
|
||||
// If code is a JSON string representing an object, parse it
|
||||
if (typeof code === 'string') {
|
||||
try { const p = JSON.parse(code); if (p && typeof p === 'object') code = p; } catch (_) {}
|
||||
}
|
||||
if (typeof code === 'object' && code !== null && code.cmd) {
|
||||
// Custom protocol message → route to handleExtMessage
|
||||
if (code.tabId === undefined && data.tabId !== undefined) code.tabId = data.tabId;
|
||||
const res = await handleExtMessage(code, {});
|
||||
ws.send(JSON.stringify({ type: res.ok ? 'result' : 'error', id: data.id, result: res.data ?? res.results ?? res, error: res.error }));
|
||||
} else if (typeof code === 'string') {
|
||||
// Plain JS code
|
||||
await handleWsExec(data);
|
||||
} else if (typeof code === 'object' && code !== null) {
|
||||
// Object without cmd → legacy extension message
|
||||
const msg = code.tabId === undefined && data.tabId !== undefined ? { ...code, tabId: data.tabId } : code;
|
||||
const res = await handleExtMessage(msg, {});
|
||||
ws.send(JSON.stringify({ type: res.ok ? 'result' : 'error', id: data.id, result: res.data ?? res.results ?? res, error: res.error }));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[TMWD-WS] message parse error', e);
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
console.log('[TMWD-WS] Disconnected');
|
||||
ws = null;
|
||||
scheduleProbe();
|
||||
};
|
||||
ws.onerror = (e) => {
|
||||
console.error('[TMWD-WS] Error:', e);
|
||||
// onclose will fire after this, which triggers reconnect
|
||||
};
|
||||
}
|
||||
|
||||
// Initial connect + wake-up hooks
|
||||
connectWS();
|
||||
chrome.runtime.onStartup.addListener(() => connectWS());
|
||||
chrome.runtime.onInstalled.addListener(() => connectWS());
|
||||
|
||||
// Sync tab list on changes
|
||||
async function sendTabsUpdate() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url) && !/streamlit/i.test(t.title));
|
||||
ws.send(JSON.stringify({
|
||||
type: 'tabs_update',
|
||||
tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title }))
|
||||
}));
|
||||
}
|
||||
chrome.tabs.onUpdated.addListener((_, changeInfo) => {
|
||||
if (changeInfo.status === 'complete') sendTabsUpdate();
|
||||
});
|
||||
chrome.tabs.onRemoved.addListener(() => sendTabsUpdate());
|
||||
chrome.tabs.onCreated.addListener(() => sendTabsUpdate());
|
||||
@@ -0,0 +1,47 @@
|
||||
;(function(){ if (/streamlit/i.test(document.title)) return;
|
||||
|
||||
// Remove meta CSP tags
|
||||
document.querySelectorAll('meta[http-equiv="Content-Security-Policy"]').forEach(e => e.remove());
|
||||
|
||||
// Indicator badge at bottom-right (userscript style)
|
||||
(function(){
|
||||
if(window.self!==window.top)return;
|
||||
const d=document.createElement('div');
|
||||
d.id='ljq-ind';
|
||||
d.innerText='ljq_driver: 已连接';
|
||||
d.style.cssText='position:fixed;bottom:8px;right:8px;background:#4CAF50;color:white;padding:4px 7px;border-radius:4px;font-size:11px;font-weight:bold;z-index:99999;cursor:pointer;box-shadow:0 2px 4px rgba(0,0,0,0.2);opacity:0.5;';
|
||||
d.addEventListener('click',()=>alert('会话活跃\nURL: '+location.href));
|
||||
(document.body||document.documentElement).appendChild(d);
|
||||
})();
|
||||
|
||||
new MutationObserver(muts => {
|
||||
for (const m of muts) for (const n of m.addedNodes) {
|
||||
if (n.id === TID || (n.querySelector && n.querySelector('#' + TID))) {
|
||||
const el = n.id === TID ? n : n.querySelector('#' + TID);
|
||||
handle(el);
|
||||
}
|
||||
}
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
|
||||
async function handle(el) {
|
||||
try {
|
||||
const req = el.textContent.trim() ? JSON.parse(el.textContent) : { cmd: 'cookies' };
|
||||
const cmd = req.cmd || 'cookies';
|
||||
let resp;
|
||||
if (cmd === 'cookies') {
|
||||
resp = await chrome.runtime.sendMessage({ cmd: 'cookies', url: req.url || location.href });
|
||||
} else if (cmd === 'cdp') {
|
||||
resp = await chrome.runtime.sendMessage({ cmd: 'cdp', method: req.method, params: req.params || {}, tabId: req.tabId });
|
||||
} else if (cmd === 'batch') {
|
||||
resp = await chrome.runtime.sendMessage({ cmd: 'batch', commands: req.commands, tabId: req.tabId });
|
||||
} else if (cmd === 'tabs') {
|
||||
resp = await chrome.runtime.sendMessage({ cmd: 'tabs', method: req.method, tabId: req.tabId });
|
||||
} else {
|
||||
resp = { ok: false, error: 'unknown cmd: ' + cmd };
|
||||
}
|
||||
el.textContent = JSON.stringify(resp);
|
||||
} catch (e) {
|
||||
el.textContent = JSON.stringify({ ok: false, error: e.message });
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,24 @@
|
||||
// Disable alert/confirm/prompt to prevent page JS from blocking extension
|
||||
(function() {
|
||||
const _log = console.log.bind(console);
|
||||
function toast(type, msg) {
|
||||
_log('[TMWD] ' + type + ' suppressed:', msg);
|
||||
try {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = '[' + type + '] ' + msg;
|
||||
Object.assign(d.style, {
|
||||
position:'fixed', top:'12px', right:'12px', zIndex:'2147483647',
|
||||
background:'#222', color:'#fff', padding:'10px 18px', borderRadius:'8px',
|
||||
fontSize:'14px', maxWidth:'420px', wordBreak:'break-all',
|
||||
boxShadow:'0 4px 16px rgba(0,0,0,.3)', opacity:'1',
|
||||
transition:'opacity .5s', pointerEvents:'none'
|
||||
});
|
||||
(document.body || document.documentElement).appendChild(d);
|
||||
setTimeout(() => { d.style.opacity = '0'; }, 3000);
|
||||
setTimeout(() => { d.remove(); }, 3600);
|
||||
} catch(e) {}
|
||||
}
|
||||
window.alert = function(msg) { toast('alert', msg); };
|
||||
window.confirm = function(msg) { toast('confirm', msg); return true; };
|
||||
window.prompt = function(msg, def) { toast('prompt', msg); return def || null; };
|
||||
})();
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "TMWD CDP Bridge",
|
||||
"version": "2.0",
|
||||
"description": "Cookie viewer + CDP bridge",
|
||||
"permissions": [
|
||||
"cookies",
|
||||
"tabs",
|
||||
"activeTab",
|
||||
"debugger",
|
||||
"scripting",
|
||||
"alarms",
|
||||
"declarativeNetRequest",
|
||||
"management",
|
||||
"contentSettings"
|
||||
],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["disable_dialogs.js"],
|
||||
"run_at": "document_start",
|
||||
"all_frames": true,
|
||||
"world": "MAIN"
|
||||
},
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["config.js", "content.js"],
|
||||
"run_at": "document_idle",
|
||||
"all_frames": true
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "TMWD CDP Bridge"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body{width:420px;max-height:500px;margin:0;padding:8px;font:12px monospace;background:#1e1e1e;color:#d4d4d4;overflow-y:auto}
|
||||
h3{margin:4px 0;color:#569cd6}
|
||||
button{background:#264f78;color:#fff;border:none;padding:4px 12px;cursor:pointer;border-radius:3px;margin-bottom:6px}
|
||||
button:hover{background:#37699e}
|
||||
pre{white-space:pre-wrap;word-break:break-all;margin:0;padding:6px;background:#252526;border-radius:3px;max-height:420px;overflow-y:auto}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h3>🍪 Cookies</h3>
|
||||
<button id="refresh">刷新</button>
|
||||
<pre id="out">点击刷新获取 cookies...</pre>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const out = document.getElementById('out');
|
||||
const btn = document.getElementById('refresh');
|
||||
btn.addEventListener('click', fetchCookies);
|
||||
fetchCookies();
|
||||
});
|
||||
|
||||
async function fetchCookies() {
|
||||
const out = document.getElementById('out');
|
||||
try {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab?.url) { out.textContent = 'No active tab'; return; }
|
||||
const resp = await chrome.runtime.sendMessage({ cmd: 'cookies', url: tab.url });
|
||||
if (!resp?.ok) { out.textContent = 'Error: ' + (resp?.error || 'unknown'); return; }
|
||||
if (!resp.data.length) { out.textContent = '(no cookies)'; return; }
|
||||
// 展示带标记
|
||||
out.textContent = resp.data.map(c =>
|
||||
`${c.name}=${c.value}` + (c.httpOnly ? ' [H]' : '') + (c.secure ? ' [S]' : '') + (c.partitionKey ? ' [P]' : '')
|
||||
).join('\n');
|
||||
// 自动复制 name=value; 格式到剪贴板
|
||||
const str = resp.data.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
await navigator.clipboard.writeText(str);
|
||||
} catch (e) { out.textContent = 'Error: ' + e.message; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[{"role":"user","content":[{"type":"text","text":"code_run个helloworld,根据结果简单评价你的工具配置"}]},{"role":"assistant","content":[{"type":"text","text":"\n\n<summary>用户要求code_run helloworld并评价工具配置</summary>\n\n```python\nprint(\"Hello, World!\")\n```"},{"type":"tool_use","id":"tooluse_GlmTNw190WGgK4sBBOiDHK","name":"code_run","input":{"script":"print(\"Hello, World!\")"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"tooluse_GlmTNw190WGgK4sBBOiDHK","content":"{\"status\": \"success\", \"stdout\": \"Hello, World!\\r\\n\", \"exit_code\": 0}"},{"type":"text","text":"\n### [WORKING MEMORY]\n<history>\n\n[USER]: code_run个helloworld,根据结果简单评价你的工具配置\n</history>\nCurrent turn: 1\n"}]},{"role":"assistant","content":[{"type":"text","text":"\n\n<summary>code_run helloworld成功,评价工具配置</summary>\n\n工具配置没问题。`code_run` 返回了结构化的 JSON(`status`、`stdout`、`exit_code`),执行链路通畅,cwd 默认落在 temp 目录。\n\n刚才还验证了 `inline_eval` 能正确访问 agent 内部状态并导出 history,`file_patch` 也能精准改文件。整套工具链从代码执行、文件读写、到 agent 内省都是通的。"}]}]
|
||||
@@ -1,68 +1,74 @@
|
||||
[
|
||||
{"type": "function", "function": {
|
||||
"name": "code_run",
|
||||
"description": "代码执行器。优先使用python,仅在必要系统操作时使用 powershell。注意:执行的代码必须放在在回复正文中,以 ```python 或 ```powershell 代码块的形式。严禁在代码中硬编码大量数据,如有需要应通过文件读取。",
|
||||
"description": "Code executor. Prefer python. Multi-call OK, use script param. Reply code block is executed if no script arg; prefer for single call to avoid escaping. No hardcoding bulk data",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"type": {"type": "string", "enum": ["python", "powershell"], "description": "执行环境类型,默认为 python。", "default": "python"},
|
||||
"timeout": {"type": "integer", "description": "执行超时时间(秒),默认 60。", "default": 60},
|
||||
"cwd": {"type": "string", "description": "工作目录,默认为当前工作目录。"}}}
|
||||
"script": {"type": "string", "description": "[Mutually exclusive] NEVER use this param when use reply code block."},
|
||||
"type": {"type": "string", "enum": ["python", "powershell"], "description": "Code type", "default": "python"},
|
||||
"timeout": {"type": "integer", "description": "in seconds", "default": 60},
|
||||
"cwd": {"type": "string", "description": "Working directory, defaults to cwd"},
|
||||
"inline_eval": {"type": "boolean", "description": "DO NOT USE except explicitly specified."}}}
|
||||
}},
|
||||
{"type": "function", "function": {
|
||||
"name": "file_read",
|
||||
"description": "读取文件内容。建议在修改文件前先读取,以确保获取最新的上下文和行号。支持分页读取或关键字搜索。",
|
||||
"description": "Read file. Read before modify for latest context and line numbers",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"path": {"type": "string", "description": "文件相对或绝对路径。"},
|
||||
"start": {"type": "integer", "description": "起始行号(从 1 开始)。", "default": 1},
|
||||
"count": {"type": "integer", "description": "读取的行数。", "default": 200},
|
||||
"keyword": {"type": "string", "description": "可选搜索关键字。如果提供,将返回第一个匹配项(忽略大小写)及其周边的内容。"},
|
||||
"show_linenos": {"type": "boolean", "description": "是否显示行号,建议开启以辅助 file_patch 定位。", "default": true}}, "required": ["path"]}
|
||||
"path": {"type": "string", "description": "Relative or absolute"},
|
||||
"start": {"type": "integer", "description": "Start line number (1-based)"},
|
||||
"count": {"type": "integer", "description": "Number of lines to read", "default": 200},
|
||||
"keyword": {"type": "string", "description": "[Optional] If provided, returns first match (case-insensitive) with context"},
|
||||
"show_linenos": {"type": "boolean", "description": "Show line numbers", "default": true}}}
|
||||
}},
|
||||
{"type": "function", "function": {
|
||||
"name": "file_patch",
|
||||
"description": "精细化局部文件修改。在文件中寻找唯一的 old_content 块并替换为 new_content。要求 old_content 必须在文件中唯一存在,且空格、缩进、换行必须与原文件完全一致。如果匹配失败,请使用 file_read 重新确认文件内容。",
|
||||
"description": "Replace unique old_content with new_content. Exact match required (whitespace/indentation). On failure, file_read to recheck",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"path": {"type": "string", "description": "文件路径。"},
|
||||
"old_content": {"type": "string", "description": "文件中需要被替换的原始文本块(需确保唯一性)。"},
|
||||
"new_content": {"type": "string", "description": "替换后的新文本内容。"}}, "required": ["path", "old_content", "new_content"]}
|
||||
"path": {"type": "string", "description": "File path"},
|
||||
"old_content": {"type": "string", "description": "Original text block to replace (must be unique)"},
|
||||
"new_content": {"type": "string", "description": "New content. Supports {{file:path:startLine:endLine}} to ref file lines, auto-expanded"}}}
|
||||
}},
|
||||
{"type": "function", "function": {
|
||||
"name": "file_write",
|
||||
"description": "用于文件的新建、全量覆盖或追加写入。对于精细的代码修改,应优先使用 file_patch。注意:要写入的内容必须放在回复正文的 <file_content> 标签或代码块中。",
|
||||
"description": "Create/overwrite/append files. HUGE edits ONLY. Supports {{file:path:startLine:endLine}}, auto-expanded",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"path": {"type": "string", "description": "文件路径。"},
|
||||
"mode": {"type": "string", "enum": ["overwrite", "append", "prepend"], "description": "写入模式覆盖、追加或在开头追加。", "default": "overwrite"}}, "required": ["path"]}
|
||||
"path": {"type": "string", "description": "File path"},
|
||||
"content": {"type": "string"},
|
||||
"mode": {"type": "string", "enum": ["overwrite", "append", "prepend"], "description": "Write mode", "default": "overwrite"}}}
|
||||
}},
|
||||
{"type": "function", "function": {
|
||||
"name": "web_scan",
|
||||
"description": "获取当前页面的简化HTML内容和标签页列表。注意:简化会过滤边栏、浮动元素等非主体内容,如需查看被过滤内容请用execute_js。切换页面后一般应先调用查看。",
|
||||
"description": "Get simplified HTML and tab list. Removes hidden/floating/covered elements. Call after switching pages",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"tabs_only": {"type": "boolean", "description": "仅返回标签页列表和当前标签信息,不获取HTML内容。", "default": false},
|
||||
"switch_tab_id": {"type": "string", "description": "可选的标签页 ID。如果提供,系统将在扫描前切换到该标签页。"}}}
|
||||
"tabs_only": {"type": "boolean", "description": "Show tab list only, no HTML"},
|
||||
"switch_tab_id": {"type": "string", "description": "[Optional] Tab ID to switch to"},
|
||||
"text_only": {"type": "boolean", "description": "Plain text only, no HTML"}}}
|
||||
}},
|
||||
{"type": "function", "function": {
|
||||
"name": "web_execute_js",
|
||||
"description": "万能网页操控工具。通过执行 JavaScript 脚本实现对浏览器的完全控制(如点击、滚动、提取特定数据)。鼓励在有把握情况下(记忆中有selector/做法等)精准使用以减少web_scan调用。执行结果可选择保存到本地文件进行后续分析。",
|
||||
"description": "Execute JS. Multi-call OK with different switch_tab_id. No guessing. Act accurately to reduce web_scan calls. Execute JS in ```javascript blocks if no script arg, prefer to avoid escaping",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"script": {"type": "string", "description": "要执行的 JavaScript 代码。"},
|
||||
"save_to_file": {"type": "string", "description": "可选。将 JS 执行结果(js_return)保存到的文件路径。该功能不支持 await 等异步结果。"}}, "required": ["script"]}
|
||||
"script": {"type": "string", "description": "[Mutually exclusive] JS code or script path. NEVER use this param when use reply code block"},
|
||||
"save_to_file": {"type": "string", "description": "file path; **only** for long result"},
|
||||
"no_monitor": {"type": "boolean", "description": "Skip page change monitoring, saves 2-3s. Only for reads, not for page actions"},
|
||||
"switch_tab_id": {"type": "string", "description": "[Optional] Tab ID to switch to before executing"}}}
|
||||
}},
|
||||
{"type": "function", "function": {
|
||||
"name": "update_working_checkpoint",
|
||||
"description": "短期工作便签,内容每轮自动注入,防止长任务中关键信息丢失。要在任务前中期而非结束时调用,新任务切换时应当及时使用清除之前影响。何时调用:(1)即将切换子任务、上下文将被大量新信息冲刷前,存入当前路径/参数/进度;(2)获得后续步骤必需的关键发现后;(3)分步任务完成一步后更新为本步结果+下一步要求。原则:只存N轮后可能忘记但后面还要用的信息,刚发生的不用存。宁可多更新不可丢关键上下文。",
|
||||
"description": "Short-term working notepad, auto-injected each turn to prevent info loss in long tasks. Call during early/mid stages, not at end. When: (1) after reading SOP, store user needs & key constraints (skip for simple 1-2 step tasks); (2) before subtask switch or context flush; (3) after repeated failures, re-read SOP and must store new findings; (4) on new task, update content, clear old progress but keep valid constraints.\n\nDon't call: simple tasks (1-2 steps), task completed (use long-term memory tool)",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"key_info": {"type": "string", "description": "替换当前便签(<200 tokens)。只写后续必须记住的:文件路径、关键参数/发现、当前进度、下一步计划、要避的坑。刚完成的和上下文中显而易见的不写,省空间给真正容易丢的信息。"},
|
||||
"related_sop": {"type": "string", "description": "相关sop名称,可以多个,必要时需要再读"}}}
|
||||
"key_info": {"type": "string", "description": "Replaces current notepad (<200 tokens). Incremental update: review existing, keep valid, add/remove/modify. Store: pitfalls, user requirements, key params/findings, file paths, progress, next steps. Don't store: ephemeral info, obvious context, old task info when user switched tasks. Prefer over-updating over losing key info"},
|
||||
"related_sop": {"type": "string", "description": "Related SOP names, tips for further re-read"}}}
|
||||
}},
|
||||
{"type": "function", "function": {
|
||||
"name": "ask_user",
|
||||
"description": "当需要用户决策、提供额外信息或遇到无法自动解决的阻碍时,调用此工具中断任务并提问。",
|
||||
"description": "Interrupt task to ask user when needing decisions, extra info, or facing unresolvable blockers",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"question": {"type": "string", "description": "向用户提出的明确问题。"},
|
||||
"candidates": {"type": "array", "items": {"type": "string"}, "description": "提供给用户的可选快捷选项列表。"}}, "required": ["question"]}
|
||||
"question": {"type": "string", "description": "Question for the user"},
|
||||
"candidates": {"type": "array", "items": {"type": "string"}, "description": "Optional quick-select choices for the user"}}}
|
||||
}},
|
||||
{"type": "function", "function": {
|
||||
"name": "start_long_term_update",
|
||||
"description": "准备开始提炼记忆。发现值得长期记忆的信息(环境事实/用户偏好/避坑经验)时调用此工具。一次用户对话只允许调用一次,已记忆更新或在自主流程内时无需调用。",
|
||||
"description": "Start distilling long-term memory. Call when discovering info worth remembering (env facts/user prefs/lessons learned). Skip if memory already updated or in autonomous flow. Must call when a task that took 15+ turns is completed",
|
||||
"parameters": {"type": "object", "properties": {}}}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
[
|
||||
{"type": "function", "function": {
|
||||
"name": "code_run",
|
||||
"description": "代码执行器。优先使用python。支持Multi-call,并行时用script参数。无script参数时正文代码块会被执行,单次调用优先使用以免转义。禁硬编码大量数据",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"script": {"type": "string", "description": "[Optional] 要执行的代码。为免转义建议留空,改用正文代码块(与此参数互斥)"},
|
||||
"type": {"type": "string", "enum": ["python", "powershell"], "description": "代码类型", "default": "python"},
|
||||
"timeout": {"type": "integer", "description": "执行超时时间(秒)", "default": 60},
|
||||
"cwd": {"type": "string", "description": "工作目录,默认为当前工作目录"},
|
||||
"inline_eval": {"type": "boolean", "description": "不允许使用除非明确要求"}}}
|
||||
}},
|
||||
{"type": "function", "function": {
|
||||
"name": "file_read",
|
||||
"description": "读取文件内容。建议在修改文件前先读取,以确保获取最新的上下文和行号。支持分页读取或关键字搜索",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"path": {"type": "string", "description": "文件相对或绝对路径"},
|
||||
"start": {"type": "integer", "description": "起始行号(从 1 开始)"},
|
||||
"count": {"type": "integer", "description": "读取的行数", "default": 200},
|
||||
"keyword": {"type": "string", "description": "可选搜索关键字。如果提供,将返回第一个匹配项(忽略大小写)及其周边的内容"},
|
||||
"show_linenos": {"type": "boolean", "description": "是否显示行号,建议开启以辅助 file_patch 定位", "default": true}}}
|
||||
}},
|
||||
{"type": "function", "function": {
|
||||
"name": "file_patch",
|
||||
"description": "精细化局部文件修改。在文件中寻找唯一的 old_content 块并替换为 new_content。要求 old_content 必须在文件中唯一存在,且空格、缩进、换行必须与原文件完全一致。如果匹配失败,请使用 file_read 重新确认文件内容",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"path": {"type": "string", "description": "文件路径"},
|
||||
"old_content": {"type": "string", "description": "文件中需要被替换的原始文本块(需确保唯一性)"},
|
||||
"new_content": {"type": "string", "description": "替换后的新文本内容。支持 {{file:路径:起始行:结束行}} 语法引用文件内容,写入前自动展开"}}}
|
||||
}},
|
||||
{"type": "function", "function": {
|
||||
"name": "file_write",
|
||||
"description": "用于文件的新建、全量覆盖或追加写入。对于精细的代码修改,应优先使用 file_patch。写入内容支持 {{file:路径:起始行:结束行}} 语法引用文件片段,写入前自动展开",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"path": {"type": "string", "description": "文件路径"},
|
||||
"content": {"type": "string"},
|
||||
"mode": {"type": "string", "enum": ["overwrite", "append", "prepend"], "description": "写入模式覆盖、追加或在开头追加", "default": "overwrite"}}}
|
||||
}},
|
||||
{"type": "function", "function": {
|
||||
"name": "web_scan",
|
||||
"description": "获取当前页面的简化HTML内容和标签页列表。会移除隐藏/浮动/被遮盖的元素。切换页面后一般应先调用查看",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"tabs_only": {"type": "boolean", "description": "仅返回标签页列表和当前标签信息,不获取HTML内容"},
|
||||
"switch_tab_id": {"type": "string", "description": "可选的标签页 ID。如果提供,系统将在扫描前切换到该标签页"},
|
||||
"text_only": {"type": "boolean", "description": "只要纯文本不要HTML"}}}
|
||||
}},
|
||||
{"type": "function", "function": {
|
||||
"name": "web_execute_js",
|
||||
"description": "执行JS。支持Multi-call,用不同switch_tab_id并行操作多标签页。禁止猜测,准确操作以减少 web_scan 调用。无script参数时执行正文 ```javascript 块,以免转义",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"script": {"type": "string", "description": "[Optional] JS代码或路径。为免转义建议留空,改用正文代码块(与此参数互斥)"},
|
||||
"save_to_file": {"type": "string", "description": "结果存文件,适合返回值较长时"},
|
||||
"no_monitor": {"type": "boolean", "description": "跳过页面变更监控,省2-3秒。仅在纯读取信息时设置,页面操作时不要设置"},
|
||||
"switch_tab_id": {"type": "string", "description": "可选的标签页 ID,切换到该标签页执行"}}}
|
||||
}},
|
||||
{"type": "function", "function": {
|
||||
"name": "update_working_checkpoint",
|
||||
"description": "短期工作便签,每轮自动注入上下文,防长任务信息丢失。前中期调用,非结束时。何时调用:(1)任务开始读SOP后,存用户需求和关键约束/参数(简单1-2步任务除外);(2)子任务切换或上下文即将被冲刷前;(3)多次重试失败后,重读SOP并必须调用存储新发现;(4)切换新任务时更新内容,清旧进度但保留仍有效的约束。\n\n何时不调用:简单任务(1-2步且无严重约束)、任务已完成时(应当用长期结算工具)",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"key_info": {"type": "string", "description": "替换当前便签(<200 tokens)。增量更新:先回顾现有内容,保留仍有效的,再增删改。存:要避的坑、用户原始需求、关键参数/发现、文件路径、当前进度、下一步计划。不存:马上要用用完即丢的、上下文中显而易见的、用户已换全新任务时的旧任务信息。宁多更新不丢关键"},
|
||||
"related_sop": {"type": "string", "description": "相关sop名称,可以多个,必要时需要再读"}}}
|
||||
}},
|
||||
{"type": "function", "function": {
|
||||
"name": "ask_user",
|
||||
"description": "当需要用户决策、提供额外信息或遇到无法自动解决的阻碍时,调用此工具中断任务并提问",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"question": {"type": "string", "description": "向用户提出的明确问题"},
|
||||
"candidates": {"type": "array", "items": {"type": "string"}, "description": "提供给用户的可选快捷选项列表"}}}
|
||||
}},
|
||||
{"type": "function", "function": {
|
||||
"name": "start_long_term_update",
|
||||
"description": "准备开始提炼记忆。发现值得长期记忆的信息(环境事实/用户偏好/避坑经验)时调用此工具。已记忆更新或在自主流程内时无需调用。超15轮完成的任务必须调用以沉淀经验",
|
||||
"parameters": {"type": "object", "properties": {}}}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,175 @@
|
||||
# Desktop Pet Skin System
|
||||
|
||||
## 快速开始
|
||||
|
||||
运行桌面宠物:
|
||||
```bash
|
||||
python3 desktop_pet_v2.pyw
|
||||
```
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 1. 多皮肤支持
|
||||
- 自动发现 `skins/` 目录下的所有皮肤
|
||||
- 右键菜单切换皮肤
|
||||
- 支持 sprite sheet 和 GIF 两种格式
|
||||
|
||||
### 2. 多动画状态
|
||||
- **idle** - 待机动画
|
||||
- **walk** - 行走动画
|
||||
- **run** - 跑步动画
|
||||
- **sprint** - 冲刺动画
|
||||
|
||||
右键菜单可切换动画状态
|
||||
|
||||
### 3. 交互功能
|
||||
- **单击** - 拖动宠物
|
||||
- **双击** - 关闭程序
|
||||
- **右键** - 打开菜单(切换皮肤/动画)
|
||||
|
||||
### 4. HTTP 远程控制
|
||||
```bash
|
||||
# 显示消息
|
||||
curl "http://127.0.0.1:51983/?msg=Hello"
|
||||
|
||||
# 切换动画状态
|
||||
curl "http://127.0.0.1:51983/?state=run"
|
||||
|
||||
# POST 消息
|
||||
curl -X POST -d "任务完成" http://127.0.0.1:51983/
|
||||
```
|
||||
|
||||
## 添加新皮肤
|
||||
|
||||
### 目录结构
|
||||
```
|
||||
skins/
|
||||
└── your-skin-name/
|
||||
├── skin.json # 配置文件(必需)
|
||||
├── idle.png # 动画资源
|
||||
├── walk.png
|
||||
├── run.png
|
||||
└── sprint.png
|
||||
```
|
||||
|
||||
### skin.json 配置示例
|
||||
|
||||
#### Sprite Sheet 格式(推荐)
|
||||
```json
|
||||
{
|
||||
"name": "My Pet",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"description": "描述",
|
||||
"format": "sprite",
|
||||
"animations": {
|
||||
"idle": {
|
||||
"file": "idle.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 44,
|
||||
"frameHeight": 31,
|
||||
"frameCount": 6,
|
||||
"columns": 6,
|
||||
"fps": 6,
|
||||
"startFrame": 0
|
||||
}
|
||||
},
|
||||
"walk": {
|
||||
"file": "walk.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 65,
|
||||
"frameHeight": 32,
|
||||
"frameCount": 8,
|
||||
"columns": 8,
|
||||
"fps": 8,
|
||||
"startFrame": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### GIF 格式
|
||||
```json
|
||||
{
|
||||
"name": "My Pet",
|
||||
"format": "gif",
|
||||
"animations": {
|
||||
"idle": {
|
||||
"file": "idle.gif",
|
||||
"loop": true
|
||||
},
|
||||
"walk": {
|
||||
"file": "walk.gif",
|
||||
"loop": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
|
||||
- **frameWidth/frameHeight**: 单帧尺寸(像素)
|
||||
- **frameCount**: 帧数
|
||||
- **columns**: sprite sheet 的列数
|
||||
- **fps**: 播放帧率
|
||||
- **startFrame**: 起始帧索引(从 0 开始)
|
||||
|
||||
### Sprite Sheet 布局
|
||||
|
||||
```
|
||||
+-------+-------+-------+-------+
|
||||
| 帧0 | 帧1 | 帧2 | 帧3 | ← 第一行
|
||||
+-------+-------+-------+-------+
|
||||
| 帧4 | 帧5 | 帧6 | 帧7 | ← 第二行
|
||||
+-------+-------+-------+-------+
|
||||
```
|
||||
|
||||
如果 `columns=4, startFrame=2, frameCount=3`,则读取:帧2, 帧3, 帧4
|
||||
|
||||
## 已包含的皮肤
|
||||
|
||||
1. **Glube** - 像素风小怪兽(多文件 sprite)
|
||||
2. **Vita** - 像素风小恐龙(单文件 sprite)
|
||||
3. **Doux** - 像素风小恐龙(单文件 sprite)
|
||||
|
||||
## 从 ai-bubu 导入更多皮肤
|
||||
|
||||
ai-bubu 项目包含更多皮肤资源,可以直接复制:
|
||||
|
||||
```bash
|
||||
# 复制皮肤
|
||||
cp -r ai-bubu-main/packages/app/public/skins/boy frontends/skins/
|
||||
cp -r ai-bubu-main/packages/app/public/skins/dinosaur frontends/skins/
|
||||
cp -r ai-bubu-main/packages/app/public/skins/line frontends/skins/
|
||||
cp -r ai-bubu-main/packages/app/public/skins/mort frontends/skins/
|
||||
cp -r ai-bubu-main/packages/app/public/skins/tard frontends/skins/
|
||||
```
|
||||
|
||||
## 与 stapp.py 集成
|
||||
|
||||
在 `stapp.py` 中点击"🐱 桌面宠物"按钮会自动启动桌面宠物,并在每个 turn 结束时发送通知。
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 皮肤不显示
|
||||
1. 检查 `skin.json` 格式是否正确
|
||||
2. 确认图片文件存在
|
||||
3. 检查 sprite 配置参数是否匹配图片尺寸
|
||||
|
||||
### 动画不流畅
|
||||
- 调整 `fps` 参数
|
||||
- 检查帧数是否正确
|
||||
|
||||
### 透明背景问题
|
||||
- 确保 PNG 文件包含 alpha 通道
|
||||
- 使用 RGBA 模式的图片
|
||||
|
||||
## 技术细节
|
||||
|
||||
- 基于 Tkinter + PIL/Pillow
|
||||
- 支持透明背景(#01FF01 色键)
|
||||
- 窗口置顶、无边框
|
||||
- HTTP 服务器端口:51983
|
||||
@@ -0,0 +1,142 @@
|
||||
"""`/btw` 命令:side question — 不打断主 Agent 的临时 subagent 问答。
|
||||
|
||||
- 持锁 deepcopy backend.history → 后台线程 backend.raw_ask 单次拉答
|
||||
- 主 agent backend.history 零写入;不入 task_queue
|
||||
- 答案 → display_queue 'done'(install 路径)或同步 return(frontend 路径)
|
||||
|
||||
复用 backend.raw_ask + make_messages,不新建 LLM 实例。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import copy, os, threading, time
|
||||
from typing import Optional
|
||||
|
||||
|
||||
_WRAPPER_ZH = """<system-reminder>
|
||||
这是用户的临时插问 (side question)。主 agent 仍在后台运行,**不会被打断**。
|
||||
|
||||
身份与边界:
|
||||
- 你是一个独立的轻量 sub-agent
|
||||
- 上下文里能看到主 agent 与用户的完整对话、最近的工具调用与结果
|
||||
- 用户在问当前进展或顺便确认某事——基于已有信息**一次性**作答
|
||||
- 没有任何工具可用:不要"让我查一下" / "我去试试" / 任何承诺动作
|
||||
- 信息不足就坦白说"基于目前对话我不知道"
|
||||
|
||||
侧问内容如下:
|
||||
</system-reminder>
|
||||
|
||||
{question}"""
|
||||
|
||||
_WRAPPER_EN = """<system-reminder>
|
||||
This is a side question from the user. The main agent is NOT interrupted — it continues in the background.
|
||||
|
||||
Identity & boundaries:
|
||||
- You are an independent lightweight sub-agent
|
||||
- You can see the full conversation between the main agent and the user, plus recent tool calls/results
|
||||
- The user is asking about current progress or a quick aside — answer in **one shot** from existing info
|
||||
- You have NO tools — never say "let me check" / "I'll try" / any action promise
|
||||
- If info is missing, just say "based on the conversation I don't know"
|
||||
|
||||
Question:
|
||||
</system-reminder>
|
||||
|
||||
{question}"""
|
||||
|
||||
_TIMEOUT_SEC = 120
|
||||
|
||||
|
||||
def _wrapper(): return _WRAPPER_EN if os.environ.get('GA_LANG') == 'en' else _WRAPPER_ZH
|
||||
|
||||
|
||||
def _strip_cmd(query):
|
||||
s = (query or '').strip()
|
||||
return s[len('/btw'):].strip() if s.startswith('/btw') else s
|
||||
|
||||
|
||||
def _help_text():
|
||||
return ('**/btw 用法**:side question — 临时问主 agent 当前进展,不打断主线\n\n'
|
||||
'`/btw <你的问题>`\n\n'
|
||||
'行为:抓取当前对话上下文 → 单轮纯文本作答(无工具)→ 主 agent 历史不变。')
|
||||
|
||||
|
||||
def _snapshot_history(backend):
|
||||
"""Lock + deepcopy: defends against concurrent compress_history_tags mutating inner blocks."""
|
||||
with backend.lock:
|
||||
return copy.deepcopy(list(backend.history))
|
||||
|
||||
|
||||
def _build_wire(backend, history, sidequest_msg):
|
||||
"""history + sidequest → wire-format. Dispatches: BaseSession subclasses → make_messages,
|
||||
Native* → raw pairs (raw_ask runs _fix/_drop/_ensure transforms itself)."""
|
||||
msgs = history + [sidequest_msg]
|
||||
if hasattr(backend, 'make_messages'):
|
||||
return backend.make_messages(msgs)
|
||||
return [{"role": m["role"], "content": list(m.get("content", []))} for m in msgs]
|
||||
|
||||
|
||||
def _ask(agent, question, deadline):
|
||||
"""One-shot raw_ask against current backend; never mutates backend.history."""
|
||||
backend = agent.llmclient.backend
|
||||
user_msg = {"role": "user",
|
||||
"content": [{"type": "text", "text": _wrapper().format(question=question)}]}
|
||||
wire = _build_wire(backend, _snapshot_history(backend), user_msg)
|
||||
text = ''
|
||||
for chunk in backend.raw_ask(wire):
|
||||
text += chunk
|
||||
if time.time() > deadline:
|
||||
return text + '\n\n⚠️ /btw 超时,仅返回部分回复。'
|
||||
return text
|
||||
|
||||
|
||||
def _format(question, body, took):
|
||||
head = f'> 🟡 /btw {question}\n\n'
|
||||
return head + (body.strip() or '*(空回复)*') + f'\n\n*({took:.1f}s)*'
|
||||
|
||||
|
||||
def _run(agent, question, deadline):
|
||||
"""Catches errors at the boundary so neither caller path needs its own try/except."""
|
||||
try: return _ask(agent, question, deadline)
|
||||
except Exception as e: return f'❌ /btw 失败: {type(e).__name__}: {e}'
|
||||
|
||||
|
||||
def handle(agent, query, display_queue) -> Optional[str]:
|
||||
"""Slash-cmd entry (server-side, install path). Spawn worker; return None to consume."""
|
||||
question = _strip_cmd(query)
|
||||
if not question or question in ('help', '?', '-h', '--help'):
|
||||
display_queue.put({'done': _help_text(), 'source': 'system'})
|
||||
return None
|
||||
started = time.time()
|
||||
deadline = started + _TIMEOUT_SEC
|
||||
|
||||
def worker():
|
||||
body = _run(agent, question, deadline)
|
||||
display_queue.put({'done': _format(question, body, time.time() - started), 'source': 'system'})
|
||||
|
||||
threading.Thread(target=worker, daemon=True, name='btw-sidequest').start()
|
||||
return None
|
||||
|
||||
|
||||
def handle_frontend_command(agent, query) -> str:
|
||||
"""Sync entry for frontends wanting a string back (tg/wx/stapp/...)."""
|
||||
question = _strip_cmd(query)
|
||||
if not question or question in ('help', '?', '-h', '--help'):
|
||||
return _help_text()
|
||||
started = time.time()
|
||||
body = _run(agent, question, started + _TIMEOUT_SEC)
|
||||
return _format(question, body, time.time() - started)
|
||||
|
||||
|
||||
def install(cls):
|
||||
"""Idempotent monkey-patch: intercept /btw before original dispatch."""
|
||||
orig = cls._handle_slash_cmd
|
||||
if getattr(orig, '_btw_patched', False): return
|
||||
|
||||
def patched(self, raw_query, display_queue):
|
||||
s = (raw_query or '').strip()
|
||||
if s == '/btw' or s.startswith('/btw ') or s.startswith('/btw\t'):
|
||||
r = handle(self, raw_query, display_queue)
|
||||
if r is None: return None
|
||||
return r
|
||||
return orig(self, raw_query, display_queue)
|
||||
|
||||
patched._btw_patched = True
|
||||
cls._handle_slash_cmd = patched
|
||||
|
After Width: | Height: | Size: 493 KiB |
@@ -0,0 +1,347 @@
|
||||
import ast, asyncio, glob, json, os, queue as Q, re, socket, sys, time
|
||||
|
||||
# 确保能导入上级目录的模块(如 agentmain)
|
||||
_parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _parent_dir not in sys.path:
|
||||
sys.path.insert(0, _parent_dir)
|
||||
|
||||
HELP_COMMANDS = (
|
||||
("/help", "显示帮助"),
|
||||
("/status", "查看状态"),
|
||||
("/stop", "停止当前任务"),
|
||||
("/new", "开启新对话并清空当前上下文"),
|
||||
("/restore", "恢复上次对话历史"),
|
||||
("/continue", "列出可恢复会话"),
|
||||
("/continue [n]", "恢复第 n 个会话"),
|
||||
("/btw <q>", "side question — 临时插问主 agent 进展,不打断主线"),
|
||||
("/llm", "查看当前模型列表"),
|
||||
("/llm [n]", "切换到第 n 个模型"),
|
||||
)
|
||||
TELEGRAM_MENU_COMMANDS = (
|
||||
("help", "显示帮助"),
|
||||
("status", "查看状态"),
|
||||
("stop", "停止当前任务"),
|
||||
("new", "开启新对话并清空当前上下文"),
|
||||
("restore", "恢复上次对话历史"),
|
||||
("continue", "列出可恢复会话;/continue n 恢复第 n 个"),
|
||||
("btw", "临时插问主 agent 进展,不打断主线"),
|
||||
("llm", "查看模型列表;/llm n 切换到指定模型"),
|
||||
)
|
||||
|
||||
|
||||
def build_help_text(commands=HELP_COMMANDS):
|
||||
return "📖 命令列表:\n" + "\n".join(f"{cmd} - {desc}" for cmd, desc in commands)
|
||||
|
||||
|
||||
HELP_TEXT = build_help_text()
|
||||
FILE_HINT = "If you need to show files to user, use [FILE:filepath] in your response."
|
||||
TAG_PATS = [r"<" + t + r">.*?</" + t + r">" for t in ("thinking", "summary", "tool_use", "file_content")]
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
RESTORE_GLOBS = (
|
||||
os.path.join(PROJECT_ROOT, "temp", "model_responses", "model_responses_*.txt"),
|
||||
os.path.join(PROJECT_ROOT, "temp", "model_responses_*.txt"),
|
||||
)
|
||||
RESTORE_BLOCK_RE = re.compile(
|
||||
r"^=== (Prompt|Response) ===.*?\n(.*?)(?=^=== (?:Prompt|Response) ===|\Z)",
|
||||
re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
HISTORY_RE = re.compile(r"<history>\s*(.*?)\s*</history>", re.DOTALL)
|
||||
SUMMARY_RE = re.compile(r"<summary>\s*(.*?)\s*</summary>", re.DOTALL)
|
||||
|
||||
|
||||
def clean_reply(text):
|
||||
for pat in TAG_PATS:
|
||||
text = re.sub(pat, "", text or "", flags=re.DOTALL)
|
||||
return re.sub(r"\n{3,}", "\n\n", text).strip() or "..."
|
||||
|
||||
|
||||
def extract_files(text):
|
||||
return re.findall(r"\[FILE:([^\]]+)\]", text or "")
|
||||
|
||||
|
||||
def strip_files(text):
|
||||
return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip()
|
||||
|
||||
|
||||
def split_text(text, limit):
|
||||
text, parts = (text or "").strip() or "...", []
|
||||
while len(text) > limit:
|
||||
cut = text.rfind("\n", 0, limit)
|
||||
if cut < limit * 0.6:
|
||||
cut = limit
|
||||
parts.append(text[:cut].rstrip())
|
||||
text = text[cut:].lstrip()
|
||||
return parts + ([text] if text else []) or ["..."]
|
||||
|
||||
|
||||
def _restore_log_files():
|
||||
files = []
|
||||
for pattern in RESTORE_GLOBS:
|
||||
files.extend(glob.glob(pattern))
|
||||
return sorted(set(files))
|
||||
|
||||
|
||||
def _restore_text_pairs(content):
|
||||
users = re.findall(r"=== USER ===\n(.+?)(?==== |$)", content, re.DOTALL)
|
||||
resps = re.findall(r"=== Response ===.*?\n(.+?)(?==== Prompt|$)", content, re.DOTALL)
|
||||
restored = []
|
||||
for u, r in zip(users, resps):
|
||||
u, r = u.strip(), r.strip()[:500]
|
||||
if u and r:
|
||||
restored.extend([f"[USER]: {u}", f"[Agent] {r}"])
|
||||
return restored
|
||||
|
||||
|
||||
def _native_prompt_obj(prompt_body):
|
||||
try:
|
||||
prompt = json.loads(prompt_body)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(prompt, dict) or prompt.get("role") != "user":
|
||||
return None
|
||||
if not isinstance(prompt.get("content"), list):
|
||||
return None
|
||||
return prompt
|
||||
|
||||
|
||||
def _native_prompt_text(prompt):
|
||||
texts = []
|
||||
for block in prompt.get("content", []):
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
text = block.get("text", "")
|
||||
if isinstance(text, str) and text.strip():
|
||||
texts.append(text)
|
||||
return "\n".join(texts).strip()
|
||||
|
||||
|
||||
def _native_history_lines(prompt_text):
|
||||
match = HISTORY_RE.search(prompt_text or "")
|
||||
if not match:
|
||||
return []
|
||||
restored = []
|
||||
for line in match.group(1).splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("[USER]: ") or line.startswith("[Agent] "):
|
||||
restored.append(line)
|
||||
return restored
|
||||
|
||||
|
||||
def _native_first_user_line(prompt_text):
|
||||
text = (prompt_text or "").strip()
|
||||
if not text or "<history>" in text or text.startswith("### [WORKING MEMORY]"):
|
||||
return ""
|
||||
if text.startswith(FILE_HINT):
|
||||
text = text[len(FILE_HINT):].lstrip()
|
||||
if "### 用户当前消息" in text:
|
||||
text = text.split("### 用户当前消息", 1)[-1].strip()
|
||||
return text
|
||||
|
||||
|
||||
def _native_response_summary(response_body):
|
||||
try:
|
||||
blocks = ast.literal_eval((response_body or "").strip())
|
||||
except Exception:
|
||||
return ""
|
||||
if not isinstance(blocks, list):
|
||||
return ""
|
||||
text_parts = []
|
||||
for block in blocks:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
text = block.get("text", "")
|
||||
if isinstance(text, str) and text:
|
||||
text_parts.append(text)
|
||||
match = SUMMARY_RE.search("\n".join(text_parts))
|
||||
return (match.group(1).strip() if match else "")[:500]
|
||||
|
||||
|
||||
def _restore_native_history(content):
|
||||
blocks = RESTORE_BLOCK_RE.findall(content or "")
|
||||
if not blocks:
|
||||
return []
|
||||
pairs = []
|
||||
pending_prompt = None
|
||||
for label, body in blocks:
|
||||
if label == "Prompt":
|
||||
pending_prompt = body
|
||||
elif pending_prompt is not None:
|
||||
pairs.append((pending_prompt, body))
|
||||
pending_prompt = None
|
||||
for prompt_body, response_body in reversed(pairs):
|
||||
prompt = _native_prompt_obj(prompt_body)
|
||||
if prompt is None:
|
||||
continue
|
||||
prompt_text = _native_prompt_text(prompt)
|
||||
restored = list(_native_history_lines(prompt_text))
|
||||
if restored:
|
||||
summary = _native_response_summary(response_body)
|
||||
summary_line = f"[Agent] {summary}" if summary else ""
|
||||
if summary_line and (not restored or restored[-1] != summary_line):
|
||||
restored.append(summary_line)
|
||||
return restored
|
||||
user_text = _native_first_user_line(prompt_text)
|
||||
summary = _native_response_summary(response_body)
|
||||
if user_text and summary:
|
||||
return [f"[USER]: {user_text}", f"[Agent] {summary}"]
|
||||
return []
|
||||
|
||||
|
||||
def format_restore():
|
||||
files = _restore_log_files()
|
||||
if not files:
|
||||
return None, "❌ 没有找到历史记录"
|
||||
latest = max(files, key=os.path.getmtime)
|
||||
with open(latest, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
restored = _restore_text_pairs(content) or _restore_native_history(content)
|
||||
if not restored:
|
||||
return None, "❌ 历史记录里没有可恢复内容"
|
||||
count = sum(1 for line in restored if line.startswith("[USER]: "))
|
||||
return (restored, os.path.basename(latest), count), None
|
||||
|
||||
|
||||
def build_done_text(raw_text):
|
||||
files = [p for p in extract_files(raw_text) if os.path.exists(p)]
|
||||
body = strip_files(clean_reply(raw_text))
|
||||
if files:
|
||||
body = (body + "\n\n" if body else "") + "\n".join(f"生成文件: {p}" for p in files)
|
||||
return body or "..."
|
||||
|
||||
|
||||
def public_access(allowed):
|
||||
return not allowed or "*" in allowed
|
||||
|
||||
|
||||
def to_allowed_set(value):
|
||||
if value is None:
|
||||
return set()
|
||||
if isinstance(value, str):
|
||||
value = [value]
|
||||
return {str(x).strip() for x in value if str(x).strip()}
|
||||
|
||||
|
||||
def allowed_label(allowed):
|
||||
return "public" if public_access(allowed) else sorted(allowed)
|
||||
|
||||
|
||||
def ensure_single_instance(port, label):
|
||||
try:
|
||||
lock_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
lock_sock.bind(("127.0.0.1", port))
|
||||
return lock_sock
|
||||
except OSError:
|
||||
print(f"[{label}] Another instance is already running, skipping...")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def require_runtime(agent, label, **required):
|
||||
missing = [k for k, v in required.items() if not v]
|
||||
if missing:
|
||||
print(f"[{label}] ERROR: please set {', '.join(missing)} in mykey.py or mykey.json")
|
||||
sys.exit(1)
|
||||
if agent.llmclient is None:
|
||||
print(f"[{label}] ERROR: no usable LLM backend found in mykey.py or mykey.json")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def redirect_log(script_file, log_name, label, allowed):
|
||||
log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(script_file))), "temp")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
logf = open(os.path.join(log_dir, log_name), "a", encoding="utf-8", buffering=1)
|
||||
sys.stdout = sys.stderr = logf
|
||||
print(f"[NEW] {label} process starting, the above are history infos ...")
|
||||
print(f"[{label}] allow list: {allowed_label(allowed)}")
|
||||
|
||||
|
||||
class AgentChatMixin:
|
||||
label = "Chat"
|
||||
source = "chat"
|
||||
split_limit = 1500
|
||||
ping_interval = 20
|
||||
|
||||
def __init__(self, agent, user_tasks):
|
||||
self.agent, self.user_tasks = agent, user_tasks
|
||||
|
||||
async def send_text(self, chat_id, content, **ctx):
|
||||
raise NotImplementedError
|
||||
|
||||
async def send_done(self, chat_id, raw_text, **ctx):
|
||||
await self.send_text(chat_id, build_done_text(raw_text), **ctx)
|
||||
|
||||
async def handle_command(self, chat_id, cmd, **ctx):
|
||||
parts = (cmd or "").split()
|
||||
op = (parts[0] if parts else "").lower()
|
||||
if op == "/help":
|
||||
return await self.send_text(chat_id, HELP_TEXT, **ctx)
|
||||
if op == "/stop":
|
||||
state = self.user_tasks.get(chat_id)
|
||||
if state:
|
||||
state["running"] = False
|
||||
self.agent.abort()
|
||||
return await self.send_text(chat_id, "⏹️ 正在停止...", **ctx)
|
||||
if op == "/status":
|
||||
llm = self.agent.get_llm_name() if self.agent.llmclient else "未配置"
|
||||
return await self.send_text(chat_id, f"状态: {'🔴 运行中' if self.agent.is_running else '🟢 空闲'}\nLLM: [{self.agent.llm_no}] {llm}", **ctx)
|
||||
if op == "/llm":
|
||||
if not self.agent.llmclient:
|
||||
return await self.send_text(chat_id, "❌ 当前没有可用的 LLM 配置", **ctx)
|
||||
if len(parts) > 1:
|
||||
try:
|
||||
self.agent.next_llm(int(parts[1]))
|
||||
return await self.send_text(chat_id, f"✅ 已切换到 [{self.agent.llm_no}] {self.agent.get_llm_name()}", **ctx)
|
||||
except Exception:
|
||||
return await self.send_text(chat_id, f"用法: /llm <0-{len(self.agent.list_llms()) - 1}>", **ctx)
|
||||
lines = [f"{'→' if cur else ' '} [{i}] {name}" for i, name, cur in self.agent.list_llms()]
|
||||
return await self.send_text(chat_id, "LLMs:\n" + "\n".join(lines), **ctx)
|
||||
if op == "/restore":
|
||||
try:
|
||||
restored_info, err = format_restore()
|
||||
if err:
|
||||
return await self.send_text(chat_id, err, **ctx)
|
||||
restored, fname, count = restored_info
|
||||
self.agent.abort()
|
||||
self.agent.history.extend(restored)
|
||||
return await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)", **ctx)
|
||||
except Exception as e:
|
||||
return await self.send_text(chat_id, f"❌ 恢复失败: {e}", **ctx)
|
||||
if op == "/continue":
|
||||
return await self.send_text(chat_id, _handle_continue_frontend(self.agent, cmd), **ctx)
|
||||
if op == "/new":
|
||||
return await self.send_text(chat_id, _reset_conversation(self.agent), **ctx)
|
||||
if op == "/btw":
|
||||
answer = await asyncio.to_thread(_handle_btw_frontend, self.agent, cmd)
|
||||
return await self.send_text(chat_id, answer, **ctx)
|
||||
return await self.send_text(chat_id, HELP_TEXT, **ctx)
|
||||
|
||||
async def run_agent(self, chat_id, text, **ctx):
|
||||
state = {"running": True}
|
||||
self.user_tasks[chat_id] = state
|
||||
try:
|
||||
await self.send_text(chat_id, "思考中...", **ctx)
|
||||
dq = self.agent.put_task(f"{FILE_HINT}\n\n{text}", source=self.source)
|
||||
last_ping = time.time()
|
||||
while state["running"]:
|
||||
try:
|
||||
item = await asyncio.to_thread(dq.get, True, 3)
|
||||
except Q.Empty:
|
||||
if self.agent.is_running and time.time() - last_ping > self.ping_interval:
|
||||
await self.send_text(chat_id, "⏳ 还在处理中,请稍等...", **ctx)
|
||||
last_ping = time.time()
|
||||
continue
|
||||
if "done" in item:
|
||||
await self.send_done(chat_id, item.get("done", ""), **ctx)
|
||||
break
|
||||
if not state["running"]:
|
||||
await self.send_text(chat_id, "⏹️ 已停止", **ctx)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[{self.label}] run_agent error: {e}")
|
||||
traceback.print_exc()
|
||||
await self.send_text(chat_id, f"❌ 错误: {e}", **ctx)
|
||||
finally:
|
||||
self.user_tasks.pop(chat_id, None)
|
||||
|
||||
|
||||
from agentmain import GeneraticAgent as _GA
|
||||
from continue_cmd import handle_frontend_command as _handle_continue_frontend, install as _install_continue, reset_conversation as _reset_conversation
|
||||
_install_continue(_GA)
|
||||
from btw_cmd import handle_frontend_command as _handle_btw_frontend, install as _install_btw; _install_btw(_GA)
|
||||
@@ -0,0 +1,421 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Conductor Streamlit</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f7fb;
|
||||
--panel: rgba(255,255,255,0.78);
|
||||
--panel-2: rgba(255,255,255,0.92);
|
||||
--border: rgba(31,42,68,0.10);
|
||||
--text: #172033;
|
||||
--muted: #7a8497;
|
||||
--soft: #43506a;
|
||||
--accent: #3b82f6;
|
||||
--accent-2: #8b5cf6;
|
||||
--green: #10b981;
|
||||
--amber: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--gray: #9ca3af;
|
||||
--shadow: 0 18px 55px rgba(30, 45, 80, .11);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
font-family: Inter, "SF Pro Display", "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(900px 600px at 12% 8%, rgba(59, 130, 246, .14), transparent 54%),
|
||||
radial-gradient(800px 650px at 86% 18%, rgba(139, 92, 246, .12), transparent 58%),
|
||||
radial-gradient(650px 520px at 61% 94%, rgba(16, 185, 129, .10), transparent 60%),
|
||||
linear-gradient(180deg, #fbfcff, var(--bg));
|
||||
}
|
||||
|
||||
.app {
|
||||
height: 100vh;
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 45%) 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.pane {
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.86), rgba(255,255,255,.66));
|
||||
backdrop-filter: blur(18px);
|
||||
border-radius: 26px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left, .chat { display: flex; flex-direction: column; }
|
||||
|
||||
.head {
|
||||
padding: 20px 20px 14px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(31,42,68,.075);
|
||||
background: rgba(255,255,255,.34);
|
||||
}
|
||||
.title {
|
||||
font-size: 18px;
|
||||
letter-spacing: .2px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cards {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.cards::-webkit-scrollbar, .messages::-webkit-scrollbar { width: 8px; }
|
||||
.cards::-webkit-scrollbar-thumb, .messages::-webkit-scrollbar-thumb {
|
||||
background: rgba(31,42,68,.16);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
padding: 15px 15px 14px 17px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255,255,255,.76);
|
||||
border: 1px solid rgba(31,42,68,.10);
|
||||
box-shadow: 0 8px 24px rgba(30,45,80,.055);
|
||||
transition: transform .18s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-1px);
|
||||
background: rgba(255,255,255,.95);
|
||||
border-color: rgba(59,130,246,.24);
|
||||
box-shadow: 0 12px 30px rgba(30,45,80,.08);
|
||||
}
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; top: 16px; bottom: 16px;
|
||||
width: 4px;
|
||||
border-radius: 99px;
|
||||
background: var(--state);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--state), transparent 84%);
|
||||
}
|
||||
.card.running { --state: var(--green); }
|
||||
.card.waiting { --state: var(--amber); }
|
||||
.card.stopped { --state: var(--gray); }
|
||||
.card.failed { --state: var(--red); }
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dot-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--state);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--state), transparent 86%);
|
||||
}
|
||||
.label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
letter-spacing: .10em;
|
||||
}
|
||||
.prompt {
|
||||
color: #24314d;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 12px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.reply {
|
||||
color: var(--soft);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
background: rgba(245,247,251,.78);
|
||||
border: 1px solid rgba(31,42,68,.07);
|
||||
border-radius: 14px;
|
||||
padding: 11px 12px;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.reply p, .reply ul, .reply ol, .reply pre, .reply blockquote {
|
||||
margin: 0 0 .25em 0;
|
||||
}
|
||||
.reply p:last-child, .reply ul:last-child, .reply ol:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.reply li { margin: 0; line-height: 1.35; }
|
||||
.reply code {
|
||||
font-size: 12px;
|
||||
background: rgba(0,0,0,.05);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.reply pre { white-space: pre-wrap; margin: .2em 0; }
|
||||
.reply pre code { background: rgba(0,0,0,.04); display: block; padding: 6px 8px; border-radius: 5px; }
|
||||
.reply::-webkit-scrollbar { width: 5px; }
|
||||
.reply::-webkit-scrollbar-thumb {
|
||||
background: rgba(31,42,68,.14);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.msg {
|
||||
max-width: 78%;
|
||||
padding: 13px 15px;
|
||||
border-radius: 18px;
|
||||
line-height: 1.45;
|
||||
font-size: 14px;
|
||||
word-wrap: break-word;
|
||||
box-shadow: 0 8px 22px rgba(30,45,80,.055);
|
||||
}
|
||||
.msg p, .msg ul, .msg ol, .msg pre, .msg blockquote {
|
||||
margin: 0 0 .3em 0;
|
||||
}
|
||||
.msg p:last-child, .msg ul:last-child, .msg ol:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.msg li { margin: 0; line-height: 1.4; }
|
||||
.msg code {
|
||||
font-size: 12px;
|
||||
background: rgba(0,0,0,.05);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.msg pre { white-space: pre-wrap; margin: .3em 0; }
|
||||
.msg pre code { background: rgba(0,0,0,.04); display: block; padding: 6px 8px; border-radius: 6px; }
|
||||
.msg.me {
|
||||
align-self: flex-end;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #3b82f6, #7c3aed);
|
||||
border: 1px solid rgba(59,130,246,.20);
|
||||
border-bottom-right-radius: 7px;
|
||||
}
|
||||
.msg.me.unread {
|
||||
box-shadow: 0 0 0 2px #f59e0b, 0 8px 22px rgba(30,45,80,.055);
|
||||
}
|
||||
.msg.me.unread::after {
|
||||
content: '未读';
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
opacity: .85;
|
||||
color: #fbbf24;
|
||||
}
|
||||
.msg.ai {
|
||||
align-self: flex-start;
|
||||
background: rgba(255,255,255,.88);
|
||||
border: 1px solid rgba(31,42,68,.10);
|
||||
border-bottom-left-radius: 7px;
|
||||
}
|
||||
.msg.system {
|
||||
align-self: center;
|
||||
max-width: 88%;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
padding: 7px 11px;
|
||||
border-radius: 99px;
|
||||
background: rgba(255,255,255,.70);
|
||||
border: 1px solid rgba(31,42,68,.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.composer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid rgba(31,42,68,.075);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
background: rgba(255,255,255,.40);
|
||||
}
|
||||
textarea {
|
||||
flex: 1;
|
||||
height: 52px;
|
||||
max-height: 140px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
background: rgba(255,255,255,.92);
|
||||
border: 1px solid rgba(31,42,68,.12);
|
||||
border-radius: 18px;
|
||||
padding: 14px 15px;
|
||||
font: inherit;
|
||||
line-height: 1.35;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.8);
|
||||
}
|
||||
textarea:focus {
|
||||
border-color: rgba(59,130,246,.44);
|
||||
box-shadow: 0 0 0 4px rgba(59,130,246,.10);
|
||||
}
|
||||
textarea::placeholder { color: #98a1b2; }
|
||||
button {
|
||||
height: 52px;
|
||||
min-width: 92px;
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
color: white;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, #3b82f6, #10b981);
|
||||
box-shadow: 0 10px 26px rgba(59,130,246,.22);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app { grid-template-columns: 1fr; grid-template-rows: 42% 1fr; padding: 12px; }
|
||||
.msg { max-width: 92%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
<section class="pane left">
|
||||
<div class="head">
|
||||
<div class="title">Conductor</div>
|
||||
<div class="hint">subagents · live</div>
|
||||
</div>
|
||||
<div class="cards" id="cards">
|
||||
<div class="empty">暂无 subagent。你可以在右侧告诉 Conductor 要做什么。</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pane chat">
|
||||
<div class="head">
|
||||
<div class="title">Conversation</div>
|
||||
<div class="hint" id="wsStatus">connecting...</div>
|
||||
</div>
|
||||
<div class="messages" id="messages">
|
||||
<div class="msg system">Conductor 正在启动,等待 WebSocket 连接。</div>
|
||||
</div>
|
||||
<div class="composer">
|
||||
<textarea id="input" placeholder="告诉 Conductor 你想做什么……"></textarea>
|
||||
<button id="send">发送</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script>
|
||||
const cardsEl = document.getElementById('cards');
|
||||
const messagesEl = document.getElementById('messages');
|
||||
const inputEl = document.getElementById('input');
|
||||
const sendBtn = document.getElementById('send');
|
||||
const wsStatus = document.getElementById('wsStatus');
|
||||
let ws;
|
||||
|
||||
function esc(s) {
|
||||
return String(s || '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
}
|
||||
|
||||
function renderMd(s) {
|
||||
try { return marked.parse(s || ''); } catch(e) { return esc(s); }
|
||||
}
|
||||
|
||||
function renderCards(items) {
|
||||
if (!items || !items.length) {
|
||||
cardsEl.innerHTML = '<div class="empty">暂无 subagent。你可以在右侧告诉 Conductor 要做什么。</div>';
|
||||
return;
|
||||
}
|
||||
cardsEl.innerHTML = items.map(it => {
|
||||
const status = it.status || 'stopped';
|
||||
return `
|
||||
<article class="card ${esc(status)}">
|
||||
<div class="status-row"><span class="dot-text"><span class="dot"></span>${esc(status)}</span></div>
|
||||
<div class="prompt">${esc(it.prompt)}</div>
|
||||
<div class="reply">${renderMd(it.reply || '等待输出……')}</div>
|
||||
</article>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function addMsg(item) {
|
||||
const role = item.role || 'system';
|
||||
const cls = role === 'user' ? 'me' : (role === 'conductor' ? 'ai' : 'system');
|
||||
const div = document.createElement('div');
|
||||
div.className = `msg ${cls}` + (role === 'user' && !item.read ? ' unread' : '');
|
||||
if (item.id) div.dataset.id = item.id;
|
||||
div.innerHTML = renderMd(item.msg || '');
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
function markAllRead() {
|
||||
messagesEl.querySelectorAll('.msg.me.unread').forEach(el => el.classList.remove('unread'));
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
ws.onopen = () => wsStatus.textContent = 'connected';
|
||||
ws.onclose = () => {
|
||||
wsStatus.textContent = 'disconnected · retrying';
|
||||
setTimeout(connect, 1200);
|
||||
};
|
||||
ws.onerror = () => wsStatus.textContent = 'error';
|
||||
ws.onmessage = ev => {
|
||||
const data = JSON.parse(ev.data);
|
||||
if (data.type === 'hello') {
|
||||
renderCards(data.subagents || []);
|
||||
messagesEl.innerHTML = '';
|
||||
(data.chat || []).forEach(addMsg);
|
||||
if (!(data.chat || []).length) addMsg({role:'system', msg:'Conductor 已启动,正在监听用户消息和 subagent 输出。'});
|
||||
} else if (data.type === 'subagents') {
|
||||
renderCards(data.items || []);
|
||||
} else if (data.type === 'chat') {
|
||||
addMsg(data.item);
|
||||
} else if (data.type === 'chat_read') {
|
||||
markAllRead();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function send() {
|
||||
const msg = inputEl.value.trim();
|
||||
if (!msg || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({msg}));
|
||||
inputEl.value = '';
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
sendBtn.addEventListener('click', send);
|
||||
inputEl.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
});
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,415 @@
|
||||
import os, sys, re, time, json, uuid, queue, asyncio, threading, builtins
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
# Silence print() from subagent threads (they share stdout with conductor)
|
||||
_original_print = builtins.print
|
||||
def _filtered_print(*args, **kwargs):
|
||||
t = threading.current_thread()
|
||||
if t.name.startswith('subagent-'):
|
||||
return
|
||||
return _original_print(*args, **kwargs)
|
||||
builtins.print = _filtered_print
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, PlainTextResponse, JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
# allow: python frontends/conductor.py
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from agentmain import GenericAgent
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 8900
|
||||
HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "conductor.html")
|
||||
|
||||
app = FastAPI(title="Conductor")
|
||||
|
||||
class ChatIn(BaseModel):
|
||||
msg: str
|
||||
role: str = "conductor" # conductor | system | user
|
||||
|
||||
class StartSubagentIn(BaseModel):
|
||||
prompt: str
|
||||
|
||||
class SubagentActionIn(BaseModel):
|
||||
action: str = "intervene" # intervene | abort | kill
|
||||
msg: str = ""
|
||||
|
||||
@dataclass
|
||||
class SubAgentState:
|
||||
id: str
|
||||
agent: GenericAgent
|
||||
prompt: str
|
||||
reply: str = ""
|
||||
status: str = "running" # running | stopped | failed | aborted
|
||||
created_at: float = field(default_factory=time.time)
|
||||
updated_at: float = field(default_factory=time.time)
|
||||
last_done: str = ""
|
||||
monitor_threads: List[threading.Thread] = field(default_factory=list)
|
||||
|
||||
subagents: Dict[str, SubAgentState] = {}
|
||||
sub_lock = threading.RLock()
|
||||
ws_clients: set[WebSocket] = set()
|
||||
main_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
# conductor event queue: only user messages and subagent-done events enter here.
|
||||
conductor_events: "queue.Queue[dict]" = queue.Queue()
|
||||
conductor_agent: Optional[GenericAgent] = None
|
||||
conductor_started = False
|
||||
chat_messages: List[dict] = []
|
||||
|
||||
def now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
def short_id() -> str:
|
||||
return uuid.uuid4().hex[:8]
|
||||
|
||||
_TURN_SPLIT_RE = re.compile(r'\**LLM Running \(Turn \d+\) \.\.\.\**')
|
||||
_SUMMARY_RE = re.compile(r'<summary>(.*?)</summary>\s*', re.DOTALL)
|
||||
|
||||
def extract_last_summary(full: str) -> str:
|
||||
"""Extract the latest <summary> content for in-progress display."""
|
||||
matches = _SUMMARY_RE.findall(full or "")
|
||||
if not matches:
|
||||
return ""
|
||||
s = matches[-1].strip()
|
||||
return s[-1000:] if len(s) > 1000 else s
|
||||
|
||||
def extract_last_text_reply(full: str) -> str:
|
||||
"""Extract only the last turn's text reply (like stapp.py fold_turns logic)."""
|
||||
# Split by turn markers, take last segment
|
||||
parts = _TURN_SPLIT_RE.split(full)
|
||||
last = parts[-1] if parts else full
|
||||
# Strip <summary> tags
|
||||
last = _SUMMARY_RE.sub('', last)
|
||||
# Strip [Status] and [Info] lines
|
||||
last = re.sub(r'\[(Status|Info)\][^\n]*\n?', '', last)
|
||||
# Strip trailing whitespace
|
||||
last = last.strip()
|
||||
# Cap length
|
||||
return last[-3000:] if len(last) > 3000 else last
|
||||
|
||||
def subagent_snapshot() -> list[dict]:
|
||||
with sub_lock:
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
"prompt": s.prompt,
|
||||
"reply": (extract_last_summary(s.reply) if s.status == "running" else extract_last_text_reply(s.reply)) if s.reply else "",
|
||||
"status": s.status,
|
||||
"created_at": s.created_at,
|
||||
"updated_at": s.updated_at,
|
||||
}
|
||||
for s in subagents.values()
|
||||
if s.status != "aborted"
|
||||
]
|
||||
|
||||
def schedule_broadcast(payload: dict):
|
||||
if main_loop and main_loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(broadcast(payload), main_loop)
|
||||
|
||||
async def broadcast(payload: dict):
|
||||
dead = []
|
||||
for ws in list(ws_clients):
|
||||
try:
|
||||
await ws.send_json(payload)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
ws_clients.discard(ws)
|
||||
|
||||
def push_cards():
|
||||
schedule_broadcast({"type": "subagents", "items": subagent_snapshot()})
|
||||
|
||||
def add_chat(msg: str, role: str = "conductor"):
|
||||
item = {"id": short_id(), "role": role, "msg": msg, "ts": now_ms(), "read": role != "user"}
|
||||
chat_messages.append(item)
|
||||
if len(chat_messages) > 200:
|
||||
del chat_messages[:-200]
|
||||
schedule_broadcast({"type": "chat", "item": item})
|
||||
return item
|
||||
|
||||
def start_agent_runner(agent: GenericAgent, name: str):
|
||||
t = threading.Thread(target=agent.run, name=name, daemon=True)
|
||||
t.start()
|
||||
return t
|
||||
|
||||
def monitor_display_queue(agent_id: str, dq: "queue.Queue", trigger_when_done: bool):
|
||||
"""Consume one GenericAgent display_queue.
|
||||
|
||||
next: update card only, never wake conductor.
|
||||
done: update card/chat, then wake conductor if this is subagent queue.
|
||||
"""
|
||||
acc = ""
|
||||
while True:
|
||||
item = dq.get()
|
||||
if "next" in item:
|
||||
chunk = item.get("next") or ""
|
||||
# agent.inc_out=True means next is delta.
|
||||
acc += chunk
|
||||
with sub_lock:
|
||||
s = subagents.get(agent_id)
|
||||
if s:
|
||||
s.reply = acc
|
||||
s.status = "running"
|
||||
s.updated_at = time.time()
|
||||
push_cards()
|
||||
if "done" in item:
|
||||
done = item.get("done") or acc
|
||||
with sub_lock:
|
||||
s = subagents.get(agent_id)
|
||||
if s:
|
||||
s.reply = done
|
||||
s.last_done = done
|
||||
if s.status != "aborted":
|
||||
s.status = "stopped"
|
||||
s.updated_at = time.time()
|
||||
push_cards()
|
||||
if trigger_when_done:
|
||||
conductor_events.put({"type": "subagent_done", "id": agent_id, "reply": done})
|
||||
break
|
||||
|
||||
def start_subagent(prompt: str) -> dict:
|
||||
sid = short_id()
|
||||
agent = GenericAgent()
|
||||
agent.inc_out = True
|
||||
agent.verbose = False
|
||||
|
||||
start_agent_runner(agent, f"subagent-{sid}")
|
||||
state = SubAgentState(id=sid, agent=agent, prompt=prompt, status="running")
|
||||
with sub_lock:
|
||||
subagents[sid] = state
|
||||
dq = agent.put_task(prompt, source=f"subagent:{sid}")
|
||||
mt = threading.Thread(target=monitor_display_queue, args=(sid, dq, True), name=f"monitor-{sid}", daemon=True)
|
||||
mt.start()
|
||||
state.monitor_threads.append(mt)
|
||||
push_cards()
|
||||
return {"id": sid, "status": "running"}
|
||||
|
||||
def keyinfo_subagent(sid: str, msg: str) -> dict:
|
||||
"""Inject into agent's working key_info; visible from next turn onward."""
|
||||
with sub_lock:
|
||||
s = subagents.get(sid)
|
||||
if not s:
|
||||
return {"error": "subagent not found", "id": sid}
|
||||
h = s.agent.handler
|
||||
h.working['key_info'] = h.working.get('key_info', '') + f"\n[MASTER] {msg}"
|
||||
s.updated_at = time.time()
|
||||
return {"id": sid, "status": "keyinfo_injected"}
|
||||
|
||||
def input_subagent(sid: str, msg: str) -> dict:
|
||||
"""Start a new task round (used for input/reply when agent is stopped)."""
|
||||
with sub_lock:
|
||||
s = subagents.get(sid)
|
||||
if not s:
|
||||
return {"error": "subagent not found", "id": sid}
|
||||
if s.status == "running":
|
||||
return {"error": "subagent is still running, cannot input/reply. Start a new subagent instead.", "id": sid}
|
||||
s.prompt = msg
|
||||
s.reply = ""
|
||||
s.status = "running"
|
||||
s.updated_at = time.time()
|
||||
dq = s.agent.put_task(msg, source=f"subagent:{sid}")
|
||||
mt = threading.Thread(target=monitor_display_queue, args=(sid, dq, True), name=f"monitor-{sid}", daemon=True)
|
||||
mt.start()
|
||||
s.monitor_threads.append(mt)
|
||||
push_cards()
|
||||
return {"id": sid, "status": "running"}
|
||||
|
||||
def conductor_readme() -> str:
|
||||
base = f"http://{HOST}:{PORT}"
|
||||
return "\n".join([
|
||||
f"Conductor API\tBase: {base}",
|
||||
"",
|
||||
"POST /chat\tbody: {\"msg\": \"...\"}\t给用户发消息",
|
||||
"POST /subagent\tbody: {\"prompt\": \"...\"}\t启动新subagent,返回 {\"id\": \"xxx\"}",
|
||||
'POST /subagent/{id}\tbody: {\"action\": \"keyinfo\", \"msg\": \"...\"}\t注入key_info(agent下轮可见)',
|
||||
'POST /subagent/{id}\tbody: {\"action\": \"input\", \"msg\": \"...\"}\t开新一轮任务(agent停下后追加)',
|
||||
'POST /subagent/{id}\tbody: {\"action\": \"stop\"}\t中断执行但保留(可继续input/reply)',
|
||||
'POST /subagent/{id}\tbody: {\"action\": \"kill\"}\t彻底杀死(从卡片消失,不可复用)',
|
||||
"GET /chat?last=N\t返回最近N条对话(默认20)",
|
||||
"GET /subagent\t返回 {\"items\": [...]}\t查看所有subagent状态",
|
||||
"GET /readme\t本文档",
|
||||
"",
|
||||
"触发时机: 用户新消息 | subagent done",
|
||||
])
|
||||
|
||||
def conductor_prompt_from_events(events: list) -> str:
|
||||
# 极简摘要:subagent数量和状态
|
||||
with sub_lock:
|
||||
running = sum(1 for s in subagents.values() if s.status == "running")
|
||||
stopped = sum(1 for s in subagents.values() if s.status != "running")
|
||||
unread = sum(1 for m in chat_messages if m.get("role") == "user" and not m.get("read"))
|
||||
done_count = sum(1 for e in events if e.get("type") == "subagent_done")
|
||||
summary = f"subagents: {running} running, {stopped} stopped | {unread}条用户未读消息, {done_count}个subagent完成报告"
|
||||
base = f"http://{HOST}:{PORT}"
|
||||
return f"""你是agent总管。用户只和你对话,你负责调度、验收、交付,目标是降低用户管理多个agent的负担。
|
||||
API: {base};先requests,GET /readme查用法,GET /chat读未读对话,GET /subagent看状态;POST /chat是唯一对用户说话方式。
|
||||
|
||||
铁律:
|
||||
- 绝不亲自执行任务/探测环境;一切执行交给subagent。你只分析、派遣、审查、沟通。
|
||||
- 每次唤醒只做最小必要动作(发消息/开subagent/reply/keyinfo/abort),做完立刻停,等待下次事件唤醒。
|
||||
- 改写prompt时严禁添加用户未提及的假设、工具、前提条件。只能精炼/结构化用户原意,不能脑补,只能做很小的改写
|
||||
|
||||
用户消息流程:
|
||||
1. 结合记忆、上下文和用户偏好判断真实需求;不清楚/不能代劳时,用精简checklist一次性问用户。
|
||||
2. 判断是新任务还是延续现有任务;优先复用已有stopped subagent(用input追加),只有确实无关的新任务才新建。
|
||||
3. 分派前必须POST /chat告知用户:改写后的prompt + 分派方案(新建/复用哪个subagent)。
|
||||
4. 执行分派,完成即停。危险操作(改源码/删数据/安全敏感)必须改成先让subagent出方案;你验收后POST /chat请用户确认,确认后才继续执行。
|
||||
|
||||
subagent完成流程:
|
||||
1. 读subagent输出;若最后一条不足以判断,GET /subagent或日志补足信息。
|
||||
2. 预测用户是否满意;不满意就reply/keyinfo要求返工、修改、优化,继续监督,不急着报告。
|
||||
3. 预计用户满意后,POST /chat给简洁交付报告。
|
||||
|
||||
原则:
|
||||
- 信任subagent足够聪明,不要写具体步骤和容易探测的信息;能自己判断的自己判断,只在真正需要用户决策时打扰。
|
||||
{summary}"""
|
||||
|
||||
def _auto_cleanup_loop():
|
||||
"""Background: auto-abort stopped subagents idle for >1 hour."""
|
||||
IDLE_TIMEOUT = 3600 # 1 hour
|
||||
while True:
|
||||
time.sleep(300) # check every 5 minutes
|
||||
now = time.time()
|
||||
to_abort = []
|
||||
with sub_lock:
|
||||
for sid, s in subagents.items():
|
||||
if s.status == "stopped" and (now - s.updated_at) > IDLE_TIMEOUT:
|
||||
to_abort.append((sid, s))
|
||||
for sid, s in to_abort:
|
||||
s.agent.abort()
|
||||
with sub_lock:
|
||||
s.status = "aborted"
|
||||
s.updated_at = now
|
||||
if to_abort:
|
||||
push_cards()
|
||||
|
||||
def monitor_conductor_queue(dq: "queue.Queue"):
|
||||
"""Conductor output is discarded. Visible reply must go through POST /chat only."""
|
||||
while True:
|
||||
item = dq.get()
|
||||
if "done" in item:
|
||||
print(f"Conductor task done")
|
||||
break
|
||||
|
||||
def conductor_loop():
|
||||
global conductor_agent, conductor_started
|
||||
conductor_agent = GenericAgent()
|
||||
conductor_agent.inc_out = True
|
||||
start_agent_runner(conductor_agent, "conductor-agent")
|
||||
conductor_started = True
|
||||
# Start background cleanup thread
|
||||
threading.Thread(target=_auto_cleanup_loop, name="subagent-cleanup", daemon=True).start()
|
||||
while True:
|
||||
# Block until first event arrives
|
||||
first = conductor_events.get()
|
||||
conductor_events.task_done()
|
||||
# Short debounce: collect any additional events that arrived meanwhile
|
||||
time.sleep(0.3)
|
||||
events = [first]
|
||||
while not conductor_events.empty():
|
||||
try:
|
||||
events.append(conductor_events.get_nowait())
|
||||
conductor_events.task_done()
|
||||
except Exception:
|
||||
break
|
||||
try:
|
||||
prompt = conductor_prompt_from_events(events)
|
||||
dq = conductor_agent.put_task(prompt, source="conductor")
|
||||
# Block here until conductor finishes — serializes execution
|
||||
monitor_conductor_queue(dq)
|
||||
except Exception as e:
|
||||
add_chat(f"Conductor error: {e}", role="system")
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
global main_loop
|
||||
main_loop = asyncio.get_running_loop()
|
||||
threading.Thread(target=conductor_loop, name="conductor-loop", daemon=True).start()
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return FileResponse(HTML_PATH)
|
||||
|
||||
@app.get("/readme")
|
||||
def readme():
|
||||
return PlainTextResponse(conductor_readme())
|
||||
|
||||
@app.get("/subagent")
|
||||
def list_subagents():
|
||||
return {"items": subagent_snapshot()}
|
||||
|
||||
@app.post("/subagent")
|
||||
def api_start_subagent(body: StartSubagentIn):
|
||||
result = start_subagent(body.prompt)
|
||||
result["instruction"] = "Task received. I'll handle it from here. You MUST stop now and end your reply. Wait for next event."
|
||||
return result
|
||||
|
||||
@app.post("/subagent/{sid}")
|
||||
def api_subagent_action(sid: str, body: SubagentActionIn):
|
||||
with sub_lock:
|
||||
s = subagents.get(sid)
|
||||
if not s:
|
||||
return JSONResponse({"error": "subagent not found", "id": sid}, status_code=404)
|
||||
action = body.action.lower().strip()
|
||||
if action == "keyinfo":
|
||||
result = keyinfo_subagent(sid, body.msg)
|
||||
result["instruction"] = "Received. I'll incorporate this. You MUST stop now and end your reply."
|
||||
return result
|
||||
if action in ("input", "reply", "append", "message", "msg"):
|
||||
result = input_subagent(sid, body.msg)
|
||||
result["instruction"] = "Task received. I'll handle it from here. You MUST stop now and end your reply."
|
||||
return result
|
||||
if action in ("abort", "stop"):
|
||||
s.agent.abort()
|
||||
s.status = "stopped"
|
||||
s.updated_at = time.time()
|
||||
push_cards()
|
||||
return {"id": sid, "status": "stopped"}
|
||||
if action == "kill":
|
||||
s.agent.abort()
|
||||
s.status = "aborted"
|
||||
s.updated_at = time.time()
|
||||
push_cards()
|
||||
return {"id": sid, "status": "aborted"}
|
||||
return JSONResponse({"error": f"unknown action: {body.action}"}, status_code=400)
|
||||
|
||||
@app.get("/chat")
|
||||
def api_get_chat(last: int = 20):
|
||||
"""按需拉取最近N条对话,同时标记所有用户消息为已读"""
|
||||
for m in chat_messages:
|
||||
if m.get("role") == "user" and not m.get("read"):
|
||||
m["read"] = True
|
||||
schedule_broadcast({"type": "chat_read"})
|
||||
return {"items": chat_messages[-last:]}
|
||||
|
||||
@app.post("/chat")
|
||||
def api_chat(body: ChatIn):
|
||||
return add_chat(body.msg, role=body.role)
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket(ws: WebSocket):
|
||||
await ws.accept()
|
||||
ws_clients.add(ws)
|
||||
try:
|
||||
await ws.send_json({"type": "hello", "subagents": subagent_snapshot(), "chat": chat_messages})
|
||||
while True:
|
||||
data = await ws.receive_json()
|
||||
msg = (data.get("msg") or "").strip()
|
||||
if not msg:
|
||||
continue
|
||||
add_chat(msg, role="user")
|
||||
conductor_events.put({"type": "user_message", "msg": msg})
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
ws_clients.discard(ws)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn, webbrowser, threading
|
||||
threading.Timer(1.0, lambda: webbrowser.open(f"http://{HOST}:{PORT}")).start()
|
||||
uvicorn.run("conductor:app", host=HOST, port=PORT, reload=False)
|
||||
@@ -0,0 +1,315 @@
|
||||
"""`/continue` command: list & restore past model_responses sessions.
|
||||
Pure functions + one `install(cls)` monkey-patch entry. No side effects at import.
|
||||
"""
|
||||
import ast, glob, json, os, re, time
|
||||
_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'temp', 'model_responses')
|
||||
_LOG_GLOB = os.path.join(_LOG_DIR, 'model_responses_*.txt')
|
||||
_BLOCK_RE = re.compile(r'^=== (Prompt|Response) ===.*?\n(.*?)(?=^=== (?:Prompt|Response) ===|\Z)',
|
||||
re.DOTALL | re.MULTILINE)
|
||||
_SUMMARY_RE = re.compile(r'<summary>\s*(.*?)\s*</summary>', re.DOTALL)
|
||||
|
||||
def _rel_time(mtime):
|
||||
d = int(time.time() - mtime)
|
||||
if d < 60: return f'{d}秒前'
|
||||
if d < 3600: return f'{d // 60}分前'
|
||||
if d < 86400: return f'{d // 3600}小时前'
|
||||
return f'{d // 86400}天前'
|
||||
|
||||
def _pairs(content):
|
||||
blocks, pairs, pending = _BLOCK_RE.findall(content or ''), [], None
|
||||
for label, body in blocks:
|
||||
if label == 'Prompt': pending = body.strip()
|
||||
elif pending is not None:
|
||||
pairs.append((pending, body.strip())); pending = None
|
||||
return pairs
|
||||
|
||||
def _first_user(pairs):
|
||||
for p, _ in pairs:
|
||||
try: msg = json.loads(p)
|
||||
except Exception: continue
|
||||
if not isinstance(msg, dict): continue
|
||||
for blk in msg.get('content', []) or []:
|
||||
if isinstance(blk, dict) and blk.get('type') == 'text':
|
||||
t = (blk.get('text') or '').strip()
|
||||
if t and '<history>' not in t and not t.startswith('### [WORKING MEMORY]'):
|
||||
return t
|
||||
for p, _ in pairs[:1]:
|
||||
for line in p.splitlines():
|
||||
s = line.strip()
|
||||
if s and not s.startswith('###'): return s
|
||||
return ''
|
||||
|
||||
|
||||
def _last_summary(pairs):
|
||||
for _, response_body in reversed(pairs):
|
||||
try:
|
||||
blocks = ast.literal_eval(response_body)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(blocks, list):
|
||||
continue
|
||||
text_parts = []
|
||||
for block in blocks:
|
||||
if isinstance(block, dict) and block.get('type') == 'text':
|
||||
text = block.get('text', '')
|
||||
if isinstance(text, str) and text:
|
||||
text_parts.append(text)
|
||||
match = _SUMMARY_RE.search('\n'.join(text_parts))
|
||||
if match:
|
||||
summary = match.group(1).strip()
|
||||
if summary:
|
||||
return summary
|
||||
return ''
|
||||
|
||||
|
||||
def _preview_text(pairs):
|
||||
return _last_summary(pairs) or _first_user(pairs)
|
||||
|
||||
def _recent_context(my_pid, n=5):
|
||||
"""扫描最近 n 个 model_response 文件(排除自身),提取 lastQ / lastA。"""
|
||||
out = []
|
||||
for f in sorted(glob.glob(_LOG_GLOB), key=os.path.getmtime, reverse=True):
|
||||
m = re.search(r'model_responses_(\d+)', os.path.basename(f))
|
||||
if not m or m.group(1) == str(my_pid): continue
|
||||
try: c = open(f, encoding='utf-8', errors='ignore').read()
|
||||
except Exception: continue
|
||||
q = s = ""
|
||||
for hm in re.finditer(r'<history>(.*?)</history>', c, re.DOTALL):
|
||||
u = re.search(r'\[USER\]:\s*(.+?)(?:\\n|<)', hm.group(1))
|
||||
if u: q = u.group(1)
|
||||
sm = _SUMMARY_RE.search(c)
|
||||
if sm: s = sm.group(1).strip()
|
||||
q, s = q[:60].strip(), s[:60].replace('\n', ' ').strip()
|
||||
out.append(f'· {m.group(1)} | lastQ: {q or "-"} | lastA: {s or "-"}')
|
||||
if len(out) >= n: break
|
||||
return ('[RecentContext] 近期并行会话(非当前):\n' + '\n'.join(out) + '\n[/RecentContext]') if out else ""
|
||||
|
||||
def _parse_native_history(pairs):
|
||||
history = []
|
||||
for p, r in pairs:
|
||||
try: user_msg = json.loads(p)
|
||||
except Exception: return None
|
||||
try: blocks = ast.literal_eval(r)
|
||||
except Exception: return None
|
||||
if not (isinstance(user_msg, dict) and user_msg.get('role') == 'user'): return None
|
||||
if not isinstance(blocks, list): return None
|
||||
history.append(user_msg)
|
||||
history.append({'role': 'assistant', 'content': blocks})
|
||||
return history
|
||||
|
||||
def list_sessions(exclude_pid=None):
|
||||
"""Newest-first list of (path, mtime, first_user_text, n_rounds)."""
|
||||
files = glob.glob(_LOG_GLOB)
|
||||
if exclude_pid is not None:
|
||||
tag = f'model_responses_{exclude_pid}.txt'
|
||||
files = [f for f in files if not f.endswith(tag)]
|
||||
out = []
|
||||
for f in files:
|
||||
try:
|
||||
with open(f, encoding='utf-8', errors='replace') as fh:
|
||||
content = fh.read()
|
||||
except Exception: continue
|
||||
pairs = _pairs(content)
|
||||
if not pairs: continue
|
||||
out.append((f, os.path.getmtime(f), _preview_text(pairs), len(pairs)))
|
||||
out.sort(key=lambda x: x[1], reverse=True)
|
||||
return out
|
||||
_MD_ESCAPE_RE = re.compile(r'([\\`*_\[\]])')
|
||||
def _escape_md(s): return _MD_ESCAPE_RE.sub(r'\\\1', s)
|
||||
|
||||
|
||||
def _agent_clients(agent):
|
||||
clients = []
|
||||
for client in getattr(agent, 'llmclients', []) or []:
|
||||
if client not in clients:
|
||||
clients.append(client)
|
||||
current = getattr(agent, 'llmclient', None)
|
||||
if current is not None and current not in clients:
|
||||
clients.insert(0, current)
|
||||
return clients
|
||||
|
||||
|
||||
def _replace_backend_history(agent, history):
|
||||
backend = getattr(getattr(agent, 'llmclient', None), 'backend', None)
|
||||
if backend is not None and hasattr(backend, 'history'):
|
||||
backend.history = list(history or [])
|
||||
|
||||
|
||||
def _current_log_path(pid=None):
|
||||
pid = os.getpid() if pid is None else pid
|
||||
return os.path.join(_LOG_DIR, f'model_responses_{pid}.txt')
|
||||
|
||||
|
||||
def _snapshot_current_log(pid=None):
|
||||
"""Persist current PID log as a standalone recoverable snapshot, then clear it."""
|
||||
path = _current_log_path(pid)
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding='utf-8', errors='replace') as fh:
|
||||
content = fh.read()
|
||||
except Exception:
|
||||
return None
|
||||
if not _pairs(content):
|
||||
return None
|
||||
os.makedirs(_LOG_DIR, exist_ok=True)
|
||||
pid = os.getpid() if pid is None else pid
|
||||
stamp = time.strftime('%Y%m%d_%H%M%S')
|
||||
snapshot = os.path.join(_LOG_DIR, f'model_responses_snapshot_{pid}_{stamp}_{time.time_ns() % 1_000_000_000:09d}.txt')
|
||||
with open(snapshot, 'w', encoding='utf-8', errors='replace') as fh:
|
||||
fh.write(content)
|
||||
with open(path, 'w', encoding='utf-8', errors='replace'):
|
||||
pass
|
||||
return snapshot
|
||||
|
||||
|
||||
def reset_conversation(agent, message='🆕 已开启新对话,当前上下文已清空'):
|
||||
"""Abort current work and clear all known frontend-visible conversation state."""
|
||||
try:
|
||||
agent.abort()
|
||||
except Exception:
|
||||
pass
|
||||
_snapshot_current_log()
|
||||
if hasattr(agent, 'history'):
|
||||
agent.history = []
|
||||
for client in _agent_clients(agent):
|
||||
backend = getattr(client, 'backend', None)
|
||||
if backend is not None and hasattr(backend, 'history'):
|
||||
backend.history = []
|
||||
if hasattr(client, 'last_tools'):
|
||||
client.last_tools = ''
|
||||
if hasattr(agent, 'handler'):
|
||||
agent.handler = None
|
||||
return message
|
||||
|
||||
def format_list(sessions, limit=20):
|
||||
if not sessions: return '❌ 没有可恢复的历史会话'
|
||||
lines = ['**可恢复会话**(输入 `/continue N` 恢复第 N 个):', '']
|
||||
for i, (_, mtime, first, n) in enumerate(sessions[:limit], 1):
|
||||
preview = _escape_md((first or '(无法预览)').replace('\n', ' ')[:60])
|
||||
lines.append(f'{i}. `{_rel_time(mtime)}` · **{n} 轮** · {preview}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def restore(agent, path):
|
||||
"""Restore session at path. Returns (msg, is_full)."""
|
||||
try:
|
||||
with open(path, encoding='utf-8', errors='replace') as fh:
|
||||
content = fh.read()
|
||||
except Exception as e: return f'❌ 读取失败: {e}', False
|
||||
pairs = _pairs(content)
|
||||
if not pairs: return f'❌ {os.path.basename(path)} 为空或格式不符', False
|
||||
history = _parse_native_history(pairs)
|
||||
name = os.path.basename(path)
|
||||
if history is not None:
|
||||
agent.abort()
|
||||
_replace_backend_history(agent, history)
|
||||
return f'✅ 已恢复 {len(pairs)} 轮完整对话({name})\n(已写入 backend.history,可直接继续)', True
|
||||
from chatapp_common import _restore_native_history, _restore_text_pairs
|
||||
summary = _restore_text_pairs(content) or _restore_native_history(content)
|
||||
if not summary: return f'❌ {name} 无法解析(非 native 且无摘要可提取)', False
|
||||
agent.abort()
|
||||
agent.history.extend(summary)
|
||||
n = sum(1 for l in summary if l.startswith('[USER]: '))
|
||||
return f'⚠️ 非 native 格式,已降级恢复 {n} 轮摘要({name})\n(请输入新问题继续)', False
|
||||
|
||||
def handle(agent, query, display_queue):
|
||||
"""Dispatch /continue or /continue N. Returns None if consumed else original query."""
|
||||
s = (query or '').strip()
|
||||
if s == '/continue':
|
||||
display_queue.put({'done': format_list(list_sessions(exclude_pid=os.getpid())), 'source': 'system'})
|
||||
return None
|
||||
m = re.match(r'/continue\s+(\d+)\s*$', s)
|
||||
if m:
|
||||
sessions = list_sessions(exclude_pid=os.getpid())
|
||||
idx = int(m.group(1)) - 1
|
||||
if not (0 <= idx < len(sessions)):
|
||||
display_queue.put({'done': f'❌ 索引越界(有效范围 1-{len(sessions)})', 'source': 'system'})
|
||||
return None
|
||||
reset_conversation(agent, message=None)
|
||||
msg, _ = restore(agent, sessions[idx][0])
|
||||
display_queue.put({'done': msg, 'source': 'system'})
|
||||
return None
|
||||
return query
|
||||
|
||||
|
||||
def _user_text(prompt_body):
|
||||
"""User-typed text from a prompt JSON; '' if this is an agent auto-continuation."""
|
||||
try: msg = json.loads(prompt_body)
|
||||
except Exception: return ''
|
||||
if not isinstance(msg, dict): return ''
|
||||
for blk in msg.get('content', []) or []:
|
||||
if isinstance(blk, dict) and blk.get('type') == 'text':
|
||||
t = (blk.get('text') or '').strip()
|
||||
if t and not t.startswith('### [WORKING MEMORY]'): return t
|
||||
return ''
|
||||
|
||||
|
||||
def _assistant_text(response_body):
|
||||
"""Joined text from a response blocks repr; '' on parse failure."""
|
||||
try: blocks = ast.literal_eval(response_body)
|
||||
except Exception: return ''
|
||||
if not isinstance(blocks, list): return ''
|
||||
return '\n'.join(b['text'] for b in blocks
|
||||
if isinstance(b, dict) and b.get('type') == 'text'
|
||||
and isinstance(b.get('text'), str) and b['text'].strip())
|
||||
|
||||
|
||||
_TURN_MARK = '**LLM Running (Turn {}) ...**\n\n'
|
||||
|
||||
|
||||
def extract_ui_messages(path):
|
||||
"""Parse a model_responses log into [{role, content}, ...] for UI replay.
|
||||
|
||||
Auto-continuation turns are folded into one assistant bubble with Turn markers,
|
||||
matching live chat rendering via fold_turns().
|
||||
"""
|
||||
try:
|
||||
with open(path, encoding='utf-8', errors='replace') as f: content = f.read()
|
||||
except Exception: return []
|
||||
|
||||
rounds = [] # [(user_text, [turn_text, ...]), ...]
|
||||
for prompt, response in _pairs(content):
|
||||
user = _user_text(prompt)
|
||||
if user or not rounds: rounds.append((user, []))
|
||||
rounds[-1][1].append(_assistant_text(response))
|
||||
|
||||
out = []
|
||||
for user, turns in rounds:
|
||||
if not user or not any(turns): continue
|
||||
body = '\n\n'.join(t if i == 0 else _TURN_MARK.format(i + 1) + t
|
||||
for i, t in enumerate(turns))
|
||||
out += [{'role': 'user', 'content': user},
|
||||
{'role': 'assistant', 'content': body}]
|
||||
return out
|
||||
|
||||
|
||||
def handle_frontend_command(agent, query, exclude_pid=None):
|
||||
"""Frontend-friendly /continue entry that returns text directly."""
|
||||
s = (query or '').strip()
|
||||
exclude_pid = os.getpid() if exclude_pid is None else exclude_pid
|
||||
if s == '/continue':
|
||||
return format_list(list_sessions(exclude_pid=exclude_pid))
|
||||
m = re.match(r'/continue\s+(\d+)\s*$', s)
|
||||
if not m:
|
||||
return '用法: /continue 或 /continue N'
|
||||
sessions = list_sessions(exclude_pid=exclude_pid)
|
||||
idx = int(m.group(1)) - 1
|
||||
if not (0 <= idx < len(sessions)):
|
||||
return f'❌ 索引越界(有效范围 1-{len(sessions)})'
|
||||
reset_conversation(agent, message=None)
|
||||
msg, _ = restore(agent, sessions[idx][0])
|
||||
return msg
|
||||
|
||||
|
||||
def install(cls):
|
||||
"""Wrap cls._handle_slash_cmd so /continue is handled before original dispatch."""
|
||||
orig = cls._handle_slash_cmd
|
||||
if getattr(orig, '_continue_patched', False): return
|
||||
def patched(self, raw_query, display_queue):
|
||||
if (raw_query or '').startswith('/continue'):
|
||||
r = handle(self, raw_query, display_queue)
|
||||
if r is None: return None
|
||||
return orig(self, raw_query, display_queue)
|
||||
patched._continue_patched = True
|
||||
cls._handle_slash_cmd = patched
|
||||
@@ -0,0 +1,407 @@
|
||||
# Discord Bot Frontend for GenericAgent
|
||||
# ⚠️ 需要在 Discord Developer Portal 开启 "Message Content Intent"
|
||||
# Bot → Privileged Gateway Intents → MESSAGE CONTENT INTENT → 打开
|
||||
# pip install discord.py
|
||||
|
||||
import asyncio, json, os, queue as Q, re, sys, threading, time
|
||||
from collections import OrderedDict
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from agentmain import GeneraticAgent
|
||||
from chatapp_common import (
|
||||
AgentChatMixin, build_done_text, ensure_single_instance, extract_files,
|
||||
public_access, redirect_log, require_runtime, split_text, strip_files, clean_reply,
|
||||
HELP_TEXT, FILE_HINT, format_restore,
|
||||
_handle_continue_frontend, _reset_conversation,
|
||||
)
|
||||
from llmcore import mykeys
|
||||
|
||||
try:
|
||||
import discord
|
||||
except Exception:
|
||||
print("Please install discord.py to use Discord: pip install discord.py")
|
||||
sys.exit(1)
|
||||
|
||||
agent = GeneraticAgent(); agent.verbose = False
|
||||
BOT_TOKEN = str(mykeys.get("discord_bot_token", "") or "").strip()
|
||||
ALLOWED = {str(x).strip() for x in mykeys.get("discord_allowed_users", []) if str(x).strip()}
|
||||
USER_TASKS = {}
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TEMP_DIR = os.path.join(PROJECT_ROOT, "temp")
|
||||
MEDIA_DIR = os.path.join(TEMP_DIR, "discord_media")
|
||||
ACTIVE_FILE = os.path.join(TEMP_DIR, "discord_active_channels.json")
|
||||
ACTIVE_TTL_SECONDS = 30 * 24 * 3600
|
||||
EXIT_CHANNEL_TEXTS = {"退出该频道", "退出此频道", "退出频道"}
|
||||
EXIT_THREAD_TEXTS = {"退出该子区", "退出此子区", "退出子区"}
|
||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def _extract_discord_progress(text):
|
||||
"""Return the newest concise <summary> from a streaming transcript."""
|
||||
matches = re.findall(r"<summary>\s*(.*?)\s*</summary>", text or "", flags=re.DOTALL)
|
||||
if not matches:
|
||||
return ""
|
||||
summary = re.sub(r"\s+", " ", matches[-1]).strip()
|
||||
return summary[:120]
|
||||
|
||||
|
||||
def _strip_discord_transcript(text):
|
||||
"""Hide LLM/tool transcript noise while preserving the final natural reply."""
|
||||
text = text or ""
|
||||
text = re.sub(r"^\s*\*?\*?LLM Running \(Turn \d+\) \.\.\.\*?\*?\s*$", "", text, flags=re.M)
|
||||
text = re.sub(r"^\s*🛠️\s+.*?(?=^\s*(?:\*?\*?LLM Running|<summary>|$))", "", text, flags=re.M | re.DOTALL)
|
||||
text = re.sub(r"^\s*(?:✅|❌|ERR|STDOUT|PAT\b|RC\b).*?$", "", text, flags=re.M)
|
||||
text = re.sub(r"<tool_use>.*?</tool_use>", "", text, flags=re.DOTALL)
|
||||
text = clean_reply(text)
|
||||
return strip_files(text).strip()
|
||||
|
||||
|
||||
def _display_done_text(text):
|
||||
body = _strip_discord_transcript(text)
|
||||
if body and body != "...":
|
||||
return body
|
||||
summaries = re.findall(r"<summary>\s*(.*?)\s*</summary>", text or "", flags=re.DOTALL)
|
||||
if summaries:
|
||||
return re.sub(r"\s+", " ", summaries[-1]).strip() or "..."
|
||||
return "..."
|
||||
|
||||
|
||||
class DiscordApp(AgentChatMixin):
|
||||
label, source, split_limit = "Discord", "discord", 1900
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(agent, USER_TASKS)
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
intents.guilds = True
|
||||
intents.dm_messages = True
|
||||
proxy = str(mykeys.get("proxy", "") or "").strip() or None
|
||||
self.client = discord.Client(intents=intents, proxy=proxy)
|
||||
self.background_tasks = set()
|
||||
self._channel_cache = OrderedDict() # chat_id -> channel/user object (LRU, max 500)
|
||||
self._active_channels = self._load_active_channels() # guild chat_id -> {last_seen: float}
|
||||
self._active_lock = threading.Lock()
|
||||
self._agents = OrderedDict() # chat_id -> GeneraticAgent, each chat has isolated history
|
||||
self._agent_lock = threading.Lock()
|
||||
|
||||
@self.client.event
|
||||
async def on_ready():
|
||||
print(f"[Discord] bot ready: {self.client.user} ({self.client.user.id})")
|
||||
|
||||
@self.client.event
|
||||
async def on_message(message):
|
||||
await self._handle_message(message)
|
||||
|
||||
def _chat_id(self, message):
|
||||
"""Return a string chat_id: 'dm:<user_id>' or 'ch:<channel_id>'."""
|
||||
if isinstance(message.channel, discord.DMChannel):
|
||||
return f"dm:{message.author.id}"
|
||||
return f"ch:{message.channel.id}"
|
||||
|
||||
def _load_active_channels(self):
|
||||
try:
|
||||
with open(ACTIVE_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
now = time.time()
|
||||
active = {}
|
||||
for chat_id, item in data.items():
|
||||
if not str(chat_id).startswith("ch:") or not isinstance(item, dict):
|
||||
continue
|
||||
last_seen = float(item.get("last_seen") or 0)
|
||||
if now - last_seen <= ACTIVE_TTL_SECONDS:
|
||||
active[str(chat_id)] = {"last_seen": last_seen}
|
||||
return active
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except Exception as e:
|
||||
print(f"[Discord] failed to load active channels: {e}")
|
||||
return {}
|
||||
|
||||
def _save_active_channels(self):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(ACTIVE_FILE), exist_ok=True)
|
||||
tmp = ACTIVE_FILE + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(self._active_channels, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
os.replace(tmp, ACTIVE_FILE)
|
||||
except Exception as e:
|
||||
print(f"[Discord] failed to save active channels: {e}")
|
||||
|
||||
def _is_active_channel(self, chat_id, now=None):
|
||||
now = now or time.time()
|
||||
with self._active_lock:
|
||||
item = self._active_channels.get(chat_id)
|
||||
if not item:
|
||||
return False
|
||||
if now - float(item.get("last_seen") or 0) > ACTIVE_TTL_SECONDS:
|
||||
self._active_channels.pop(chat_id, None)
|
||||
self._save_active_channels()
|
||||
print(f"[Discord] channel expired: {chat_id}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _touch_active_channel(self, chat_id, now=None):
|
||||
if not chat_id.startswith("ch:"):
|
||||
return
|
||||
with self._active_lock:
|
||||
self._active_channels[chat_id] = {"last_seen": float(now or time.time())}
|
||||
self._save_active_channels()
|
||||
|
||||
def _deactivate_channel(self, chat_id):
|
||||
with self._active_lock:
|
||||
changed = self._active_channels.pop(chat_id, None) is not None
|
||||
self._save_active_channels()
|
||||
state = self.user_tasks.get(chat_id)
|
||||
if state:
|
||||
state["running"] = False
|
||||
try:
|
||||
self._get_agent(chat_id).abort()
|
||||
except Exception as e:
|
||||
print(f"[Discord] deactivate abort failed for {chat_id}: {e}")
|
||||
return changed
|
||||
|
||||
def _get_agent(self, chat_id):
|
||||
with self._agent_lock:
|
||||
ga = self._agents.get(chat_id)
|
||||
if ga is None:
|
||||
ga = GeneraticAgent()
|
||||
ga.verbose = False
|
||||
self._agents[chat_id] = ga
|
||||
threading.Thread(target=ga.run, daemon=True, name=f"discord-agent-{chat_id}").start()
|
||||
if len(self._agents) > 200:
|
||||
old_chat_id, _old_agent = self._agents.popitem(last=False)
|
||||
print(f"[Discord] dropped agent cache entry: {old_chat_id}")
|
||||
else:
|
||||
self._agents.move_to_end(chat_id)
|
||||
return ga
|
||||
|
||||
async def _download_attachments(self, message):
|
||||
"""Download attachments/images to MEDIA_DIR, return list of local paths."""
|
||||
paths = []
|
||||
for att in message.attachments:
|
||||
safe_name = re.sub(r'[<>:"/\\|?*]', '_', att.filename or f"file_{att.id}")
|
||||
local_path = os.path.join(MEDIA_DIR, f"{att.id}_{safe_name}")
|
||||
try:
|
||||
await att.save(local_path)
|
||||
paths.append(local_path)
|
||||
print(f"[Discord] saved attachment: {local_path}")
|
||||
except Exception as e:
|
||||
print(f"[Discord] failed to save attachment {att.filename}: {e}")
|
||||
return paths
|
||||
|
||||
async def send_text(self, chat_id, content, **ctx):
|
||||
"""Send text (and optionally files) to a chat_id."""
|
||||
channel = self._channel_cache.get(chat_id)
|
||||
if channel is None:
|
||||
try:
|
||||
if chat_id.startswith("dm:"):
|
||||
user = await self.client.fetch_user(int(chat_id[3:]))
|
||||
channel = await user.create_dm()
|
||||
else:
|
||||
channel = await self.client.fetch_channel(int(chat_id[3:]))
|
||||
self._channel_cache[chat_id] = channel
|
||||
if len(self._channel_cache) > 500:
|
||||
self._channel_cache.popitem(last=False)
|
||||
except Exception as e:
|
||||
print(f"[Discord] cannot resolve channel for {chat_id}: {e}")
|
||||
return
|
||||
for part in split_text(content, self.split_limit):
|
||||
try:
|
||||
await channel.send(part)
|
||||
except Exception as e:
|
||||
print(f"[Discord] send error: {e}")
|
||||
|
||||
async def send_done(self, chat_id, raw_text, **ctx):
|
||||
"""Send final reply: text parts + file attachments."""
|
||||
files = [p for p in extract_files(raw_text) if os.path.exists(p)]
|
||||
body = _display_done_text(raw_text)
|
||||
|
||||
# Send text (send_text handles splitting internally)
|
||||
if body and body != "...":
|
||||
await self.send_text(chat_id, body, **ctx)
|
||||
|
||||
# Send files as Discord attachments
|
||||
if files:
|
||||
channel = self._channel_cache.get(chat_id)
|
||||
if channel:
|
||||
for fpath in files:
|
||||
try:
|
||||
await channel.send(file=discord.File(fpath))
|
||||
except Exception as e:
|
||||
print(f"[Discord] failed to send file {fpath}: {e}")
|
||||
await self.send_text(chat_id, f"⚠️ 文件发送失败: {os.path.basename(fpath)}", **ctx)
|
||||
|
||||
if not body and not files:
|
||||
await self.send_text(chat_id, "...", **ctx)
|
||||
|
||||
async def handle_command(self, chat_id, cmd, **ctx):
|
||||
"""Handle slash commands against the per-chat agent, keeping Discord chats isolated."""
|
||||
ga = self._get_agent(chat_id)
|
||||
parts = (cmd or "").split()
|
||||
op = (parts[0] if parts else "").lower()
|
||||
if op == "/help":
|
||||
return await self.send_text(chat_id, HELP_TEXT, **ctx)
|
||||
if op == "/stop":
|
||||
state = self.user_tasks.get(chat_id)
|
||||
if state:
|
||||
state["running"] = False
|
||||
ga.abort()
|
||||
return await self.send_text(chat_id, "⏹️ 正在停止...", **ctx)
|
||||
if op == "/status":
|
||||
llm = ga.get_llm_name() if ga.llmclient else "未配置"
|
||||
return await self.send_text(chat_id, f"状态: {'🔴 运行中' if ga.is_running else '🟢 空闲'}\nLLM: [{ga.llm_no}] {llm}", **ctx)
|
||||
if op == "/llm":
|
||||
if not ga.llmclient:
|
||||
return await self.send_text(chat_id, "❌ 当前没有可用的 LLM 配置", **ctx)
|
||||
if len(parts) > 1:
|
||||
try:
|
||||
ga.next_llm(int(parts[1]))
|
||||
return await self.send_text(chat_id, f"✅ 已切换到 [{ga.llm_no}] {ga.get_llm_name()}", **ctx)
|
||||
except Exception:
|
||||
return await self.send_text(chat_id, f"用法: /llm <0-{len(ga.list_llms()) - 1}>", **ctx)
|
||||
lines = [f"{'→' if cur else ' '} [{i}] {name}" for i, name, cur in ga.list_llms()]
|
||||
return await self.send_text(chat_id, "LLMs:\n" + "\n".join(lines), **ctx)
|
||||
if op == "/restore":
|
||||
try:
|
||||
restored_info, err = format_restore()
|
||||
if err:
|
||||
return await self.send_text(chat_id, err, **ctx)
|
||||
restored, fname, count = restored_info
|
||||
ga.abort()
|
||||
ga.history.extend(restored)
|
||||
return await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)", **ctx)
|
||||
except Exception as e:
|
||||
return await self.send_text(chat_id, f"❌ 恢复失败: {e}", **ctx)
|
||||
if op == "/continue":
|
||||
return await self.send_text(chat_id, _handle_continue_frontend(ga, cmd), **ctx)
|
||||
if op == "/new":
|
||||
return await self.send_text(chat_id, _reset_conversation(ga), **ctx)
|
||||
return await self.send_text(chat_id, HELP_TEXT, **ctx)
|
||||
|
||||
async def run_agent(self, chat_id, text, **ctx):
|
||||
"""Run the isolated per-chat Discord agent."""
|
||||
ga = self._get_agent(chat_id)
|
||||
state = {"running": True}
|
||||
self.user_tasks[chat_id] = state
|
||||
try:
|
||||
await self.send_text(chat_id, "思考中...", **ctx)
|
||||
dq = ga.put_task(f"{FILE_HINT}\n\n{text}", source=self.source)
|
||||
last_ping = time.time()
|
||||
last_step = ""
|
||||
step_no = 0
|
||||
while state["running"]:
|
||||
try:
|
||||
item = await asyncio.to_thread(dq.get, True, 3)
|
||||
except Q.Empty:
|
||||
if ga.is_running and time.time() - last_ping > self.ping_interval:
|
||||
await self.send_text(chat_id, "⏳ 还在处理中,请稍等...", **ctx)
|
||||
last_ping = time.time()
|
||||
continue
|
||||
if "next" in item:
|
||||
step = _extract_discord_progress(item.get("next", ""))
|
||||
if step and step != last_step:
|
||||
step_no += 1
|
||||
await self.send_text(chat_id, f"步骤{step_no}:{step}", **ctx)
|
||||
last_step = step
|
||||
last_ping = time.time()
|
||||
continue
|
||||
if "done" in item:
|
||||
await self.send_done(chat_id, item.get("done", ""), **ctx)
|
||||
break
|
||||
if not state["running"]:
|
||||
await self.send_text(chat_id, "⏹️ 已停止", **ctx)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[{self.label}] run_agent error: {e}")
|
||||
traceback.print_exc()
|
||||
await self.send_text(chat_id, f"❌ 错误: {e}", **ctx)
|
||||
finally:
|
||||
self.user_tasks.pop(chat_id, None)
|
||||
|
||||
async def _handle_message(self, message):
|
||||
# Ignore self
|
||||
if message.author == self.client.user or message.author.bot:
|
||||
return
|
||||
|
||||
is_dm = isinstance(message.channel, discord.DMChannel)
|
||||
is_guild = message.guild is not None
|
||||
chat_id = self._chat_id(message)
|
||||
now = time.time()
|
||||
mentioned = bool(is_guild and self.client.user and self.client.user.mentioned_in(message))
|
||||
|
||||
self._channel_cache[chat_id] = message.channel
|
||||
if len(self._channel_cache) > 500:
|
||||
self._channel_cache.popitem(last=False)
|
||||
|
||||
user_id = str(message.author.id)
|
||||
user_name = str(message.author)
|
||||
|
||||
if not public_access(ALLOWED) and user_id not in ALLOWED:
|
||||
print(f"[Discord] unauthorized user: {user_name} ({user_id})")
|
||||
return
|
||||
|
||||
if is_guild:
|
||||
active = self._is_active_channel(chat_id, now)
|
||||
if not mentioned and not active:
|
||||
return
|
||||
if mentioned or active:
|
||||
self._touch_active_channel(chat_id, now)
|
||||
|
||||
# Strip bot mention from content
|
||||
content = message.content or ""
|
||||
if is_guild and self.client.user:
|
||||
content = re.sub(rf"<@!?{self.client.user.id}>", "", content).strip()
|
||||
else:
|
||||
content = content.strip()
|
||||
|
||||
normalized = re.sub(r"\s+", "", content)
|
||||
if is_guild and normalized in EXIT_CHANNEL_TEXTS | EXIT_THREAD_TEXTS:
|
||||
self._deactivate_channel(chat_id)
|
||||
label = "子区" if normalized in EXIT_THREAD_TEXTS else "频道"
|
||||
await self.send_text(chat_id, f"✅ 已退出该{label},之后除非重新 @ 我,否则不会主动响应。")
|
||||
print(f"[Discord] manually deactivated {chat_id} by {user_name} ({user_id})")
|
||||
return
|
||||
|
||||
# Download attachments
|
||||
attachment_paths = await self._download_attachments(message)
|
||||
|
||||
# Build message text with attachment paths
|
||||
if attachment_paths:
|
||||
paths_text = "\n".join(f"[附件: {p}]" for p in attachment_paths)
|
||||
content = f"{content}\n{paths_text}" if content else paths_text
|
||||
|
||||
if not content:
|
||||
return
|
||||
|
||||
print(f"[Discord] message from {user_name} ({user_id}, {'dm' if is_dm else 'guild'}): {content[:200]}")
|
||||
|
||||
if content.startswith("/"):
|
||||
return await self.handle_command(chat_id, content)
|
||||
|
||||
task = asyncio.create_task(self.run_agent(chat_id, content))
|
||||
self.background_tasks.add(task)
|
||||
task.add_done_callback(self.background_tasks.discard)
|
||||
|
||||
async def start(self):
|
||||
print("[Discord] bot starting...")
|
||||
delay, max_delay = 5, 300
|
||||
while True:
|
||||
started_at = time.monotonic()
|
||||
try:
|
||||
await self.client.start(BOT_TOKEN)
|
||||
except Exception as e:
|
||||
print(f"[Discord] error: {e}")
|
||||
if time.monotonic() - started_at >= 60:
|
||||
delay = 5
|
||||
print(f"[Discord] reconnect in {delay}s...")
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, max_delay)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_LOCK_SOCK = ensure_single_instance(19532, "Discord")
|
||||
require_runtime(agent, "Discord", discord_bot_token=BOT_TOKEN)
|
||||
redirect_log(__file__, "dcapp.log", "Discord", ALLOWED)
|
||||
asyncio.run(DiscordApp().start())
|
||||
@@ -0,0 +1,7 @@
|
||||
# Rust build artifacts
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "genericagent-web2",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "tauri-hello"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[lib]
|
||||
name = "tauri_hello_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
|
After Width: | Height: | Size: 48 KiB |
@@ -0,0 +1,86 @@
|
||||
use std::process::{Command, Child};
|
||||
use std::sync::Mutex;
|
||||
use std::net::TcpStream;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::thread;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
static BRIDGE_PROCESS: Mutex<Option<Child>> = Mutex::new(None);
|
||||
|
||||
fn find_bridge_script() -> PathBuf {
|
||||
// exe is at frontends/GenericAgent.exe
|
||||
// bridge is at frontends/desktop_bridge.py
|
||||
let exe_dir = std::env::current_exe()
|
||||
.expect("cannot get exe path")
|
||||
.parent()
|
||||
.expect("cannot get exe dir")
|
||||
.to_path_buf();
|
||||
exe_dir.join("desktop_bridge.py")
|
||||
}
|
||||
|
||||
fn wait_for_port(port: u16, timeout: Duration) -> bool {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < timeout {
|
||||
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
|
||||
return true;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn start_bridge() {
|
||||
let script = find_bridge_script();
|
||||
if !script.exists() {
|
||||
eprintln!("[tauri] bridge script not found: {:?}", script);
|
||||
return;
|
||||
}
|
||||
|
||||
let show_console = std::env::args().any(|a| a == "--console");
|
||||
|
||||
let mut cmd = Command::new("python");
|
||||
cmd.arg(&script)
|
||||
.current_dir(script.parent().unwrap());
|
||||
|
||||
#[cfg(windows)]
|
||||
if !show_console {
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let child = cmd.spawn().expect("failed to start desktop_bridge.py");
|
||||
|
||||
eprintln!("[tauri] started bridge PID={}", child.id());
|
||||
*BRIDGE_PROCESS.lock().unwrap() = Some(child);
|
||||
|
||||
if !wait_for_port(14168, Duration::from_secs(15)) {
|
||||
eprintln!("[tauri] WARNING: bridge did not become ready within 15s");
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_bridge() {
|
||||
if let Some(mut child) = BRIDGE_PROCESS.lock().unwrap().take() {
|
||||
eprintln!("[tauri] stopping bridge PID={}", child.id());
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
start_bridge();
|
||||
|
||||
tauri::Builder::default()
|
||||
.on_window_event(|_window, event| {
|
||||
if let tauri::WindowEvent::Destroyed = event {
|
||||
stop_bridge();
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
stop_bridge();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
tauri_hello_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"productName": "GenericAgent",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.genericagent.app",
|
||||
"build": {
|
||||
"frontendDist": "../static"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"title": "GenericAgent",
|
||||
"label": "main",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"url": "http://127.0.0.1:14168/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "nsis",
|
||||
"icon": ["icons/icon.ico"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// GenericAgent Web2 browser bridge adapter.
|
||||
// HTTP is the command/data channel. WebSocket only carries small state events.
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const listeners = new Map();
|
||||
let ws = null;
|
||||
let cachedBridgeReady = null;
|
||||
const bridgeBase = `${location.protocol}//${location.hostname}:14168`;
|
||||
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.hostname}:14168/ws`;
|
||||
|
||||
function on(channel, cb) {
|
||||
if (typeof cb !== 'function') return () => {};
|
||||
if (!listeners.has(channel)) listeners.set(channel, new Set());
|
||||
listeners.get(channel).add(cb);
|
||||
if (channel === 'bridge-ready' && cachedBridgeReady) {
|
||||
try { cb(cachedBridgeReady); } catch (err) { console.error('[ga-web2 listener] replay bridge-ready', err); }
|
||||
}
|
||||
return () => listeners.get(channel)?.delete(cb);
|
||||
}
|
||||
|
||||
function emit(channel, payload) {
|
||||
if (channel === 'bridge-ready') cachedBridgeReady = payload;
|
||||
const set = listeners.get(channel);
|
||||
if (!set) return;
|
||||
for (const cb of Array.from(set)) {
|
||||
try { cb(payload); } catch (err) { console.error('[ga-web2 listener]', channel, err); }
|
||||
}
|
||||
}
|
||||
|
||||
async function http(path, options = {}) {
|
||||
const headers = Object.assign({}, options.headers || {});
|
||||
const init = Object.assign({}, options, { headers });
|
||||
if (init.body && typeof init.body !== 'string') {
|
||||
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
||||
init.body = JSON.stringify(init.body);
|
||||
}
|
||||
const res = await fetch(`${bridgeBase}${path}`, init);
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; }
|
||||
if (!res.ok) {
|
||||
const err = new Error((data && (data.error || data.message)) || `${res.status} ${res.statusText}`);
|
||||
err.status = res.status;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function connectWs() {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.addEventListener('open', () => emit('bridge-log', 'WS state channel connected'));
|
||||
ws.addEventListener('message', (ev) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(ev.data); } catch (_) { return; }
|
||||
if (msg.type === 'bridge-ready') {
|
||||
emit('bridge-ready', msg);
|
||||
} else if (msg.type === 'session-state') {
|
||||
emit('bridge-notification', msg);
|
||||
} else if (msg.type === 'bridge-log') {
|
||||
emit('bridge-log', msg.payload || msg);
|
||||
} else if (msg.type === 'bridge-error') {
|
||||
emit('bridge-error', msg.payload || msg);
|
||||
}
|
||||
});
|
||||
ws.addEventListener('close', () => emit('bridge-closed', { reason: 'ws-closed' }));
|
||||
ws.addEventListener('error', () => emit('bridge-error', { type: 'ws-error', message: 'WebSocket state channel error' }));
|
||||
} catch (err) {
|
||||
emit('bridge-error', { type: 'ws-error', message: err.message || String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
async function rpc(method, params = {}) {
|
||||
switch (method) {
|
||||
case 'app/status':
|
||||
return http('/status');
|
||||
case 'app/config/get':
|
||||
return http('/config');
|
||||
case 'app/config/save':
|
||||
return http('/config', { method: 'POST', body: params || {} });
|
||||
case 'get/model-profiles':
|
||||
return http('/model-profiles');
|
||||
case 'session/new':
|
||||
return http('/session/new', { method: 'POST', body: params || {} });
|
||||
case 'session/prompt': {
|
||||
const sid = params.sessionId || params.id || params.bridgeSessionId;
|
||||
if (!sid) throw new Error('session/prompt missing sessionId');
|
||||
return http(`/session/${encodeURIComponent(sid)}/prompt`, { method: 'POST', body: params || {} });
|
||||
}
|
||||
case 'session/poll': {
|
||||
const sid = params.sessionId || params.id || params.bridgeSessionId;
|
||||
if (!sid) throw new Error('session/poll missing sessionId');
|
||||
const after = params.afterId ?? params.after ?? 0;
|
||||
const limit = params.limit ?? 200;
|
||||
return http(`/session/${encodeURIComponent(sid)}/messages?after=${encodeURIComponent(after)}&limit=${encodeURIComponent(limit)}`);
|
||||
}
|
||||
case 'session/cancel': {
|
||||
const sid = params.sessionId || params.id || params.bridgeSessionId;
|
||||
if (!sid) throw new Error('session/cancel missing sessionId');
|
||||
return http(`/session/${encodeURIComponent(sid)}/cancel`, { method: 'POST', body: params || {} });
|
||||
}
|
||||
case 'app/path/open':
|
||||
return http('/path/open', { method: 'POST', body: params || {} });
|
||||
case 'app/path/selectGaRoot':
|
||||
return http('/config');
|
||||
case 'list_continuable_sessions':
|
||||
return { sessions: [] };
|
||||
case 'restore_session':
|
||||
throw new Error('restore_session is not implemented in web2 bridge');
|
||||
default:
|
||||
throw new Error(`Unknown RPC method: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
window.ga = {
|
||||
platform: navigator.platform.toLowerCase().includes('mac') ? 'darwin' : 'win32',
|
||||
startBridge: async () => { connectWs(); return http('/status'); },
|
||||
stopBridge: async () => ({ ok: true }),
|
||||
checkStatus: () => rpc('app/status', {}),
|
||||
getConfig: () => rpc('app/config/get', {}),
|
||||
saveConfig: (cfg) => rpc('app/config/save', cfg || {}),
|
||||
getModelProfiles: () => rpc('get/model-profiles', {}),
|
||||
selectGaRoot: () => rpc('app/path/selectGaRoot', {}),
|
||||
openMykeyTemplate: () => rpc('app/path/open', { kind: 'mykeyTemplate' }),
|
||||
openMykey: () => rpc('app/path/open', { kind: 'mykey' }),
|
||||
pollSession: (sessionId, afterId = 0) => rpc('session/poll', { sessionId, afterId }),
|
||||
rpc,
|
||||
onBridgeMessage: (cb) => on('bridge-message', cb),
|
||||
onBridgeNotification: (cb) => on('bridge-notification', cb),
|
||||
onBridgeError: (cb) => on('bridge-error', cb),
|
||||
onBridgeClosed: (cb) => on('bridge-closed', cb),
|
||||
onBridgeReady: (cb) => on('bridge-ready', cb),
|
||||
onBridgeLog: (cb) => on('bridge-log', cb),
|
||||
onOpenSearch: (cb) => on('open-search', cb)
|
||||
};
|
||||
|
||||
connectWs();
|
||||
http('/status').then(status => emit('bridge-ready', status)).catch(err => emit('bridge-error', { type: 'http-error', message: err.message || String(err) }));
|
||||
})();
|
||||
@@ -0,0 +1,116 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>GenericAgent</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Main area -->
|
||||
<main id="main">
|
||||
<header id="topbar">
|
||||
<div class="topbar-left">
|
||||
<div id="session-list" class="tab-strip" role="tablist" aria-label="Chats">
|
||||
<button id="new-session-btn" class="tab-new-btn" title="New chat" aria-label="New chat">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<h1 id="session-title" class="sr-only">GenericAgent</h1>
|
||||
<span id="status-badge" class="badge">
|
||||
<span class="status-dot"></span>
|
||||
<span id="status-text">Starting…</span>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="search-bar" class="search-bar hidden">
|
||||
<input id="search-input" type="text" placeholder="Search messages…" aria-label="Search messages">
|
||||
<span id="search-count" class="search-count"></span>
|
||||
<button id="search-prev" class="search-nav-btn" title="Previous (Shift+Enter)" aria-label="Previous match">▲</button>
|
||||
<button id="search-next" class="search-nav-btn" title="Next (Enter)" aria-label="Next match">▼</button>
|
||||
<button id="search-close" class="search-nav-btn" title="Close (Escape)" aria-label="Close search">✕</button>
|
||||
</div>
|
||||
|
||||
<section id="messages" class="empty">
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">New task</div>
|
||||
<div class="empty-sub">Task me anything. Type <code>/help</code> for commands.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="composer-wrap">
|
||||
<div id="composer">
|
||||
<div id="image-previews" class="image-previews"></div>
|
||||
<textarea id="input" rows="1" placeholder="Type a message, or /help"></textarea>
|
||||
<div class="composer-bottom">
|
||||
<div class="composer-hints">
|
||||
<button id="settings-btn" class="icon-btn ghost" title="Settings" aria-label="Settings">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<span class="hint-label">Settings</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<button id="send-btn" class="send-btn" title="Send (Enter)">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M5 12h14M13 6l6 6-6 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Settings modal -->
|
||||
<div id="settings-modal" class="modal hidden">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-header">
|
||||
<h2>Settings</h2>
|
||||
<button class="icon-btn ghost" id="close-settings">✕</button>
|
||||
</div>
|
||||
<div class="modal-content">
|
||||
<div class="form-row">
|
||||
<label for="cfg-llm">Model</label>
|
||||
<select id="cfg-llm">
|
||||
<option value="0">Default / Auto</option>
|
||||
</select>
|
||||
<small>Select the LLM profile from mykey.py.</small>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>API keys</label>
|
||||
<div class="btn-row">
|
||||
<button class="btn" id="open-mykey">Open mykey.py</button>
|
||||
</div>
|
||||
<small>Configure API keys in <code>mykey.py</code> at the GA project root.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn ghost" id="cancel-settings">Cancel</button>
|
||||
<button class="btn primary" id="save-settings">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Error banner -->
|
||||
<div id="error-banner" class="error-banner hidden">
|
||||
<span id="error-text"></span>
|
||||
<button class="icon-btn ghost" id="error-action"></button>
|
||||
<button class="icon-btn ghost" id="error-dismiss">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="vendor/marked.min.js"></script>
|
||||
<script src="ga-web.js?v=14168-2"></script>
|
||||
<script src="app.js?v=poll-fold-2"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,897 @@
|
||||
/* GenericAgent Desktop — Minimal Codex-inspired styling */
|
||||
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg-subtle: #fafafa;
|
||||
--bg-hover: #f3f3f3;
|
||||
--bg-active: #ededed;
|
||||
--border: #e8e8e8;
|
||||
--border-strong: #d4d4d4;
|
||||
--text: #0a0a0a;
|
||||
--text-muted: #6b6b6b;
|
||||
--text-faint: #9a9a9a;
|
||||
--accent: #0a0a0a;
|
||||
--accent-text: #ffffff;
|
||||
--user-bg: #f5f5f5;
|
||||
--code-bg: #f5f5f5;
|
||||
--code-text: #1a1a1a;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-strong: 0 4px 24px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
--font-display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
--font-mono: "SF Mono", "SFMono-Regular", ui-monospace, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
--ok: #16a34a;
|
||||
--warn: #f59e0b;
|
||||
--err: #dc2626;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg: #1a1a1a;
|
||||
--bg-subtle: #1f1f1f;
|
||||
--bg-hover: #262626;
|
||||
--bg-active: #2d2d2d;
|
||||
--border: #2a2a2a;
|
||||
--border-strong: #3a3a3a;
|
||||
--text: #ededed;
|
||||
--text-muted: #a0a0a0;
|
||||
--text-faint: #6b6b6b;
|
||||
--accent: #ededed;
|
||||
--accent-text: #0a0a0a;
|
||||
--user-bg: #262626;
|
||||
--code-bg: #0f0f0f;
|
||||
--code-text: #e0e0e0;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
--shadow-strong: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
[data-theme="auto"] {
|
||||
--bg: #1a1a1a;
|
||||
--bg-subtle: #1f1f1f;
|
||||
--bg-hover: #262626;
|
||||
--bg-active: #2d2d2d;
|
||||
--border: #2a2a2a;
|
||||
--border-strong: #3a3a3a;
|
||||
--text: #ededed;
|
||||
--text-muted: #a0a0a0;
|
||||
--text-faint: #6b6b6b;
|
||||
--accent: #ededed;
|
||||
--accent-text: #0a0a0a;
|
||||
--user-bg: #262626;
|
||||
--code-bg: #0f0f0f;
|
||||
--code-text: #e0e0e0;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
--shadow-strong: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0; padding: 0; height: 100%;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
/* ─── Top tabs ───────────────────────────────────────────────────────── */
|
||||
button, input, textarea, select, .icon-btn, .btn, .send-btn, .session-tab, .tab-new-btn {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.tab-strip {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
padding: 8px 0 0 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
/* macOS: leave space for traffic-light buttons */
|
||||
body.platform-darwin .tab-strip {
|
||||
padding-left: 80px;
|
||||
}
|
||||
.tab-strip::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Chrome-like chat tabs */
|
||||
.session-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 0 1 180px;
|
||||
min-width: 60px;
|
||||
height: 32px;
|
||||
padding: 0 6px 0 10px;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 0;
|
||||
border-radius: 10px 10px 0 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
.session-tab .tab-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.session-tab .tab-dot {
|
||||
flex-shrink: 0;
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--ok);
|
||||
margin-right: 2px;
|
||||
}
|
||||
.session-tab .tab-dot.busy { background: var(--warn); animation: pulse 1.2s infinite; }
|
||||
.session-tab .tab-dot.err { background: var(--err); }
|
||||
.session-tab .tab-dot.warn { background: var(--warn); }
|
||||
.session-tab .tab-close {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--text-faint);
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s, background 0.1s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.session-tab:hover .tab-close { opacity: 1; }
|
||||
.session-tab .tab-close:hover {
|
||||
background: var(--bg-active);
|
||||
color: var(--text);
|
||||
}
|
||||
.session-tab:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.session-tab.active {
|
||||
background: var(--bg);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.session-tab.active .tab-close { opacity: 0.6; }
|
||||
.session-tab.active .tab-close:hover { opacity: 1; background: var(--bg-active); }
|
||||
|
||||
.tab-new-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 0 8px 2px 2px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.tab-new-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ─── Main ────────────────────────────────────────────────────────────── */
|
||||
#main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
#topbar {
|
||||
height: 44px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px 0 0;
|
||||
-webkit-app-region: drag;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.topbar-left, .topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
.topbar-right { display: none; }
|
||||
.topbar-left { flex: 1; min-width: 0; align-items: stretch; }
|
||||
.topbar-right { flex-shrink: 0; }
|
||||
|
||||
#session-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-faint);
|
||||
}
|
||||
.badge.ok .status-dot { background: var(--ok); }
|
||||
.badge.warn .status-dot { background: var(--warn); }
|
||||
.badge.err .status-dot { background: var(--err); }
|
||||
.badge.busy .status-dot { background: var(--warn); animation: pulse 1.2s infinite; }
|
||||
|
||||
@keyframes pulse { 50% { opacity: 0.3; } }
|
||||
|
||||
/* ─── Messages ─────────────────────────────────────────────────────────── */
|
||||
#messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 20px 8px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
#messages.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
max-width: 400px;
|
||||
}
|
||||
.empty-logo {
|
||||
font-size: 36px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.empty-sub {
|
||||
font-size: 13px;
|
||||
}
|
||||
.empty-sub code {
|
||||
background: var(--bg-subtle);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
max-width: 780px;
|
||||
margin: 0 auto 18px;
|
||||
font-size: 14.5px;
|
||||
line-height: 1.66;
|
||||
}
|
||||
|
||||
.msg-user {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.msg-user .bubble {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
max-width: 85%;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.msg-assistant {
|
||||
color: var(--text);
|
||||
}
|
||||
.assistant-response {
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.msg-system {
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.msg-error {
|
||||
color: var(--err);
|
||||
background: rgba(220, 38, 38, 0.06);
|
||||
border-left: 3px solid var(--err);
|
||||
padding: 8px 14px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Turn (collapsible segment of assistant reply) */
|
||||
.turn {
|
||||
margin: 6px 0 10px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
.msg-assistant .turn[data-kind="agent_message_chunk"] {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.turn-header {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.turn-header:hover { background: var(--bg-hover); }
|
||||
.turn-caret {
|
||||
display: inline-block;
|
||||
transition: transform 0.15s;
|
||||
font-size: 10px;
|
||||
}
|
||||
.turn.collapsed .turn-caret { transform: rotate(-90deg); }
|
||||
.turn.collapsed .turn-body { display: none; }
|
||||
|
||||
.turn-body {
|
||||
padding: 8px 0 0 18px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.turn-tag {
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-faint);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.turn-status {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
.tool-detail-turn {
|
||||
margin: 0 0 8px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--surface-raised) 82%, transparent);
|
||||
}
|
||||
.tool-detail-header {
|
||||
min-height: 28px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
.tool-detail-body {
|
||||
padding: 8px;
|
||||
}
|
||||
.tool-detail-body pre {
|
||||
margin: 0;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
.turn-rendered-md:empty { display: none; }
|
||||
|
||||
/* Markdown content */
|
||||
.md {
|
||||
line-height: 1.72;
|
||||
letter-spacing: -0.006em;
|
||||
}
|
||||
.md p { margin: 0 0 10px; }
|
||||
.md p:last-child { margin-bottom: 0; }
|
||||
.md h1, .md h2, .md h3 { margin: 16px 0 8px; font-family: var(--font-display); font-weight: 600; letter-spacing: -0.02em; }
|
||||
.md h1 { font-size: 18px; }
|
||||
.md h2 { font-size: 16px; }
|
||||
.md h3 { font-size: 14px; }
|
||||
.md ul, .md ol { margin: 0 0 10px; padding-left: 22px; }
|
||||
.md li { margin: 2px 0; }
|
||||
.md code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
background: var(--code-bg);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.md pre {
|
||||
background: var(--code-bg);
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.md pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: var(--code-text);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.md blockquote {
|
||||
border-left: 3px solid var(--border-strong);
|
||||
padding-left: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 8px 0;
|
||||
}
|
||||
.md a { color: var(--text); text-decoration: underline; }
|
||||
.md hr { border: 0; border-top: 1px solid var(--border); margin: 12px 0; }
|
||||
.md table { border-collapse: collapse; margin: 8px 0; }
|
||||
.md th, .md td { border: 1px solid var(--border); padding: 4px 8px; font-size: 12.5px; }
|
||||
|
||||
/* Streaming cursor */
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 1em;
|
||||
background: var(--text-muted);
|
||||
vertical-align: text-bottom;
|
||||
margin-left: 2px;
|
||||
animation: blink 1s steps(2) infinite;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
/* ─── Composer ─────────────────────────────────────────────────────────── */
|
||||
#composer-wrap {
|
||||
padding: 8px 20px 16px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
#composer {
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
#composer:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-strong);
|
||||
}
|
||||
|
||||
#input {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
resize: none;
|
||||
padding: 12px 14px 4px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
max-height: 200px;
|
||||
}
|
||||
#input::placeholder { color: var(--text-faint); }
|
||||
|
||||
/* Image previews in composer */
|
||||
.image-previews {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 0;
|
||||
}
|
||||
.image-previews:empty { display: none; }
|
||||
.image-preview-item {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.image-preview-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.image-preview-item .remove-img {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
border: 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.image-preview-item .remove-img:hover {
|
||||
background: rgba(0,0,0,0.85);
|
||||
}
|
||||
|
||||
/* User message image thumbnails */
|
||||
.user-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.user-msg-thumb {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.composer-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px 8px 14px;
|
||||
}
|
||||
|
||||
.composer-hints {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.composer-hints .icon-btn.ghost {
|
||||
font-size: 11px;
|
||||
padding: 3px 7px;
|
||||
gap: 4px;
|
||||
}
|
||||
.hint-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 30px; height: 30px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
.send-btn:hover { opacity: 0.85; }
|
||||
.send-btn:active { transform: scale(0.96); }
|
||||
.send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.send-btn.stop { background: var(--err); }
|
||||
|
||||
/* ─── Buttons ──────────────────────────────────────────────────────────── */
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-family: var(--font);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.icon-btn:hover { background: var(--bg-hover); }
|
||||
.icon-btn.ghost {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.icon-btn.ghost:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.icon-btn.full { width: 100%; justify-content: flex-start; }
|
||||
|
||||
.btn {
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.btn:hover { background: var(--bg-hover); }
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.btn.primary:hover { opacity: 0.88; }
|
||||
.btn.ghost { border: 0; background: transparent; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ─── Modal ────────────────────────────────────────────────────────────── */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.modal-body {
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-strong);
|
||||
width: 460px;
|
||||
max-width: 90vw;
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal-header {
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-header h2 { margin: 0; font-size: 15px; font-weight: 600; }
|
||||
.modal-content { padding: 16px 18px; overflow-y: auto; }
|
||||
.modal-footer {
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.form-row label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
.form-row input, .form-row select {
|
||||
padding: 7px 10px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
outline: 0;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.form-row input:focus, .form-row select:focus {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.form-row small {
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
.form-row small code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--bg-subtle);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.input-row input { min-width: 0; }
|
||||
.input-row .btn { flex: 0 0 auto; }
|
||||
.btn-row { display: flex; gap: 8px; }
|
||||
|
||||
|
||||
/* ─── Diagnostics panel ────────────────────────────────────────────────── */
|
||||
.diagnostics-panel {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
right: 16px;
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 88px);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-strong);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.diagnostics-header {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.diagnostics-header h2 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
.diagnostics-header p { margin: 3px 0 0; color: var(--text-faint); font-size: 11px; }
|
||||
.diagnostics-log {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
min-height: 180px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--code-bg);
|
||||
color: var(--code-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.diagnostics-footer {
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ─── Error banner ─────────────────────────────────────────────────────── */
|
||||
.error-banner {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
right: 16px;
|
||||
max-width: 420px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--err);
|
||||
border-left: 3px solid var(--err);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12.5px;
|
||||
box-shadow: var(--shadow-strong);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Scrollbars (WebKit) */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-faint); }
|
||||
|
||||
|
||||
/* Structured GA action cards */
|
||||
.structured-turn { margin: 8px 0; }
|
||||
.structured-turn .turn-header { width: fit-content; max-width: min(100%, 760px); background: rgba(248, 248, 250, 0.92); }
|
||||
.turn-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); font-size: 12px; }
|
||||
.structured-turn .turn-body { margin-top: 6px; padding: 10px 12px; border-left: 2px solid var(--border); color: var(--text); }
|
||||
|
||||
.task-elapsed { display: block; width: max-content; color: var(--accent); font-size: 12px; font-weight: 600; margin: 0 0 6px 0; }
|
||||
.tool-detail-turn { margin: 8px 0 0 16px; border-left: 2px solid var(--accent); padding-left: 8px; }
|
||||
.tool-detail-body .tool-args-code { margin: 6px 0 0; white-space: pre-wrap; }
|
||||
|
||||
.summary-hint { font-style: italic; color: var(--text-muted, #888); opacity: 0.7; margin-bottom: 4px; font-size: 0.9em; }
|
||||
.think-hint { font-style: italic; color: var(--text-muted, #888); opacity: 0.6; margin-bottom: 4px; font-size: 0.85em; }
|
||||
|
||||
|
||||
/* ─── Copy Button for code blocks / pre blocks ─── */
|
||||
pre { position: relative; }
|
||||
pre .copy-btn {
|
||||
position: absolute; top: 6px; right: 6px;
|
||||
background: rgba(128,128,128,0.15); border: 1px solid rgba(128,128,128,0.25);
|
||||
border-radius: 4px; padding: 2px 8px; font-size: 12px;
|
||||
color: var(--text-muted, #888); cursor: pointer;
|
||||
opacity: 0; transition: opacity 0.2s;
|
||||
z-index: 2; user-select: none;
|
||||
}
|
||||
pre:hover .copy-btn { opacity: 1; }
|
||||
pre .copy-btn:hover { background: rgba(128,128,128,0.3); color: var(--text, #333); }
|
||||
pre .copy-btn.copied { color: #4caf50; border-color: #4caf50; opacity: 1; }
|
||||
|
||||
/* ─── Search Bar ─────────────────────────────────────────────────────── */
|
||||
#search-bar {
|
||||
display: none; position: fixed; top: 40px; right: 16px;
|
||||
background: var(--bg, #fff); border: 1px solid var(--border, #ddd);
|
||||
border-radius: 8px; padding: 6px 10px; gap: 6px; align-items: center;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 9999;
|
||||
font-size: 13px;
|
||||
}
|
||||
#search-bar.visible { display: flex; }
|
||||
#search-input {
|
||||
border: 1px solid var(--border, #ccc); border-radius: 4px;
|
||||
padding: 4px 8px; font-size: 13px; width: 200px;
|
||||
background: var(--input-bg, #fff); color: var(--text, #333);
|
||||
outline: none;
|
||||
}
|
||||
#search-input:focus { border-color: var(--accent, #4a9eff); }
|
||||
#search-count { min-width: 40px; text-align: center; color: var(--text-muted, #888); font-size: 12px; }
|
||||
#search-bar button {
|
||||
background: none; border: none; cursor: pointer; font-size: 16px;
|
||||
color: var(--text, #333); padding: 2px 6px; border-radius: 4px;
|
||||
}
|
||||
#search-bar button:hover { background: rgba(128,128,128,0.15); }
|
||||
mark.search-highlight { background: #ffeb3b; color: #000; border-radius: 2px; padding: 0 1px; }
|
||||
mark.search-highlight.active { background: #ff9800; color: #fff; }
|
||||
|
||||
/* ─── Tab Drag ───────────────────────────────────────────────────────── */
|
||||
.tab-btn.drag-over-left { box-shadow: inset 3px 0 0 var(--accent, #4a9eff); }
|
||||
.tab-btn.drag-over-right { box-shadow: inset -3px 0 0 var(--accent, #4a9eff); }
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* marked v15.0.7 - a markdown parser
|
||||
* Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed)
|
||||
* https://github.com/markedjs/marked
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s={exec:()=>null};function r(e,t=""){let n="string"==typeof e?e:e.source;const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(i.caret,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}const i={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^<a /i,endATag:/^<\/a>/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^</,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[\t ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},l=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,o=/(?:[*+-]|\d{1,9}[.)])/,a=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,c=r(a).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),h=r(a).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),p=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,u=/(?!\s*\])(?:\\.|[^\[\]\\])+/,g=r(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",u).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),k=r(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,o).getRegex(),d="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",f=/<!--(?:-?>|[\s\S]*?(?:-->|$))/,x=r("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:</\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|<![A-Z][\\s\\S]*?(?:>\\n*|$)|<!\\[CDATA\\[[\\s\\S]*?(?:\\]\\]>\\n*|$)|</?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|</(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$))","i").replace("comment",f).replace("tag",d).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegexLine truncated
|
||||
@@ -0,0 +1,616 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GenericAgent Web2 Bridge.
|
||||
|
||||
Clear split:
|
||||
1) AgentManager: owns GenericAgent instances, sessions and histories.
|
||||
2) Transport: HTTP is the command/data channel; WebSocket only pushes small
|
||||
session-state notifications.
|
||||
|
||||
HTTP API:
|
||||
GET /status
|
||||
GET /config
|
||||
POST /config
|
||||
GET /model-profiles
|
||||
GET /sessions
|
||||
POST /session/new
|
||||
GET /session/{sid}
|
||||
DELETE /session/{sid}
|
||||
POST /session/{sid}/prompt
|
||||
GET /session/{sid}/messages?after=0&limit=200
|
||||
POST /session/{sid}/cancel
|
||||
|
||||
WS API:
|
||||
GET /ws -> events only, e.g.
|
||||
{"type":"session-state","sessionId":"sess-...","state":"running","seq":3,"updatedAt":...}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from aiohttp import web, WSMsgType
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def find_default_ga_root() -> Path:
|
||||
"""Find GenericAgent root near this bridge.
|
||||
|
||||
Probe, in order:
|
||||
../
|
||||
../../
|
||||
../GenericAgent
|
||||
../../GenericAgent
|
||||
and return the first directory containing agentmain.py.
|
||||
"""
|
||||
candidates = [
|
||||
APP_DIR / "..",
|
||||
APP_DIR / ".." / "..",
|
||||
APP_DIR / ".." / "GenericAgent",
|
||||
APP_DIR / ".." / ".." / "GenericAgent",
|
||||
]
|
||||
for p in candidates:
|
||||
root = p.resolve()
|
||||
if (root / "agentmain.py").exists():
|
||||
return root
|
||||
return APP_DIR.parent.parent.resolve()
|
||||
|
||||
|
||||
DEFAULT_GA_ROOT = find_default_ga_root()
|
||||
|
||||
for _s in (sys.stdout, sys.stderr):
|
||||
with contextlib.suppress(Exception):
|
||||
_s.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent management layer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
id: str
|
||||
title: str = "New chat"
|
||||
cwd: str = ""
|
||||
created_at: float = field(default_factory=time.time)
|
||||
updated_at: float = field(default_factory=time.time)
|
||||
messages: List[dict] = field(default_factory=list)
|
||||
msg_seq: int = 0
|
||||
partial: Optional[dict] = None
|
||||
status: str = "idle" # idle|running|error|cancelled
|
||||
agent: Any = None
|
||||
thread: Optional[threading.Thread] = None
|
||||
cancel_requested: bool = False
|
||||
last_error: str = ""
|
||||
|
||||
|
||||
class AgentManager:
|
||||
def __init__(self):
|
||||
self.lock = threading.RLock()
|
||||
self.ga_root = str(DEFAULT_GA_ROOT)
|
||||
self.config: Dict[str, Any] = {}
|
||||
self.sessions: Dict[str, Session] = {}
|
||||
self.active_session_id: Optional[str] = None
|
||||
|
||||
@property
|
||||
def mykey_path(self) -> str:
|
||||
return str(Path(self.ga_root) / "mykey.txt")
|
||||
|
||||
def ensure_ga_import_path(self) -> Path:
|
||||
root = Path(self.ga_root).resolve()
|
||||
if str(root) not in sys.path:
|
||||
sys.path.insert(0, str(root))
|
||||
return root
|
||||
|
||||
def make_agent(self, sess: Session):
|
||||
root = self.ensure_ga_import_path()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(sess.cwd or str(root))
|
||||
agentmain = importlib.import_module("agentmain")
|
||||
GA = getattr(agentmain, "GenericAgent")
|
||||
agent = GA()
|
||||
agent.inc_out = True
|
||||
agent.verbose = True
|
||||
threading.Thread(target=agent.run, daemon=True, name=f"GA-{sess.id}").start()
|
||||
return agent
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
os.chdir(old_cwd)
|
||||
|
||||
def list_model_profiles(self):
|
||||
self.ensure_ga_import_path()
|
||||
try:
|
||||
agentmain = importlib.import_module("agentmain")
|
||||
agent = agentmain.GenericAgent()
|
||||
if hasattr(agent, "list_llms"):
|
||||
return [{"id": i, "name": name, "active": active} for i, name, active in agent.list_llms()]
|
||||
except Exception as e:
|
||||
print(f"get model profiles failed: {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
def snapshot(self, sess: Session, include_messages: bool = True) -> dict:
|
||||
out = {
|
||||
"sessionId": sess.id,
|
||||
"id": sess.id,
|
||||
"title": sess.title,
|
||||
"cwd": sess.cwd,
|
||||
"status": sess.status,
|
||||
"createdAt": sess.created_at,
|
||||
"updatedAt": sess.updated_at,
|
||||
"lastError": sess.last_error,
|
||||
"msgSeq": sess.msg_seq,
|
||||
}
|
||||
if include_messages:
|
||||
out["messages"] = list(sess.messages)
|
||||
out["partial"] = dict(sess.partial) if sess.partial else None
|
||||
return out
|
||||
|
||||
def add_message(self, sess: Session, role: str, content: str, **extra) -> dict:
|
||||
sess.msg_seq += 1
|
||||
msg = {"id": sess.msg_seq, "role": role, "content": content, "ts": time.time()}
|
||||
msg.update(extra)
|
||||
sess.messages.append(msg)
|
||||
sess.updated_at = time.time()
|
||||
if role == "user" and content.strip() and sess.title == "New chat":
|
||||
sess.title = content.strip().replace("\n", " ")[:40]
|
||||
return msg
|
||||
|
||||
def create_session(self, cwd: Optional[str] = None) -> Session:
|
||||
sid = "sess-" + uuid.uuid4().hex[:12]
|
||||
sess = Session(id=sid, cwd=str(cwd or self.ga_root))
|
||||
with self.lock:
|
||||
self.sessions[sid] = sess
|
||||
self.active_session_id = sid
|
||||
emit_session_state(sess, "created")
|
||||
return sess
|
||||
|
||||
def get_session(self, sid: str) -> Session:
|
||||
with self.lock:
|
||||
sess = self.sessions.get(sid)
|
||||
if not sess:
|
||||
raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
|
||||
return sess
|
||||
|
||||
def delete_session(self, sid: str) -> dict:
|
||||
with self.lock:
|
||||
sess = self.sessions.pop(sid, None)
|
||||
if not sess:
|
||||
raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
|
||||
if self.active_session_id == sid:
|
||||
self.active_session_id = next(iter(self.sessions), None)
|
||||
if sess.agent and hasattr(sess.agent, "cancel"):
|
||||
with contextlib.suppress(Exception):
|
||||
sess.agent.cancel()
|
||||
emit_session_state(sess, "closed")
|
||||
return {"ok": True, "sessionId": sid}
|
||||
|
||||
def submit_prompt(self, sid: str, prompt: Any, images: Optional[list] = None) -> dict:
|
||||
prompt, image_ids = normalize_prompt(prompt, images)
|
||||
with self.lock:
|
||||
sess = self.sessions.get(sid)
|
||||
if not sess:
|
||||
raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
|
||||
if sess.status == "running":
|
||||
raise web.HTTPConflict(text=json.dumps({"error": "session is already running"}, ensure_ascii=False), content_type="application/json")
|
||||
extra = {}
|
||||
if image_ids:
|
||||
extra["image_ids"] = image_ids
|
||||
user_msg = self.add_message(sess, "user", prompt, **extra)
|
||||
sess.status = "running"
|
||||
sess.cancel_requested = False
|
||||
sess.last_error = ""
|
||||
sess.partial = {"id": sess.msg_seq + 1, "role": "assistant", "content": "", "ts": time.time(), "partial": True}
|
||||
t = threading.Thread(target=self.run_agent_turn, args=(sess, prompt, None), daemon=True, name=f"Turn-{sid}")
|
||||
sess.thread = t
|
||||
t.start()
|
||||
seq = sess.msg_seq
|
||||
emit_session_state(sess, "running")
|
||||
return {"ok": True, "sessionId": sid, "accepted": True, "userMessageId": user_msg["id"], "seq": seq}
|
||||
|
||||
def run_agent_turn(self, sess: Session, prompt: str, images: Optional[list] = None):
|
||||
try:
|
||||
if sess.agent is None:
|
||||
sess.agent = self.make_agent(sess)
|
||||
agent = sess.agent
|
||||
full = ""
|
||||
if hasattr(agent, "put_task"):
|
||||
display_q = agent.put_task(prompt, images=images or [])
|
||||
pieces = []
|
||||
while True:
|
||||
item = display_q.get()
|
||||
if isinstance(item, dict):
|
||||
if item.get("next"):
|
||||
text = str(item["next"])
|
||||
pieces.append(text)
|
||||
with self.lock:
|
||||
if sess.partial is not None:
|
||||
sess.partial["content"] = "".join(pieces) if getattr(agent, "inc_out", False) else text
|
||||
sess.partial["ts"] = time.time()
|
||||
sess.updated_at = time.time()
|
||||
if "done" in item:
|
||||
full = str(item.get("done") or "")
|
||||
break
|
||||
else:
|
||||
pieces.append(str(item))
|
||||
if not full and pieces:
|
||||
full = pieces[-1] if not getattr(agent, "inc_out", False) else "".join(pieces)
|
||||
elif hasattr(agent, "run"):
|
||||
ret = agent.run(prompt)
|
||||
if isinstance(ret, str):
|
||||
full = ret
|
||||
else:
|
||||
full = "GenericAgent object has no put_task/run method"
|
||||
if not full:
|
||||
full = "(completed)"
|
||||
with self.lock:
|
||||
sess.partial = None
|
||||
# Strip trailing [Info] Final response to user. marker
|
||||
import re as _re
|
||||
full = _re.sub(r'\n*`{5}\n*\[Info\] Final response to user\.\n*`{5}\s*$', '', full)
|
||||
self.add_message(sess, "assistant", full)
|
||||
sess.status = "idle"
|
||||
sess.last_error = ""
|
||||
emit_session_state(sess, "idle")
|
||||
except Exception as e:
|
||||
tb = traceback.format_exc()
|
||||
with self.lock:
|
||||
sess.partial = None
|
||||
sess.status = "error"
|
||||
sess.last_error = str(e)
|
||||
self.add_message(sess, "error", str(e))
|
||||
print(tb, file=sys.stderr)
|
||||
emit_session_state(sess, "error")
|
||||
|
||||
def messages(self, sid: str, after: int = 0, limit: int = 200) -> dict:
|
||||
with self.lock:
|
||||
sess = self.sessions.get(sid)
|
||||
if not sess:
|
||||
raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
|
||||
msgs = [m for m in sess.messages if int(m.get("id", 0)) > after]
|
||||
if limit > 0:
|
||||
msgs = msgs[-limit:]
|
||||
return {
|
||||
"sessionId": sid,
|
||||
"status": sess.status,
|
||||
"messages": msgs,
|
||||
"partial": dict(sess.partial) if sess.partial else None,
|
||||
"msgSeq": sess.msg_seq,
|
||||
"updatedAt": sess.updated_at,
|
||||
"lastError": sess.last_error,
|
||||
}
|
||||
|
||||
def cancel(self, sid: str) -> dict:
|
||||
with self.lock:
|
||||
sess = self.sessions.get(sid)
|
||||
if not sess:
|
||||
raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json")
|
||||
sess.cancel_requested = True
|
||||
if sess.agent and hasattr(sess.agent, "cancel"):
|
||||
with contextlib.suppress(Exception):
|
||||
sess.agent.cancel()
|
||||
sess.status = "cancelled"
|
||||
sess.partial = None
|
||||
sess.updated_at = time.time()
|
||||
emit_session_state(sess, "cancelled")
|
||||
return {"ok": True, "sessionId": sid}
|
||||
|
||||
|
||||
import base64
|
||||
import tempfile
|
||||
|
||||
# Shared temp dir for image uploads (persists for process lifetime)
|
||||
_UPLOAD_DIR = Path(tempfile.gettempdir()) / "ga_web2_uploads"
|
||||
_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _save_image_data(data_url: str, img_id: str) -> str:
|
||||
"""Save a data URL to disk, return absolute path."""
|
||||
# data:image/png;base64,xxxxx
|
||||
if "," in data_url:
|
||||
header, b64 = data_url.split(",", 1)
|
||||
else:
|
||||
b64 = data_url
|
||||
header = ""
|
||||
ext = "png"
|
||||
if "jpeg" in header or "jpg" in header:
|
||||
ext = "jpg"
|
||||
elif "webp" in header:
|
||||
ext = "webp"
|
||||
elif "gif" in header:
|
||||
ext = "gif"
|
||||
fpath = _UPLOAD_DIR / f"{img_id}.{ext}"
|
||||
fpath.write_bytes(base64.b64decode(b64))
|
||||
return str(fpath)
|
||||
|
||||
|
||||
def normalize_prompt(prompt: Any, images: Optional[list] = None):
|
||||
"""Normalize prompt and images.
|
||||
|
||||
images: list of dicts {"id": "img-xxx", "dataUrl": "data:..."} or plain data URLs.
|
||||
Returns: (prompt_text_with_image_tags, image_ids_list)
|
||||
"""
|
||||
images = list(images or [])
|
||||
if isinstance(prompt, list):
|
||||
text_parts = []
|
||||
for part in prompt:
|
||||
if isinstance(part, str):
|
||||
text_parts.append(part)
|
||||
elif isinstance(part, dict):
|
||||
if part.get("type") in ("text", "input_text"):
|
||||
text_parts.append(str(part.get("text") or part.get("content") or ""))
|
||||
elif part.get("type") in ("image", "input_image"):
|
||||
url = part.get("image_url") or part.get("url") or part.get("data")
|
||||
if isinstance(url, dict):
|
||||
url = url.get("url")
|
||||
if url:
|
||||
images.append(url)
|
||||
prompt = "\n".join([p for p in text_parts if p])
|
||||
|
||||
# Process images: save to disk, build [image:path] tags
|
||||
image_ids = []
|
||||
image_tags = []
|
||||
for img in images:
|
||||
if isinstance(img, dict):
|
||||
img_id = img.get("id") or f"img-{uuid.uuid4().hex[:8]}"
|
||||
data_url = img.get("dataUrl") or img.get("data_url") or ""
|
||||
else:
|
||||
# Plain data URL string
|
||||
img_id = f"img-{uuid.uuid4().hex[:8]}"
|
||||
data_url = str(img)
|
||||
if data_url:
|
||||
path = _save_image_data(data_url, img_id)
|
||||
image_tags.append(f"[image:{path}]")
|
||||
image_ids.append(img_id)
|
||||
|
||||
# Append image tags to prompt
|
||||
final_prompt = str(prompt or "")
|
||||
if image_tags:
|
||||
final_prompt = final_prompt + "\n" + "\n".join(image_tags)
|
||||
|
||||
return final_prompt, image_ids
|
||||
|
||||
|
||||
manager = AgentManager()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transport layer: WS notification only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class WsHub:
|
||||
def __init__(self):
|
||||
self.websockets: Set[web.WebSocketResponse] = set()
|
||||
self.loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
def emit(self, obj: dict):
|
||||
if self.loop and self.loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(self._broadcast(obj), self.loop)
|
||||
|
||||
async def _broadcast(self, obj: dict):
|
||||
data = json.dumps(obj, ensure_ascii=False, default=str)
|
||||
dead = set()
|
||||
for ws in list(self.websockets):
|
||||
try:
|
||||
await ws.send_str(data)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
self.websockets.difference_update(dead)
|
||||
|
||||
|
||||
hub = WsHub()
|
||||
|
||||
|
||||
def emit_session_state(sess: Session, state_name: str):
|
||||
hub.emit({
|
||||
"type": "session-state",
|
||||
"sessionId": sess.id,
|
||||
"state": state_name,
|
||||
"status": sess.status,
|
||||
"seq": sess.msg_seq,
|
||||
"updatedAt": sess.updated_at,
|
||||
"title": sess.title,
|
||||
})
|
||||
|
||||
|
||||
async def ws_handler(request):
|
||||
ws = web.WebSocketResponse(heartbeat=30)
|
||||
await ws.prepare(request)
|
||||
hub.websockets.add(ws)
|
||||
await ws.send_str(json.dumps({
|
||||
"type": "bridge-ready",
|
||||
"gaRoot": manager.ga_root,
|
||||
"mykeyPath": manager.mykey_path,
|
||||
"http": True,
|
||||
"wsEventsOnly": True,
|
||||
}, ensure_ascii=False))
|
||||
async for msg in ws:
|
||||
if msg.type == WSMsgType.TEXT:
|
||||
# WS is intentionally not a data/command channel anymore.
|
||||
with contextlib.suppress(Exception):
|
||||
data = json.loads(msg.data)
|
||||
if data.get("action") == "ping":
|
||||
await ws.send_str(json.dumps({"type": "pong", "ts": time.time()}, ensure_ascii=False))
|
||||
hub.websockets.discard(ws)
|
||||
return ws
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transport layer: HTTP command/data API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cors_headers():
|
||||
return {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
}
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def cors_middleware(request, handler):
|
||||
if request.method == "OPTIONS":
|
||||
return web.Response(status=204, headers=cors_headers())
|
||||
resp = await handler(request)
|
||||
for k, v in cors_headers().items():
|
||||
resp.headers[k] = v
|
||||
return resp
|
||||
|
||||
|
||||
def json_ok(data: dict, status: int = 200):
|
||||
return web.json_response(data, status=status, headers=cors_headers(), dumps=lambda x: json.dumps(x, ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
async def read_json(request) -> dict:
|
||||
if request.can_read_body:
|
||||
try:
|
||||
data = await request.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
async def status_handler(request):
|
||||
return json_ok({
|
||||
"ok": True,
|
||||
"running": True,
|
||||
"ready": True,
|
||||
"gaRoot": manager.ga_root,
|
||||
"mykeyPath": manager.mykey_path,
|
||||
"sessionCount": len(manager.sessions),
|
||||
"activeSessionId": manager.active_session_id,
|
||||
"ws": "/ws",
|
||||
"transport": {"http": True, "wsEventsOnly": True},
|
||||
})
|
||||
|
||||
|
||||
async def get_config_handler(request):
|
||||
return json_ok({"gaRoot": manager.ga_root, "mykeyPath": manager.mykey_path, "config": manager.config})
|
||||
|
||||
|
||||
async def save_config_handler(request):
|
||||
data = await read_json(request)
|
||||
cfg = data.get("config", data)
|
||||
if isinstance(cfg, dict):
|
||||
manager.config.update(cfg)
|
||||
return json_ok({"ok": True, "gaRoot": manager.ga_root, "mykeyPath": manager.mykey_path, "config": manager.config})
|
||||
|
||||
|
||||
async def model_profiles_handler(request):
|
||||
return json_ok({"profiles": manager.list_model_profiles()})
|
||||
|
||||
|
||||
async def list_sessions_handler(request):
|
||||
with manager.lock:
|
||||
sessions = [manager.snapshot(s, include_messages=False) for s in manager.sessions.values()]
|
||||
return json_ok({"sessions": sessions, "activeSessionId": manager.active_session_id})
|
||||
|
||||
|
||||
async def new_session_handler(request):
|
||||
data = await read_json(request)
|
||||
sess = manager.create_session(cwd=data.get("cwd") or data.get("path"))
|
||||
return json_ok({"ok": True, "sessionId": sess.id, "session": manager.snapshot(sess)}, status=201)
|
||||
|
||||
|
||||
async def get_session_handler(request):
|
||||
sid = request.match_info["sid"]
|
||||
sess = manager.get_session(sid)
|
||||
return json_ok({"sessionId": sid, "session": manager.snapshot(sess), "messages": list(sess.messages), "partial": sess.partial})
|
||||
|
||||
|
||||
async def delete_session_handler(request):
|
||||
sid = request.match_info["sid"]
|
||||
return json_ok(manager.delete_session(sid))
|
||||
|
||||
|
||||
async def prompt_handler(request):
|
||||
sid = request.match_info["sid"]
|
||||
data = await read_json(request)
|
||||
prompt = data.get("prompt", data.get("content", data.get("message", "")))
|
||||
images = data.get("images") or []
|
||||
return json_ok(manager.submit_prompt(sid, prompt, images))
|
||||
|
||||
|
||||
async def messages_handler(request):
|
||||
sid = request.match_info["sid"]
|
||||
after = int(request.query.get("after") or request.query.get("afterId") or 0)
|
||||
limit = int(request.query.get("limit") or 200)
|
||||
return json_ok(manager.messages(sid, after=after, limit=limit))
|
||||
|
||||
|
||||
async def cancel_handler(request):
|
||||
sid = request.match_info["sid"]
|
||||
return json_ok(manager.cancel(sid))
|
||||
|
||||
|
||||
async def path_open_handler(request):
|
||||
data = await read_json(request)
|
||||
kind = data.get("kind", "")
|
||||
if kind == "mykey":
|
||||
target = Path(manager.ga_root) / "mykey.py"
|
||||
else:
|
||||
target = Path(data.get("path") or data.get("target") or manager.ga_root)
|
||||
target = target.resolve()
|
||||
if not target.exists():
|
||||
return json_ok({"ok": False, "error": f"File not found: {target}"})
|
||||
# Actually open the file with the system default editor
|
||||
import subprocess, platform
|
||||
if platform.system() == "Windows":
|
||||
os.startfile(str(target))
|
||||
elif platform.system() == "Darwin":
|
||||
subprocess.Popen(["open", str(target)])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", str(target)])
|
||||
return json_ok({"ok": True, "path": str(target)})
|
||||
|
||||
|
||||
def create_app():
|
||||
app = web.Application(middlewares=[cors_middleware])
|
||||
app.router.add_get("/ws", ws_handler)
|
||||
app.router.add_get("/status", status_handler)
|
||||
app.router.add_get("/config", get_config_handler)
|
||||
app.router.add_post("/config", save_config_handler)
|
||||
app.router.add_get("/model-profiles", model_profiles_handler)
|
||||
app.router.add_get("/sessions", list_sessions_handler)
|
||||
app.router.add_post("/session/new", new_session_handler)
|
||||
app.router.add_get("/session/{sid}", get_session_handler)
|
||||
app.router.add_delete("/session/{sid}", delete_session_handler)
|
||||
app.router.add_post("/session/{sid}/prompt", prompt_handler)
|
||||
app.router.add_get("/session/{sid}/messages", messages_handler)
|
||||
app.router.add_post("/session/{sid}/cancel", cancel_handler)
|
||||
app.router.add_post("/path/open", path_open_handler)
|
||||
|
||||
# Serve static frontend (desktop/static/)
|
||||
static_dir = APP_DIR / "desktop" / "static"
|
||||
|
||||
async def index_handler(request):
|
||||
return web.FileResponse(static_dir / "index.html")
|
||||
|
||||
app.router.add_get("/", index_handler)
|
||||
app.router.add_static("/", static_dir, show_index=False)
|
||||
|
||||
async def on_startup(app):
|
||||
hub.loop = asyncio.get_running_loop()
|
||||
|
||||
app.on_startup.append(on_startup)
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = os.environ.get("BRIDGE_HOST", "127.0.0.1")
|
||||
port = int(os.environ.get("BRIDGE_PORT", "14168"))
|
||||
print(f"GenericAgent Web2 bridge: http://{host}:{port} ws://{host}:{port}/ws", file=sys.stderr)
|
||||
web.run_app(create_app(), host=host, port=port, print=None)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Desktop Pet with HTTP Toast — ~90 lines"""
|
||||
import tkinter as tk, threading, random, os, sys
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
PORT = 41983
|
||||
GIF = os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), 'pet.gif')
|
||||
|
||||
class Pet:
|
||||
def __init__(self):
|
||||
self.root = tk.Tk()
|
||||
self.root.overrideredirect(True)
|
||||
self.root.wm_attributes('-topmost', True)
|
||||
self.root.wm_attributes('-transparentcolor', '#01FF01')
|
||||
self.root.config(bg='#01FF01')
|
||||
self.root.after(50, lambda: self.root.geometry('+300+500'))
|
||||
# load GIF frames
|
||||
self.frames, i = [], 0
|
||||
while True:
|
||||
try: self.frames.append(tk.PhotoImage(file=GIF, format=f'gif -index {i}')); i += 1
|
||||
except: break
|
||||
if not self.frames: raise FileNotFoundError(f'No GIF: {GIF}')
|
||||
self.idx = 0
|
||||
self.label = tk.Label(self.root, image=self.frames[0], bg='#01FF01', bd=0)
|
||||
self.label.pack()
|
||||
# drag
|
||||
self.label.bind('<Button-1>', lambda e: setattr(self, '_d', (e.x, e.y)))
|
||||
self.label.bind('<B1-Motion>', self._drag)
|
||||
self.label.bind('<Double-1>', lambda e: (self.root.destroy(), os._exit(0)))
|
||||
# start loops
|
||||
self._animate()
|
||||
self._wander()
|
||||
self._start_server()
|
||||
self.root.mainloop()
|
||||
|
||||
def _drag(self, e):
|
||||
x, y = self.root.winfo_x() + e.x - self._d[0], self.root.winfo_y() + e.y - self._d[1]
|
||||
self.root.geometry(f'+{x}+{y}')
|
||||
|
||||
def _animate(self):
|
||||
self.idx = (self.idx + 1) % len(self.frames)
|
||||
self.label.config(image=self.frames[self.idx])
|
||||
self.root.after(150, self._animate)
|
||||
|
||||
def _wander(self):
|
||||
if random.random() < 0.25:
|
||||
x = self.root.winfo_x() + random.randint(-15, 15)
|
||||
y = self.root.winfo_y() + random.randint(-5, 5)
|
||||
self.root.geometry(f'+{x}+{y}')
|
||||
self.root.after(4000, self._wander)
|
||||
|
||||
def show_toast(self, msg):
|
||||
"""Show a speech bubble near the pet that auto-dismisses."""
|
||||
tw = tk.Toplevel(self.root)
|
||||
tw.overrideredirect(True)
|
||||
tw.wm_attributes('-topmost', True)
|
||||
tw.config(bg='#FFFDE7')
|
||||
px, py = self.root.winfo_x(), self.root.winfo_y()
|
||||
tw.geometry(f'+{px + 30}+{py - 50}')
|
||||
# bubble content
|
||||
f = tk.Frame(tw, bg='#FFFDE7', highlightbackground='#888', highlightthickness=1, padx=8, pady=4)
|
||||
f.pack()
|
||||
tk.Label(f, text=msg, bg='#FFFDE7', fg='#333', font=('Segoe UI', 10), wraplength=220, justify='left').pack()
|
||||
# auto dismiss
|
||||
tw.after(3000, tw.destroy)
|
||||
|
||||
def _start_server(self):
|
||||
pet = self
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
msg = qs.get('msg', [''])[0]
|
||||
if msg:
|
||||
pet.root.after(0, pet.show_toast, msg)
|
||||
self.send_response(200); self.end_headers(); self.wfile.write(b'ok')
|
||||
else:
|
||||
self.send_response(400); self.end_headers(); self.wfile.write(b'?msg=xxx')
|
||||
def do_POST(self):
|
||||
body = self.rfile.read(int(self.headers.get('Content-Length', 0))).decode()
|
||||
if body:
|
||||
pet.root.after(0, pet.show_toast, body)
|
||||
self.send_response(200); self.end_headers(); self.wfile.write(b'ok')
|
||||
else:
|
||||
self.send_response(400); self.end_headers(); self.wfile.write(b'empty body')
|
||||
def log_message(self, *a): pass
|
||||
HTTPServer.allow_reuse_address = False
|
||||
srv = HTTPServer(('127.0.0.1', PORT), H)
|
||||
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
t.start()
|
||||
print(f'Toast server: http://127.0.0.1:{PORT}/?msg=hello')
|
||||
|
||||
if __name__ == '__main__':
|
||||
Pet()
|
||||
@@ -0,0 +1,151 @@
|
||||
import asyncio, json, os, sys, threading, time
|
||||
import requests
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from agentmain import GeneraticAgent
|
||||
from chatapp_common import AgentChatMixin, ensure_single_instance, public_access, redirect_log, require_runtime, split_text
|
||||
from llmcore import mykeys
|
||||
|
||||
try:
|
||||
from dingtalk_stream import AckMessage, CallbackHandler, Credential, DingTalkStreamClient
|
||||
from dingtalk_stream.chatbot import ChatbotMessage
|
||||
except Exception:
|
||||
print("Please install dingtalk-stream to use DingTalk: pip install dingtalk-stream")
|
||||
sys.exit(1)
|
||||
|
||||
agent = GeneraticAgent(); agent.verbose = False
|
||||
CLIENT_ID = str(mykeys.get("dingtalk_client_id", "") or "").strip()
|
||||
CLIENT_SECRET = str(mykeys.get("dingtalk_client_secret", "") or "").strip()
|
||||
ALLOWED = {str(x).strip() for x in mykeys.get("dingtalk_allowed_users", []) if str(x).strip()}
|
||||
USER_TASKS = {}
|
||||
|
||||
|
||||
class DingTalkApp(AgentChatMixin):
|
||||
label, source, split_limit = "DingTalk", "dingtalk", 1800
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(agent, USER_TASKS)
|
||||
self.client, self.access_token, self.token_expiry, self.background_tasks = None, None, 0, set()
|
||||
|
||||
async def _get_access_token(self):
|
||||
if self.access_token and time.time() < self.token_expiry:
|
||||
return self.access_token
|
||||
|
||||
def _fetch():
|
||||
resp = requests.post("https://api.dingtalk.com/v1.0/oauth2/accessToken", json={"appKey": CLIENT_ID, "appSecret": CLIENT_SECRET}, timeout=20)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
last_err = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
data = await asyncio.to_thread(_fetch)
|
||||
self.access_token = data.get("accessToken")
|
||||
self.token_expiry = time.time() + int(data.get("expireIn", 7200)) - 60
|
||||
return self.access_token
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt == 0:
|
||||
await asyncio.sleep(1)
|
||||
print(f"[DingTalk] token error after retry: {last_err}")
|
||||
return None
|
||||
|
||||
async def _send_batch_message(self, chat_id, msg_key, msg_param):
|
||||
token = await self._get_access_token()
|
||||
if not token:
|
||||
return False
|
||||
headers = {"x-acs-dingtalk-access-token": token}
|
||||
if chat_id.startswith("group:"):
|
||||
url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
|
||||
payload = {"robotCode": CLIENT_ID, "openConversationId": chat_id[6:], "msgKey": msg_key, "msgParam": json.dumps(msg_param, ensure_ascii=False)}
|
||||
else:
|
||||
url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
|
||||
payload = {"robotCode": CLIENT_ID, "userIds": [chat_id], "msgKey": msg_key, "msgParam": json.dumps(msg_param, ensure_ascii=False)}
|
||||
|
||||
def _post():
|
||||
resp = requests.post(url, json=payload, headers=headers, timeout=20)
|
||||
body = resp.text
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"HTTP {resp.status_code}: {body[:300]}")
|
||||
result = resp.json() if "json" in resp.headers.get("content-type", "") else {}
|
||||
errcode = result.get("errcode")
|
||||
if errcode not in (None, 0):
|
||||
raise RuntimeError(f"API errcode={errcode}: {body[:300]}")
|
||||
return True
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_post)
|
||||
except Exception as e:
|
||||
print(f"[DingTalk] send error: {e}")
|
||||
return False
|
||||
|
||||
async def send_text(self, chat_id, content):
|
||||
for part in split_text(content, self.split_limit):
|
||||
await self._send_batch_message(chat_id, "sampleMarkdown", {"text": part, "title": "Agent Reply"})
|
||||
|
||||
async def on_message(self, content, sender_id, sender_name, conversation_type=None, conversation_id=None):
|
||||
try:
|
||||
if not content:
|
||||
return
|
||||
if not public_access(ALLOWED) and sender_id not in ALLOWED:
|
||||
print(f"[DingTalk] unauthorized user: {sender_id}")
|
||||
return
|
||||
is_group = conversation_type == "2" and conversation_id
|
||||
chat_id = f"group:{conversation_id}" if is_group else sender_id
|
||||
print(f"[DingTalk] message from {sender_name} ({sender_id}): {content}")
|
||||
if content.startswith("/"):
|
||||
return await self.handle_command(chat_id, content)
|
||||
task = asyncio.create_task(self.run_agent(chat_id, content))
|
||||
self.background_tasks.add(task)
|
||||
task.add_done_callback(self.background_tasks.discard)
|
||||
except Exception:
|
||||
import traceback
|
||||
print("[DingTalk] handle_message error")
|
||||
traceback.print_exc()
|
||||
|
||||
async def start(self):
|
||||
self.client = DingTalkStreamClient(Credential(CLIENT_ID, CLIENT_SECRET))
|
||||
self.client.register_callback_handler(ChatbotMessage.TOPIC, _DingTalkHandler(self))
|
||||
print("[DingTalk] bot starting...")
|
||||
delay, max_delay = 5, 300
|
||||
while True:
|
||||
started_at = time.monotonic()
|
||||
try:
|
||||
await self.client.start()
|
||||
except Exception as e:
|
||||
print(f"[DingTalk] stream error: {e}")
|
||||
# any session that lived >=60s is treated as healthy -> reset backoff
|
||||
if time.monotonic() - started_at >= 60:
|
||||
delay = 5
|
||||
print(f"[DingTalk] reconnect in {delay}s...")
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, max_delay)
|
||||
|
||||
|
||||
class _DingTalkHandler(CallbackHandler):
|
||||
def __init__(self, app):
|
||||
super().__init__()
|
||||
self.app = app
|
||||
|
||||
async def process(self, message):
|
||||
try:
|
||||
chatbot_msg = ChatbotMessage.from_dict(message.data)
|
||||
text = getattr(getattr(chatbot_msg, "text", None), "content", "") or ""
|
||||
extensions = getattr(chatbot_msg, "extensions", None) or {}
|
||||
recognition = ((extensions.get("content") or {}).get("recognition") or "").strip() if isinstance(extensions, dict) else ""
|
||||
if not (text := text.strip()):
|
||||
text = recognition or str((message.data.get("text", {}) or {}).get("content", "") or "").strip()
|
||||
sender_id = str(getattr(chatbot_msg, "sender_staff_id", None) or getattr(chatbot_msg, "sender_id", None) or "unknown")
|
||||
sender_name = getattr(chatbot_msg, "sender_nick", None) or "Unknown"
|
||||
await self.app.on_message(text, sender_id, sender_name, message.data.get("conversationType"), message.data.get("conversationId") or message.data.get("openConversationId"))
|
||||
except Exception as e:
|
||||
print(f"[DingTalk] callback error: {e}")
|
||||
return AckMessage.STATUS_OK, "OK"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_LOCK_SOCK = ensure_single_instance(19530, "DingTalk")
|
||||
require_runtime(agent, "DingTalk", dingtalk_client_id=CLIENT_ID, dingtalk_client_secret=CLIENT_SECRET)
|
||||
redirect_log(__file__, "dingtalkapp.log", "DingTalk", ALLOWED)
|
||||
threading.Thread(target=agent.run, daemon=True).start()
|
||||
asyncio.run(DingTalkApp().start())
|
||||
@@ -0,0 +1,78 @@
|
||||
"""`/export` command: export last assistant reply / locate full conversation log.
|
||||
Pure functions, no Qt deps. UI wiring lives in the frontend file.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from continue_cmd import _pairs, _assistant_text
|
||||
|
||||
_TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp')
|
||||
_BACKTICK_RUN_RE = re.compile(r'`+')
|
||||
|
||||
|
||||
def wrap_for_clipboard(text, language='markdown'):
|
||||
"""Wrap text in a markdown code fence that survives nested fences.
|
||||
|
||||
CommonMark closes a fenced block on a line whose backtick run is at least
|
||||
as long as the opening one. Pick `max(3, longest_inner_run + 1)` so any
|
||||
backticks inside `text` are strictly shorter than the outer fence.
|
||||
"""
|
||||
longest = max((len(m.group(0)) for m in _BACKTICK_RUN_RE.finditer(text)), default=0)
|
||||
fence = '`' * max(3, longest + 1)
|
||||
return f"{fence}{language}\n{text}\n{fence}"
|
||||
|
||||
|
||||
def last_assistant_text(agent):
|
||||
"""Last assistant reply as joined plain text from `agent.log_path`.
|
||||
|
||||
Reads the model_responses log, takes the most recent === Response === block,
|
||||
and joins only the `text` fields (skips thinking/tool_use/tool_result).
|
||||
|
||||
Returns None when:
|
||||
- the LLM backend's history is empty (fresh agent or just-`/new`'d —
|
||||
log_path may still hold prior-session content that no longer belongs
|
||||
to the current conversation)
|
||||
- the log is missing or unreadable
|
||||
- there's no Response yet, or the last Response holds no text blocks
|
||||
(e.g. tool-only turn)
|
||||
|
||||
OS errors are logged to stderr — small failure radius, the UI falls back
|
||||
gracefully.
|
||||
"""
|
||||
if not (agent.llmclient and agent.llmclient.backend.history):
|
||||
return None
|
||||
log = agent.log_path
|
||||
if not os.path.isfile(log):
|
||||
return None
|
||||
try:
|
||||
with open(log, encoding='utf-8', errors='replace') as f:
|
||||
content = f.read()
|
||||
except OSError as e:
|
||||
print(f"[export_cmd] failed to read {log}: {e}", file=sys.stderr)
|
||||
return None
|
||||
pairs = _pairs(content)
|
||||
if not pairs:
|
||||
return None
|
||||
text = _assistant_text(pairs[-1][1])
|
||||
return text if text.strip() else None
|
||||
|
||||
|
||||
def export_to_temp(text, name):
|
||||
"""Write text to temp/<name>.md, overwriting on collision. Returns full path.
|
||||
|
||||
`name` is sanitized via os.path.basename to keep the write inside temp/.
|
||||
Empty/whitespace name falls back to a timestamp. `.md` is appended if
|
||||
the user-supplied name has no extension.
|
||||
"""
|
||||
os.makedirs(_TEMP_DIR, exist_ok=True)
|
||||
safe = os.path.basename((name or '').strip())
|
||||
if not safe:
|
||||
safe = f"export_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
if not os.path.splitext(safe)[1]:
|
||||
safe = safe + '.md'
|
||||
path = os.path.join(_TEMP_DIR, safe)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.write(text)
|
||||
return path
|
||||
@@ -0,0 +1,709 @@
|
||||
import glob, json, os, queue as Q, re, sys, threading, time
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
os.chdir(PROJECT_ROOT)
|
||||
from agentmain import GeneraticAgent
|
||||
from frontends.chatapp_common import format_restore
|
||||
from frontends.continue_cmd import handle_frontend_command as handle_continue_frontend, reset_conversation
|
||||
from llmcore import mykeys
|
||||
|
||||
import traceback
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.im.v1 import *
|
||||
|
||||
_TAG_PATS = [r"<" + t + r">.*?</" + t + r">" for t in ("thinking", "summary", "tool_use", "file_content")]
|
||||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
|
||||
_AUDIO_EXTS = {".opus", ".mp3", ".wav", ".m4a", ".aac"}
|
||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
|
||||
_FILE_TYPE_MAP = {
|
||||
".opus": "opus",
|
||||
".mp4": "mp4",
|
||||
".pdf": "pdf",
|
||||
".doc": "doc",
|
||||
".docx": "doc",
|
||||
".xls": "xls",
|
||||
".xlsx": "xls",
|
||||
".ppt": "ppt",
|
||||
".pptx": "ppt",
|
||||
}
|
||||
_MSG_TYPE_MAP = {"image": "[image]", "audio": "[audio]", "file": "[file]", "media": "[media]", "sticker": "[sticker]"}
|
||||
|
||||
TEMP_DIR = os.path.join(PROJECT_ROOT, "temp")
|
||||
MEDIA_DIR = os.path.join(TEMP_DIR, "feishu_media")
|
||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
_TRUNC_TAIL = 300 # 截断兜底时保留原文尾部字符数
|
||||
|
||||
|
||||
def _clean(text):
|
||||
for pat in _TAG_PATS:
|
||||
text = re.sub(pat, "", text or "", flags=re.DOTALL)
|
||||
return re.sub(r"\n{3,}", "\n\n", text).strip()
|
||||
|
||||
|
||||
def _extract_files(text):
|
||||
return re.findall(r"\[FILE:([^\]]+)\]", text or "")
|
||||
|
||||
|
||||
def _strip_files(text):
|
||||
return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip()
|
||||
|
||||
|
||||
def _display_text(text):
|
||||
cleaned = _strip_files(_clean(text))
|
||||
if cleaned:
|
||||
return cleaned
|
||||
tail = (text or "").strip()[-_TRUNC_TAIL:]
|
||||
return "(无文本输出)" + (f"\n…{tail}" if tail else "")
|
||||
|
||||
|
||||
def _to_allowed_set(value):
|
||||
if value is None:
|
||||
return set()
|
||||
if isinstance(value, str):
|
||||
value = [value]
|
||||
return {str(x).strip() for x in value if str(x).strip()}
|
||||
|
||||
|
||||
def _parse_json(raw):
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_share_card_content(content_json, msg_type):
|
||||
parts = []
|
||||
if msg_type == "share_chat":
|
||||
parts.append(f"[shared chat: {content_json.get('chat_id', '')}]")
|
||||
elif msg_type == "share_user":
|
||||
parts.append(f"[shared user: {content_json.get('user_id', '')}]")
|
||||
elif msg_type == "interactive":
|
||||
parts.extend(_extract_interactive_content(content_json))
|
||||
elif msg_type == "share_calendar_event":
|
||||
parts.append(f"[shared calendar event: {content_json.get('event_key', '')}]")
|
||||
elif msg_type == "system":
|
||||
parts.append("[system message]")
|
||||
elif msg_type == "merge_forward":
|
||||
parts.append("[merged forward messages]")
|
||||
return "\n".join([p for p in parts if p]).strip() or f"[{msg_type}]"
|
||||
|
||||
|
||||
def _extract_interactive_content(content):
|
||||
parts = []
|
||||
if isinstance(content, str):
|
||||
try:
|
||||
content = json.loads(content)
|
||||
except Exception:
|
||||
return [content] if content.strip() else []
|
||||
if not isinstance(content, dict):
|
||||
return parts
|
||||
title = content.get("title")
|
||||
if isinstance(title, dict):
|
||||
title_text = title.get("content", "") or title.get("text", "")
|
||||
if title_text:
|
||||
parts.append(f"title: {title_text}")
|
||||
elif isinstance(title, str) and title:
|
||||
parts.append(f"title: {title}")
|
||||
elements = content.get("elements", [])
|
||||
if isinstance(elements, list):
|
||||
for row in elements:
|
||||
if isinstance(row, dict):
|
||||
parts.extend(_extract_element_content(row))
|
||||
elif isinstance(row, list):
|
||||
for el in row:
|
||||
parts.extend(_extract_element_content(el))
|
||||
card = content.get("card", {})
|
||||
if card:
|
||||
parts.extend(_extract_interactive_content(card))
|
||||
header = content.get("header", {})
|
||||
if isinstance(header, dict):
|
||||
header_title = header.get("title", {})
|
||||
if isinstance(header_title, dict):
|
||||
header_text = header_title.get("content", "") or header_title.get("text", "")
|
||||
if header_text:
|
||||
parts.append(f"title: {header_text}")
|
||||
return [p for p in parts if p]
|
||||
|
||||
|
||||
def _extract_element_content(element):
|
||||
parts = []
|
||||
if not isinstance(element, dict):
|
||||
return parts
|
||||
tag = element.get("tag", "")
|
||||
if tag in ("markdown", "lark_md"):
|
||||
content = element.get("content", "")
|
||||
if content:
|
||||
parts.append(content)
|
||||
elif tag == "div":
|
||||
text = element.get("text", {})
|
||||
if isinstance(text, dict):
|
||||
text_content = text.get("content", "") or text.get("text", "")
|
||||
if text_content:
|
||||
parts.append(text_content)
|
||||
elif isinstance(text, str) and text:
|
||||
parts.append(text)
|
||||
for field in element.get("fields", []) or []:
|
||||
if isinstance(field, dict):
|
||||
field_text = field.get("text", {})
|
||||
if isinstance(field_text, dict):
|
||||
content = field_text.get("content", "") or field_text.get("text", "")
|
||||
if content:
|
||||
parts.append(content)
|
||||
elif tag == "a":
|
||||
href = element.get("href", "")
|
||||
text = element.get("text", "")
|
||||
if href:
|
||||
parts.append(f"link: {href}")
|
||||
if text:
|
||||
parts.append(text)
|
||||
elif tag == "button":
|
||||
text = element.get("text", {})
|
||||
if isinstance(text, dict):
|
||||
content = text.get("content", "") or text.get("text", "")
|
||||
if content:
|
||||
parts.append(content)
|
||||
url = element.get("url", "") or (element.get("multi_url", {}) or {}).get("url", "")
|
||||
if url:
|
||||
parts.append(f"link: {url}")
|
||||
elif tag == "img":
|
||||
alt = element.get("alt", {})
|
||||
if isinstance(alt, dict):
|
||||
parts.append(alt.get("content", "[image]") or "[image]")
|
||||
else:
|
||||
parts.append("[image]")
|
||||
for child in element.get("elements", []) or []:
|
||||
parts.extend(_extract_element_content(child))
|
||||
for col in element.get("columns", []) or []:
|
||||
for child in (col.get("elements", []) if isinstance(col, dict) else []):
|
||||
parts.extend(_extract_element_content(child))
|
||||
return parts
|
||||
|
||||
|
||||
def _extract_post_content(content_json):
|
||||
def _parse_block(block):
|
||||
if not isinstance(block, dict) or not isinstance(block.get("content"), list):
|
||||
return None, []
|
||||
texts, images = [], []
|
||||
if block.get("title"):
|
||||
texts.append(block.get("title"))
|
||||
for row in block["content"]:
|
||||
if not isinstance(row, list):
|
||||
continue
|
||||
for el in row:
|
||||
if not isinstance(el, dict):
|
||||
continue
|
||||
tag = el.get("tag")
|
||||
if tag in ("text", "a"):
|
||||
texts.append(el.get("text", ""))
|
||||
elif tag == "at":
|
||||
texts.append(f"@{el.get('user_name', 'user')}")
|
||||
elif tag == "img" and el.get("image_key"):
|
||||
images.append(el["image_key"])
|
||||
text = " ".join([t for t in texts if t]).strip()
|
||||
return text or None, images
|
||||
|
||||
root = content_json
|
||||
if isinstance(root, dict) and isinstance(root.get("post"), dict):
|
||||
root = root["post"]
|
||||
if not isinstance(root, dict):
|
||||
return "", []
|
||||
if "content" in root:
|
||||
text, imgs = _parse_block(root)
|
||||
if text or imgs:
|
||||
return text or "", imgs
|
||||
for key in ("zh_cn", "en_us", "ja_jp"):
|
||||
if key in root:
|
||||
text, imgs = _parse_block(root[key])
|
||||
if text or imgs:
|
||||
return text or "", imgs
|
||||
for val in root.values():
|
||||
if isinstance(val, dict):
|
||||
text, imgs = _parse_block(val)
|
||||
if text or imgs:
|
||||
return text or "", imgs
|
||||
return "", []
|
||||
|
||||
|
||||
APP_ID = str(mykeys.get("fs_app_id", "") or "").strip()
|
||||
APP_SECRET = str(mykeys.get("fs_app_secret", "") or "").strip()
|
||||
ALLOWED_USERS = _to_allowed_set(mykeys.get("fs_allowed_users", []))
|
||||
PUBLIC_ACCESS = not ALLOWED_USERS or "*" in ALLOWED_USERS
|
||||
AGENT_TIMEOUT_SEC = 900
|
||||
|
||||
agent = GeneraticAgent()
|
||||
threading.Thread(target=agent.run, daemon=True).start()
|
||||
client, user_tasks = None, {}
|
||||
|
||||
|
||||
def create_client():
|
||||
return lark.Client.builder().app_id(APP_ID).app_secret(APP_SECRET).log_level(lark.LogLevel.INFO).build()
|
||||
|
||||
|
||||
def _card_raw(elements):
|
||||
return json.dumps({
|
||||
"schema": "2.0",
|
||||
"config": {"streaming_mode": False, "width_mode": "fill"},
|
||||
"body": {"elements": elements},
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _card(text):
|
||||
return _card_raw([{"tag": "markdown", "content": text}])
|
||||
|
||||
|
||||
def _send_raw(receive_id, payload, msg_type, rtype):
|
||||
try:
|
||||
body = CreateMessageRequest.builder().receive_id_type(rtype).request_body(
|
||||
CreateMessageRequestBody.builder().receive_id(receive_id).msg_type(msg_type).content(payload).build()
|
||||
).build()
|
||||
r = client.im.v1.message.create(body)
|
||||
if r.success():
|
||||
return r.data.message_id if r.data else None
|
||||
print(f"发送失败: {r.code}, {r.msg}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] _send_raw 网络异常: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _patch_card(message_id, card_json):
|
||||
return _patch_card_result(message_id, card_json)[0]
|
||||
|
||||
|
||||
def _patch_card_result(message_id, card_json):
|
||||
try:
|
||||
body = PatchMessageRequest.builder().message_id(message_id).request_body(
|
||||
PatchMessageRequestBody.builder().content(card_json).build()
|
||||
).build()
|
||||
r = client.im.v1.message.patch(body)
|
||||
if not r.success():
|
||||
print(f"[ERROR] patch_card 失败: {r.code}, {r.msg}")
|
||||
msg = f"{getattr(r, 'code', '')} {getattr(r, 'msg', '')}".lower()
|
||||
return r.success(), ("230099" in msg or "11310" in msg or "element exceeds the limit" in msg)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] _patch_card 网络异常: {e}")
|
||||
return False, False
|
||||
|
||||
|
||||
def send_message(receive_id, content, msg_type="text", use_card=False, receive_id_type="open_id"):
|
||||
if use_card:
|
||||
return _send_raw(receive_id, _card(content), "interactive", receive_id_type)
|
||||
if msg_type == "text":
|
||||
return _send_raw(receive_id, json.dumps({"text": content}, ensure_ascii=False), "text", receive_id_type)
|
||||
return _send_raw(receive_id, content, msg_type, receive_id_type)
|
||||
|
||||
|
||||
def update_message(message_id, content):
|
||||
return _patch_card(message_id, _card(content))
|
||||
|
||||
|
||||
def _upload_image_sync(file_path):
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
request = CreateImageRequest.builder().request_body(
|
||||
CreateImageRequestBody.builder().image_type("message").image(f).build()
|
||||
).build()
|
||||
response = client.im.v1.image.create(request)
|
||||
if response.success():
|
||||
return response.data.image_key
|
||||
print(f"[ERROR] upload image failed: {response.code}, {response.msg}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] upload image failed {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _upload_file_sync(file_path):
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
file_type = _FILE_TYPE_MAP.get(ext, "stream")
|
||||
file_name = os.path.basename(file_path)
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
request = CreateFileRequest.builder().request_body(
|
||||
CreateFileRequestBody.builder().file_type(file_type).file_name(file_name).file(f).build()
|
||||
).build()
|
||||
response = client.im.v1.file.create(request)
|
||||
if response.success():
|
||||
return response.data.file_key
|
||||
print(f"[ERROR] upload file failed: {response.code}, {response.msg}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] upload file failed {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _download_image_sync(message_id, image_key):
|
||||
try:
|
||||
request = GetMessageResourceRequest.builder().message_id(message_id).file_key(image_key).type("image").build()
|
||||
response = client.im.v1.message_resource.get(request)
|
||||
if response.success():
|
||||
data = response.file.read() if hasattr(response.file, "read") else response.file
|
||||
return data, response.file_name
|
||||
print(f"[ERROR] download image failed: {response.code}, {response.msg}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] download image failed {image_key}: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def _download_file_sync(message_id, file_key, resource_type="file"):
|
||||
if resource_type == "audio":
|
||||
resource_type = "file"
|
||||
try:
|
||||
request = GetMessageResourceRequest.builder().message_id(message_id).file_key(file_key).type(resource_type).build()
|
||||
response = client.im.v1.message_resource.get(request)
|
||||
if response.success():
|
||||
data = response.file.read() if hasattr(response.file, "read") else response.file
|
||||
return data, response.file_name
|
||||
print(f"[ERROR] download {resource_type} failed: {response.code}, {response.msg}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] download {resource_type} failed {file_key}: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def _download_and_save_media(msg_type, content_json, message_id):
|
||||
data, filename = None, None
|
||||
if msg_type == "image":
|
||||
image_key = content_json.get("image_key")
|
||||
if image_key and message_id:
|
||||
data, filename = _download_image_sync(message_id, image_key)
|
||||
if not filename:
|
||||
filename = f"{image_key[:16]}.jpg"
|
||||
elif msg_type in ("audio", "file", "media"):
|
||||
file_key = content_json.get("file_key")
|
||||
if file_key and message_id:
|
||||
data, filename = _download_file_sync(message_id, file_key, msg_type)
|
||||
if not filename:
|
||||
filename = file_key[:16]
|
||||
if msg_type == "audio" and filename and not filename.endswith(".opus"):
|
||||
filename = f"{filename}.opus"
|
||||
if data and filename:
|
||||
file_path = os.path.join(MEDIA_DIR, os.path.basename(filename))
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(data)
|
||||
return file_path, filename
|
||||
return None, None
|
||||
|
||||
|
||||
def _describe_media(msg_type, file_path, filename):
|
||||
if msg_type == "image":
|
||||
return f"[image: {filename}]\n[Image: source: {file_path}]"
|
||||
if msg_type == "audio":
|
||||
return f"[audio: {filename}]\n[File: source: {file_path}]"
|
||||
if msg_type in ("file", "media"):
|
||||
return f"[{msg_type}: {filename}]\n[File: source: {file_path}]"
|
||||
return f"[{msg_type}]\n[File: source: {file_path}]"
|
||||
|
||||
|
||||
def _send_local_file(receive_id, file_path, receive_id_type="open_id"):
|
||||
if not os.path.isfile(file_path):
|
||||
send_message(receive_id, f"⚠️ 文件不存在: {file_path}", receive_id_type=receive_id_type)
|
||||
return False
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext in _IMAGE_EXTS:
|
||||
image_key = _upload_image_sync(file_path)
|
||||
if image_key:
|
||||
send_message(receive_id, json.dumps({"image_key": image_key}, ensure_ascii=False), msg_type="image", receive_id_type=receive_id_type)
|
||||
return True
|
||||
else:
|
||||
file_key = _upload_file_sync(file_path)
|
||||
if file_key:
|
||||
msg_type = "media" if ext in _AUDIO_EXTS or ext in _VIDEO_EXTS else "file"
|
||||
send_message(receive_id, json.dumps({"file_key": file_key}, ensure_ascii=False), msg_type=msg_type, receive_id_type=receive_id_type)
|
||||
return True
|
||||
send_message(receive_id, f"⚠️ 文件发送失败: {os.path.basename(file_path)}", receive_id_type=receive_id_type)
|
||||
return False
|
||||
|
||||
|
||||
def _send_generated_files(receive_id, raw_text, receive_id_type="open_id"):
|
||||
for file_path in _extract_files(raw_text):
|
||||
_send_local_file(receive_id, file_path, receive_id_type)
|
||||
|
||||
|
||||
def _build_user_message(message):
|
||||
msg_type = message.message_type
|
||||
message_id = message.message_id
|
||||
content_json = _parse_json(message.content)
|
||||
parts, image_paths = [], []
|
||||
if msg_type == "text":
|
||||
text = str(content_json.get("text", "") or "").strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
elif msg_type == "post":
|
||||
text, image_keys = _extract_post_content(content_json)
|
||||
if text:
|
||||
parts.append(text)
|
||||
for image_key in image_keys:
|
||||
file_path, filename = _download_and_save_media("image", {"image_key": image_key}, message_id)
|
||||
if file_path and filename:
|
||||
parts.append(_describe_media("image", file_path, filename))
|
||||
image_paths.append(file_path)
|
||||
else:
|
||||
parts.append("[image: download failed]")
|
||||
elif msg_type in ("image", "audio", "file", "media"):
|
||||
file_path, filename = _download_and_save_media(msg_type, content_json, message_id)
|
||||
if file_path and filename:
|
||||
parts.append(_describe_media(msg_type, file_path, filename))
|
||||
if msg_type == "image":
|
||||
image_paths.append(file_path)
|
||||
else:
|
||||
parts.append(f"[{msg_type}: download failed]")
|
||||
elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"):
|
||||
parts.append(_extract_share_card_content(content_json, msg_type))
|
||||
else:
|
||||
parts.append(_MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
|
||||
return "\n".join([p for p in parts if p]).strip(), image_paths
|
||||
|
||||
|
||||
def _fmt_tool_call(tc):
|
||||
name = tc.get('tool_name', '?')
|
||||
args = {k: v for k, v in (tc.get('args') or {}).items() if not k.startswith('_')}
|
||||
return f"- `{name}`({json.dumps(args, ensure_ascii=False)[:200]})"
|
||||
|
||||
|
||||
def _build_step_detail(resp, tool_calls):
|
||||
"""从 LLM response + tool_calls 组装单步展开详情(纯函数)。"""
|
||||
parts = []
|
||||
thinking = (getattr(resp, 'thinking', '') or '').strip() if resp else ''
|
||||
if thinking:
|
||||
parts.append(f"### 💭 Thinking\n{thinking}")
|
||||
if tool_calls:
|
||||
parts.append("### 🛠 Tool Calls\n" + "\n".join(_fmt_tool_call(tc) for tc in tool_calls))
|
||||
content = _display_text((getattr(resp, 'content', '') or '')).strip() if resp else ''
|
||||
if content and content != '...':
|
||||
parts.append(f"### 📝 Output\n{content}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
class _TaskCard:
|
||||
"""飞书任务卡片:单卡片持续 patch;每步一个独立折叠面板(header 显示 summary,展开看详情)。"""
|
||||
_DETAIL_LIMIT = 8000
|
||||
_FINAL_LIMIT = 6000
|
||||
|
||||
def __init__(self, receive_id, rid_type):
|
||||
self.rid, self.rtype = receive_id, rid_type
|
||||
self.steps = [] # [(summary, detail), ...]
|
||||
self.status = "🤔 思考中..."
|
||||
self.final = None
|
||||
self.msg_id = None
|
||||
self.page_no = 1
|
||||
self.turn_no = 0
|
||||
self.turn_base = 1
|
||||
self.note = None
|
||||
|
||||
def _step_panel(self, idx, summary, detail):
|
||||
detail = detail or "_(无输出)_"
|
||||
if len(detail) > self._DETAIL_LIMIT:
|
||||
detail = detail[:self._DETAIL_LIMIT] + f"\n\n…(已截断,共 {len(detail)} 字符)"
|
||||
return {
|
||||
"tag": "collapsible_panel", "expanded": False,
|
||||
"header": {"title": {"tag": "plain_text", "content": f"Turn {idx} · {summary}"}},
|
||||
"elements": [{"tag": "markdown", "content": detail}],
|
||||
}
|
||||
|
||||
def _build(self):
|
||||
header = f"**{self.status}**"
|
||||
if self.page_no > 1:
|
||||
header += f"\n\n📄 工作卡片 {self.page_no}"
|
||||
els = [{"tag": "markdown", "content": header}]
|
||||
if self.note:
|
||||
els.append({"tag": "markdown", "content": self.note})
|
||||
for i, (s, d) in enumerate(self.steps, self.turn_base):
|
||||
els.append(self._step_panel(i, s, d))
|
||||
if self.final:
|
||||
els += [{"tag": "hr"}, {"tag": "markdown", "content": self.final}]
|
||||
return _card_raw(els)
|
||||
|
||||
def _push(self):
|
||||
card = self._build()
|
||||
if self.msg_id:
|
||||
return _patch_card_result(self.msg_id, card)
|
||||
else:
|
||||
self.msg_id = _send_raw(self.rid, card, "interactive", self.rtype)
|
||||
return bool(self.msg_id), False
|
||||
|
||||
def _rollover(self):
|
||||
self.page_no += 1
|
||||
self.msg_id = None
|
||||
self.final = None
|
||||
self.note = "⚠️ 上一张工作卡片达到飞书限制,本页继续展示后续进展。"
|
||||
|
||||
# ── 公开接口 ──
|
||||
|
||||
def start(self):
|
||||
self._push()
|
||||
|
||||
def step(self, summary, detail=""):
|
||||
self.turn_no += 1
|
||||
step = (summary, detail)
|
||||
self.steps.append(step)
|
||||
self.status = f"⏳ 工作中 · Turn {self.turn_no}"
|
||||
ok, limit = self._push()
|
||||
if limit:
|
||||
self.steps.pop()
|
||||
self._rollover()
|
||||
self.turn_base = self.turn_no
|
||||
self.steps = [step]
|
||||
self._push()
|
||||
|
||||
def done(self, text):
|
||||
self.status = "✅ 已完成"
|
||||
self.final = (text or "_(无文本输出)_")[:self._FINAL_LIMIT]
|
||||
ok, limit = self._push()
|
||||
if limit:
|
||||
self._rollover()
|
||||
self.steps = []
|
||||
self.turn_base = self.turn_no + 1
|
||||
self.final = (text or "_(无文本输出)_")[:self._FINAL_LIMIT]
|
||||
self._push()
|
||||
|
||||
def fail(self, msg):
|
||||
self.status = f"❌ {msg}"
|
||||
self._push()
|
||||
|
||||
|
||||
def _make_task_hook(card, done_event, on_final):
|
||||
"""飞书任务 hook:每轮 patch 卡片状态;结束触发 on_final(raw) 处理附件。"""
|
||||
def hook(ctx):
|
||||
try:
|
||||
if ctx.get('exit_reason'):
|
||||
resp = ctx.get('response')
|
||||
raw = resp.content if hasattr(resp, 'content') else str(resp)
|
||||
card.done(_display_text(raw))
|
||||
on_final(raw)
|
||||
done_event.set()
|
||||
elif ctx.get('summary'):
|
||||
detail = _build_step_detail(ctx.get('response'), ctx.get('tool_calls') or [])
|
||||
card.step(ctx['summary'], detail)
|
||||
except Exception as e:
|
||||
print(f"[fs hook] error: {e}")
|
||||
return hook
|
||||
|
||||
|
||||
def handle_message(data):
|
||||
event, message, sender = data.event, data.event.message, data.event.sender
|
||||
open_id = sender.sender_id.open_id
|
||||
chat_id = message.chat_id
|
||||
if not PUBLIC_ACCESS and open_id not in ALLOWED_USERS:
|
||||
print(f"未授权用户: {open_id}")
|
||||
return
|
||||
user_input, image_paths = _build_user_message(message)
|
||||
if not user_input:
|
||||
if chat_id:
|
||||
send_message(chat_id, f"⚠️ 暂不支持处理此类飞书消息:{message.message_type}", receive_id_type="chat_id")
|
||||
else:
|
||||
send_message(open_id, f"⚠️ 暂不支持处理此类飞书消息:{message.message_type}")
|
||||
return
|
||||
print(f"收到消息 [{open_id}] ({message.message_type}, {len(image_paths)} images): {user_input[:200]}")
|
||||
if message.message_type == "text" and user_input.startswith("/"):
|
||||
return handle_command(open_id, user_input, chat_id)
|
||||
|
||||
def run_agent():
|
||||
user_tasks[open_id] = {"running": True}
|
||||
receive_id = chat_id or open_id
|
||||
rid_type = "chat_id" if chat_id else "open_id"
|
||||
done_event = threading.Event()
|
||||
hook_key = f"fs_{open_id}"
|
||||
card = _TaskCard(receive_id, rid_type)
|
||||
card.start()
|
||||
on_final = lambda raw: _send_generated_files(receive_id, raw, receive_id_type=rid_type)
|
||||
if not hasattr(agent, '_turn_end_hooks'): agent._turn_end_hooks = {}
|
||||
agent._turn_end_hooks[hook_key] = _make_task_hook(card, done_event, on_final)
|
||||
try:
|
||||
agent.put_task(user_input, source="feishu", images=image_paths)
|
||||
start = time.time()
|
||||
while not done_event.wait(timeout=3):
|
||||
if not user_tasks.get(open_id, {}).get("running", True):
|
||||
agent.abort()
|
||||
card.fail("已停止")
|
||||
break
|
||||
if time.time() - start > AGENT_TIMEOUT_SEC:
|
||||
agent.abort()
|
||||
card.fail("任务超时")
|
||||
break
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
card.fail(f"错误: {e}")
|
||||
finally:
|
||||
agent._turn_end_hooks.pop(hook_key, None)
|
||||
user_tasks.pop(open_id, None)
|
||||
|
||||
threading.Thread(target=run_agent, daemon=True).start()
|
||||
|
||||
|
||||
def handle_command(open_id, cmd, chat_id=None):
|
||||
def _send_cmd_response(content):
|
||||
if chat_id:
|
||||
send_message(chat_id, content, receive_id_type="chat_id")
|
||||
else:
|
||||
send_message(open_id, content)
|
||||
parts = (cmd or "").split()
|
||||
op = (parts[0] if parts else "").lower()
|
||||
if op == "/stop":
|
||||
if open_id in user_tasks:
|
||||
user_tasks[open_id]["running"] = False
|
||||
agent.abort()
|
||||
_send_cmd_response("正在停止...")
|
||||
elif op == "/new":
|
||||
_send_cmd_response(reset_conversation(agent))
|
||||
elif op == "/help":
|
||||
_send_cmd_response("命令列表:\n/stop - 停止当前任务\n/status - 查看状态\n/llm - 查看当前模型列表\n/llm [n] - 切换到第 n 个模型\n/restore - 恢复上次对话历史\n/continue - 列出可恢复会话\n/continue [n] - 恢复第 n 个会话\n/new - 开启新对话并清空当前上下文\n/help - 显示帮助")
|
||||
elif op == "/status":
|
||||
llm = agent.get_llm_name() if agent.llmclient else "未配置"
|
||||
_send_cmd_response(f"状态: {'🔴 运行中' if agent.is_running else '🟢 空闲'}\nLLM: [{agent.llm_no}] {llm}")
|
||||
elif op == "/llm":
|
||||
if not agent.llmclient:
|
||||
return _send_cmd_response("❌ 当前没有可用的 LLM 配置")
|
||||
if len(parts) > 1:
|
||||
try:
|
||||
agent.next_llm(int(parts[1]))
|
||||
return _send_cmd_response(f"✅ 已切换到 [{agent.llm_no}] {agent.get_llm_name()}")
|
||||
except Exception:
|
||||
return _send_cmd_response(f"用法: /llm <0-{len(agent.list_llms()) - 1}>")
|
||||
lines = [f"{'→' if cur else ' '} [{i}] {name}" for i, name, cur in agent.list_llms()]
|
||||
_send_cmd_response("LLMs:\n" + "\n".join(lines))
|
||||
elif op == "/restore":
|
||||
try:
|
||||
restored_info, err = format_restore()
|
||||
if err:
|
||||
return _send_cmd_response(err.replace("❌ ", ""))
|
||||
restored, fname, count = restored_info
|
||||
agent.history.extend(restored)
|
||||
agent.abort()
|
||||
_send_cmd_response(f"已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)")
|
||||
except Exception as e:
|
||||
_send_cmd_response(f"恢复失败: {e}")
|
||||
elif op == "/continue" or cmd.startswith("/continue"):
|
||||
_send_cmd_response(handle_continue_frontend(agent, cmd))
|
||||
else:
|
||||
_send_cmd_response(f"未知命令: {cmd}")
|
||||
|
||||
|
||||
def main():
|
||||
global client
|
||||
if not APP_ID or not APP_SECRET:
|
||||
print("错误: 请在 mykey.py 或 mykey.json 中配置 fs_app_id 和 fs_app_secret")
|
||||
sys.exit(1)
|
||||
client = create_client()
|
||||
handler = lark.EventDispatcherHandler.builder("", "").register_p2_im_message_receive_v1(handle_message).build()
|
||||
print("=" * 50 + "\n飞书 Agent 已启动(长连接模式)\n" + f"App ID: {APP_ID}\n等待消息...\n" + "=" * 50)
|
||||
retry_delay = 5
|
||||
while True:
|
||||
try:
|
||||
cli = lark.ws.Client(APP_ID, APP_SECRET, event_handler=handler, log_level=lark.LogLevel.INFO)
|
||||
cli.start()
|
||||
except Exception as e:
|
||||
print(f"[WARN] 飞书长连接断开或启动失败: {e}")
|
||||
print(f"[INFO] {retry_delay}s 后重连...")
|
||||
time.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, 120)
|
||||
# 重连时刷新 client
|
||||
try:
|
||||
client = create_client()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,375 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Must run BEFORE importing agentmain — it reconfigures stdout at import time,
|
||||
# and its submodules may print() during init. We capture the raw binary stdout
|
||||
# for ACP JSON-RPC, then redirect the text-mode stdout to stderr so any stray
|
||||
# prints from agentmain/llmcore don't pollute the ACP channel.
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
_stdout_fd = os.dup(sys.__stdout__.fileno())
|
||||
msvcrt.setmode(_stdout_fd, os.O_BINARY)
|
||||
_acp_stdout = os.fdopen(_stdout_fd, "wb", buffering=0)
|
||||
msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
|
||||
# Mark the ACP fd as non-inheritable so child processes can't write to it.
|
||||
os.set_inheritable(_stdout_fd, False)
|
||||
# Redirect the original stdout fd to stderr so child processes
|
||||
# (tool calls) don't write into the ACP JSON-RPC channel.
|
||||
os.dup2(sys.stderr.fileno(), sys.__stdout__.fileno())
|
||||
else:
|
||||
_stdout_fd = os.dup(sys.__stdout__.fileno())
|
||||
os.set_inheritable(_stdout_fd, False)
|
||||
_acp_stdout = os.fdopen(_stdout_fd, "wb", buffering=0)
|
||||
os.dup2(sys.stderr.fileno(), sys.__stdout__.fileno())
|
||||
|
||||
|
||||
class _StdoutToStderrRouter(io.TextIOBase):
|
||||
"""Redirect text-mode stdout to stderr so agentmain prints don't leak."""
|
||||
def writable(self): return True
|
||||
def write(self, s):
|
||||
if s:
|
||||
sys.stderr.write(s)
|
||||
sys.stderr.flush()
|
||||
return len(s) if s else 0
|
||||
def flush(self): sys.stderr.flush()
|
||||
|
||||
sys.stdout = _StdoutToStderrRouter()
|
||||
|
||||
import argparse
|
||||
import queue
|
||||
import threading
|
||||
import traceback
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agentmain import GeneraticAgent
|
||||
|
||||
|
||||
JSONRPC_VERSION = "2.0"
|
||||
ACP_PROTOCOL_VERSION = 1
|
||||
|
||||
|
||||
def eprint(*args: Any) -> None:
|
||||
print(*args, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def make_text_block(text: str) -> Dict[str, Any]:
|
||||
return {"type": "text", "text": text}
|
||||
|
||||
|
||||
def make_session_update(session_id: str, update: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"jsonrpc": JSONRPC_VERSION,
|
||||
"method": "session/update",
|
||||
"params": {"sessionId": session_id, "update": update},
|
||||
}
|
||||
|
||||
|
||||
def compact_json(obj: Dict[str, Any]) -> str:
|
||||
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def parse_jsonrpc_line(line: str) -> Optional[Dict[str, Any]]:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
try:
|
||||
obj = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return obj if isinstance(obj, dict) else None
|
||||
|
||||
|
||||
def content_blocks_to_text(blocks: List[Dict[str, Any]]) -> str:
|
||||
parts: List[str] = []
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
block_type = block.get("type")
|
||||
if block_type == "text":
|
||||
text = block.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
parts.append(text)
|
||||
elif block_type == "resource_link":
|
||||
name = block.get("name") or "resource"
|
||||
uri = block.get("uri") or ""
|
||||
desc = block.get("description") or ""
|
||||
parts.append(f"[ResourceLink] {name}: {uri}\n{desc}".strip())
|
||||
elif block_type == "resource":
|
||||
uri = block.get("uri") or "resource"
|
||||
text = block.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
parts.append(f"[Resource] {uri}\n{text}")
|
||||
else:
|
||||
parts.append(f"[Resource] {uri}")
|
||||
elif block_type == "image":
|
||||
uri = block.get("uri") or "inline-image"
|
||||
parts.append(f"[Image omitted] {uri}")
|
||||
else:
|
||||
parts.append(f"[Unsupported content block: {block_type}]")
|
||||
return "\n\n".join(p for p in parts if p).strip()
|
||||
|
||||
|
||||
def jsonrpc_error(code: int, message: str, req_id: Any = None, data: Any = None) -> Dict[str, Any]:
|
||||
err: Dict[str, Any] = {"code": code, "message": message}
|
||||
if data is not None:
|
||||
err["data"] = data
|
||||
return {"jsonrpc": JSONRPC_VERSION, "id": req_id, "error": err}
|
||||
|
||||
|
||||
def jsonrpc_result(req_id: Any, result: Any) -> Dict[str, Any]:
|
||||
return {"jsonrpc": JSONRPC_VERSION, "id": req_id, "result": result}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionState:
|
||||
session_id: str
|
||||
cwd: str
|
||||
agent: GeneraticAgent
|
||||
current_prompt_id: Any = None
|
||||
prompt_lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
|
||||
|
||||
class GenericAgentAcpBridge:
|
||||
def __init__(self, llm_no: int = 0):
|
||||
self.llm_no = llm_no
|
||||
self._json_out = _acp_stdout
|
||||
self._write_lock = threading.Lock()
|
||||
self._sessions: Dict[str, SessionState] = {}
|
||||
self._shutdown = False
|
||||
|
||||
def write_message(self, msg: Dict[str, Any]) -> None:
|
||||
payload = compact_json(msg)
|
||||
raw = (payload + "\n").encode("utf-8")
|
||||
method = msg.get("method", msg.get("id", "?"))
|
||||
eprint(f"[ACP-BRIDGE] >>> {payload[:500]}")
|
||||
try:
|
||||
with self._write_lock:
|
||||
self._json_out.write(raw)
|
||||
self._json_out.flush()
|
||||
except Exception as e:
|
||||
eprint(f"[ACP-BRIDGE] WRITE FAILED: {type(e).__name__}: {e}")
|
||||
|
||||
def new_agent(self) -> GeneraticAgent:
|
||||
agent = GeneraticAgent()
|
||||
agent.next_llm(self.llm_no)
|
||||
agent.verbose = True
|
||||
agent.inc_out = True
|
||||
threading.Thread(target=agent.run, daemon=True).start()
|
||||
return agent
|
||||
|
||||
def handle_initialize(self, req_id: Any, params: Dict[str, Any]) -> None:
|
||||
requested_version = params.get("protocolVersion", ACP_PROTOCOL_VERSION)
|
||||
version = ACP_PROTOCOL_VERSION if requested_version == ACP_PROTOCOL_VERSION else ACP_PROTOCOL_VERSION
|
||||
result = {
|
||||
"protocolVersion": version,
|
||||
"agentCapabilities": {
|
||||
"loadSession": False,
|
||||
"mcpCapabilities": {"http": False, "sse": False},
|
||||
"promptCapabilities": {
|
||||
"image": False,
|
||||
"audio": False,
|
||||
"embeddedContext": False,
|
||||
},
|
||||
"sessionCapabilities": {},
|
||||
},
|
||||
"agentInfo": {
|
||||
"name": "genericagent-acp",
|
||||
"title": "GenericAgent",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
"authMethods": [],
|
||||
}
|
||||
self.write_message(jsonrpc_result(req_id, result))
|
||||
|
||||
def handle_session_new(self, req_id: Any, params: Dict[str, Any]) -> None:
|
||||
cwd = params.get("cwd")
|
||||
if not isinstance(cwd, str) or not cwd:
|
||||
self.write_message(jsonrpc_error(-32602, "cwd is required", req_id))
|
||||
return
|
||||
if not os.path.isabs(cwd):
|
||||
cwd = os.path.abspath(cwd)
|
||||
session_id = f"ga_{uuid.uuid4().hex}"
|
||||
agent = self.new_agent()
|
||||
session = SessionState(session_id=session_id, cwd=cwd, agent=agent)
|
||||
self._sessions[session_id] = session
|
||||
self.write_message(
|
||||
jsonrpc_result(
|
||||
req_id,
|
||||
{
|
||||
"sessionId": session_id,
|
||||
"modes": None,
|
||||
"configOptions": None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def handle_session_prompt(self, req_id: Any, params: Dict[str, Any]) -> None:
|
||||
session_id = params.get("sessionId")
|
||||
prompt_blocks = params.get("prompt")
|
||||
session = self._sessions.get(session_id)
|
||||
if session is None:
|
||||
self.write_message(jsonrpc_error(-32602, "unknown sessionId", req_id))
|
||||
return
|
||||
if not isinstance(prompt_blocks, list):
|
||||
self.write_message(jsonrpc_error(-32602, "prompt must be an array", req_id))
|
||||
return
|
||||
prompt_text = content_blocks_to_text(prompt_blocks)
|
||||
if not prompt_text:
|
||||
self.write_message(jsonrpc_error(-32602, "prompt must contain text or supported content", req_id))
|
||||
return
|
||||
|
||||
with session.prompt_lock:
|
||||
if session.current_prompt_id is not None:
|
||||
self.write_message(
|
||||
jsonrpc_error(-32603, "session already has an active prompt", req_id)
|
||||
)
|
||||
return
|
||||
session.current_prompt_id = req_id
|
||||
|
||||
def run_prompt() -> None:
|
||||
stop_reason = "end_turn"
|
||||
try:
|
||||
dq = session.agent.put_task(prompt_text, source="acp")
|
||||
self._drain_agent_queue(session, dq)
|
||||
except Exception as exc:
|
||||
stop_reason = "end_turn"
|
||||
self.write_message(
|
||||
make_session_update(
|
||||
session.session_id,
|
||||
{
|
||||
"sessionUpdate": "agent_message_chunk",
|
||||
"content": make_text_block(
|
||||
f"[Bridge error] {type(exc).__name__}: {exc}"
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
eprint("[GenericAgent ACP] prompt thread failed:", traceback.format_exc())
|
||||
finally:
|
||||
with session.prompt_lock:
|
||||
finished_req_id = session.current_prompt_id
|
||||
session.current_prompt_id = None
|
||||
if finished_req_id is not None:
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
self.write_message(
|
||||
jsonrpc_result(finished_req_id, {"stopReason": stop_reason})
|
||||
)
|
||||
|
||||
threading.Thread(target=run_prompt, daemon=True).start()
|
||||
|
||||
def _drain_agent_queue(self, session: SessionState, dq: "queue.Queue[Dict[str, Any]]") -> None:
|
||||
sent_any = False
|
||||
while True:
|
||||
item = dq.get()
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
# With inc_out=True, "next" items are already incremental deltas.
|
||||
if "next" in item and "done" not in item:
|
||||
delta = item["next"]
|
||||
if isinstance(delta, str) and delta:
|
||||
sent_any = True
|
||||
try:
|
||||
self.write_message(
|
||||
make_session_update(
|
||||
session.session_id,
|
||||
{
|
||||
"sessionUpdate": "agent_message_chunk",
|
||||
"content": make_text_block(delta),
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
eprint(f"[ACP-BRIDGE] ERROR writing update: {e}")
|
||||
if "done" in item:
|
||||
# "done" text has post-processing (</summary>\n\n insertion)
|
||||
# that shifts offsets — cannot safely compute a tail delta.
|
||||
# Only use "done" content if nothing was streamed (error case).
|
||||
if not sent_any:
|
||||
done_text = item["done"]
|
||||
if isinstance(done_text, str) and done_text:
|
||||
try:
|
||||
self.write_message(
|
||||
make_session_update(
|
||||
session.session_id,
|
||||
{
|
||||
"sessionUpdate": "agent_message_chunk",
|
||||
"content": make_text_block(done_text),
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
eprint(f"[ACP-BRIDGE] ERROR writing done: {e}")
|
||||
break
|
||||
|
||||
def handle_session_cancel(self, params: Dict[str, Any]) -> None:
|
||||
session_id = params.get("sessionId")
|
||||
session = self._sessions.get(session_id)
|
||||
if session is None:
|
||||
return
|
||||
if session.current_prompt_id is not None:
|
||||
session.agent.abort()
|
||||
|
||||
def handle_message(self, msg: Dict[str, Any]) -> None:
|
||||
method = msg.get("method")
|
||||
req_id = msg.get("id")
|
||||
params = msg.get("params") or {}
|
||||
|
||||
try:
|
||||
if method == "initialize":
|
||||
self.handle_initialize(req_id, params)
|
||||
elif method == "session/new":
|
||||
self.handle_session_new(req_id, params)
|
||||
elif method == "session/prompt":
|
||||
self.handle_session_prompt(req_id, params)
|
||||
elif method == "session/cancel":
|
||||
self.handle_session_cancel(params)
|
||||
elif method == "session/load":
|
||||
self.write_message(jsonrpc_error(-32601, "session/load not supported", req_id))
|
||||
elif method == "session/list":
|
||||
self.write_message(jsonrpc_error(-32601, "session/list not supported", req_id))
|
||||
elif method == "session/close":
|
||||
self.write_message(jsonrpc_result(req_id, {}))
|
||||
elif method is None:
|
||||
if req_id is not None:
|
||||
self.write_message(jsonrpc_error(-32600, "invalid request", req_id))
|
||||
else:
|
||||
if req_id is not None:
|
||||
self.write_message(jsonrpc_error(-32601, f"method not found: {method}", req_id))
|
||||
except Exception as exc:
|
||||
eprint("[GenericAgent ACP] request handler failed:", traceback.format_exc())
|
||||
if req_id is not None:
|
||||
self.write_message(
|
||||
jsonrpc_error(-32603, f"internal error: {type(exc).__name__}: {exc}", req_id)
|
||||
)
|
||||
|
||||
def serve(self) -> None:
|
||||
eprint("[GenericAgent ACP] bridge started")
|
||||
stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace") if hasattr(sys.stdin, 'buffer') else sys.stdin
|
||||
for raw_line in stdin:
|
||||
msg = parse_jsonrpc_line(raw_line)
|
||||
if msg is None:
|
||||
continue
|
||||
self.handle_message(msg)
|
||||
if self._shutdown:
|
||||
break
|
||||
eprint("[GenericAgent ACP] bridge stopped")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="GenericAgent ACP bridge over stdio")
|
||||
parser.add_argument("--llm-no", type=int, default=0, help="LLM index for GenericAgent")
|
||||
args = parser.parse_args()
|
||||
bridge = GenericAgentAcpBridge(llm_no=args.llm_no)
|
||||
bridge.serve()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,126 @@
|
||||
import asyncio, os, sys, threading, time
|
||||
from collections import deque
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from agentmain import GeneraticAgent
|
||||
from chatapp_common import AgentChatMixin, ensure_single_instance, public_access, redirect_log, require_runtime, split_text
|
||||
from llmcore import mykeys
|
||||
|
||||
try:
|
||||
import botpy
|
||||
from botpy.message import C2CMessage, GroupMessage
|
||||
except Exception:
|
||||
print("Please install qq-botpy to use QQ module: pip install qq-botpy")
|
||||
sys.exit(1)
|
||||
|
||||
agent = GeneraticAgent(); agent.verbose = False
|
||||
APP_ID = str(mykeys.get("qq_app_id", "") or "").strip()
|
||||
APP_SECRET = str(mykeys.get("qq_app_secret", "") or "").strip()
|
||||
ALLOWED = {str(x).strip() for x in mykeys.get("qq_allowed_users", []) if str(x).strip()}
|
||||
PROCESSED_IDS, USER_TASKS = deque(maxlen=1000), {}
|
||||
SEQ_LOCK, MSG_SEQ = threading.Lock(), 1
|
||||
|
||||
|
||||
def _next_msg_seq():
|
||||
global MSG_SEQ
|
||||
with SEQ_LOCK:
|
||||
MSG_SEQ += 1
|
||||
return MSG_SEQ
|
||||
|
||||
|
||||
def _build_intents():
|
||||
try:
|
||||
return botpy.Intents(public_messages=True, direct_message=True)
|
||||
except Exception:
|
||||
intents = botpy.Intents.none() if hasattr(botpy.Intents, "none") else botpy.Intents()
|
||||
for attr in ("public_messages", "public_guild_messages", "direct_message", "direct_messages", "c2c_message", "c2c_messages", "group_at_message", "group_at_messages"):
|
||||
if hasattr(intents, attr):
|
||||
try:
|
||||
setattr(intents, attr, True)
|
||||
except Exception:
|
||||
pass
|
||||
return intents
|
||||
|
||||
|
||||
def _make_bot_class(app):
|
||||
class QQBot(botpy.Client):
|
||||
def __init__(self):
|
||||
super().__init__(intents=_build_intents(), ext_handlers=False)
|
||||
|
||||
async def on_ready(self):
|
||||
print(f"[QQ] bot ready: {getattr(getattr(self, 'robot', None), 'name', 'QQBot')}")
|
||||
|
||||
async def on_c2c_message_create(self, message: C2CMessage):
|
||||
await app.on_message(message, is_group=False)
|
||||
|
||||
async def on_group_at_message_create(self, message: GroupMessage):
|
||||
await app.on_message(message, is_group=True)
|
||||
|
||||
async def on_direct_message_create(self, message):
|
||||
await app.on_message(message, is_group=False)
|
||||
|
||||
return QQBot
|
||||
|
||||
|
||||
class QQApp(AgentChatMixin):
|
||||
label, source, split_limit = "QQ", "qq", 1500
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(agent, USER_TASKS)
|
||||
self.client = None
|
||||
|
||||
async def send_text(self, chat_id, content, *, msg_id=None, is_group=False):
|
||||
if not self.client:
|
||||
return
|
||||
api = self.client.api.post_group_message if is_group else self.client.api.post_c2c_message
|
||||
key = "group_openid" if is_group else "openid"
|
||||
for part in split_text(content, self.split_limit):
|
||||
await api(**{key: chat_id, "msg_type": 0, "content": part, "msg_id": msg_id, "msg_seq": _next_msg_seq()})
|
||||
|
||||
async def on_message(self, data, is_group=False):
|
||||
try:
|
||||
msg_id = getattr(data, "id", None)
|
||||
if msg_id in PROCESSED_IDS:
|
||||
return
|
||||
PROCESSED_IDS.append(msg_id)
|
||||
content = (getattr(data, "content", "") or "").strip()
|
||||
if not content:
|
||||
return
|
||||
author = getattr(data, "author", None)
|
||||
user_id = str(getattr(author, "member_openid" if is_group else "user_openid", "") or getattr(author, "id", "") or "unknown")
|
||||
chat_id = str(getattr(data, "group_openid", "") or user_id) if is_group else user_id
|
||||
if not public_access(ALLOWED) and user_id not in ALLOWED:
|
||||
print(f"[QQ] unauthorized user: {user_id}")
|
||||
return
|
||||
print(f"[QQ] message from {user_id} ({'group' if is_group else 'c2c'}): {content}")
|
||||
if content.startswith("/"):
|
||||
return await self.handle_command(chat_id, content, msg_id=msg_id, is_group=is_group)
|
||||
asyncio.create_task(self.run_agent(chat_id, content, msg_id=msg_id, is_group=is_group))
|
||||
except Exception:
|
||||
import traceback
|
||||
print("[QQ] handle_message error")
|
||||
traceback.print_exc()
|
||||
|
||||
async def start(self):
|
||||
self.client = _make_bot_class(self)()
|
||||
delay, max_delay = 5, 300
|
||||
while True:
|
||||
started_at = time.monotonic()
|
||||
try:
|
||||
print(f"[QQ] bot starting... {time.strftime('%m-%d %H:%M')}")
|
||||
await self.client.start(appid=APP_ID, secret=APP_SECRET)
|
||||
except Exception as e:
|
||||
print(f"[QQ] bot error: {e}")
|
||||
if time.monotonic() - started_at >= 60:
|
||||
delay = 5
|
||||
print(f"[QQ] reconnect in {delay}s...")
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, max_delay)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_LOCK_SOCK = ensure_single_instance(19528, "QQ")
|
||||
require_runtime(agent, "QQ", qq_app_id=APP_ID, qq_app_secret=APP_SECRET)
|
||||
redirect_log(__file__, "qqapp.log", "QQ", ALLOWED)
|
||||
threading.Thread(target=agent.run, daemon=True).start()
|
||||
asyncio.run(QQApp().start())
|
||||
|
After Width: | Height: | Size: 8.9 KiB |
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "Boy",
|
||||
"version": "1.0.0",
|
||||
"author": "pzuh",
|
||||
"source": "https://pzuh.itch.io/temple-run-game-sprites",
|
||||
"description": "Boy 角色皮肤",
|
||||
"style": "pixel",
|
||||
"format": "sprite",
|
||||
"size": {
|
||||
"width": 80,
|
||||
"height": 122
|
||||
},
|
||||
"animations": {
|
||||
"idle": {
|
||||
"file": "skin.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 64,
|
||||
"frameHeight": 98,
|
||||
"frameCount": 10,
|
||||
"columns": 40,
|
||||
"fps": 6,
|
||||
"startFrame": 0
|
||||
}
|
||||
},
|
||||
"walk": {
|
||||
"file": "skin.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 64,
|
||||
"frameHeight": 98,
|
||||
"frameCount": 10,
|
||||
"columns": 40,
|
||||
"fps": 3,
|
||||
"startFrame": 20
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"file": "skin.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 64,
|
||||
"frameHeight": 98,
|
||||
"frameCount": 10,
|
||||
"columns": 40,
|
||||
"fps": 10,
|
||||
"startFrame": 20
|
||||
}
|
||||
},
|
||||
"sprint": {
|
||||
"file": "skin.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 64,
|
||||
"frameHeight": 98,
|
||||
"frameCount": 10,
|
||||
"columns": 40,
|
||||
"fps": 24,
|
||||
"startFrame": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 871 B |
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "Dinosaur",
|
||||
"version": "1.0.0",
|
||||
"author": "voidcord54",
|
||||
"source": "https://voidcord54.itch.io/",
|
||||
"description": "像素风小恐龙 Dinosaur",
|
||||
"style": "pixel",
|
||||
"format": "sprite",
|
||||
"size": { "width": 128, "height": 128 },
|
||||
"animations": {
|
||||
"idle": {
|
||||
"file": "skin.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 128,
|
||||
"frameHeight": 128,
|
||||
"frameCount": 2,
|
||||
"columns": 5,
|
||||
"fps": 6,
|
||||
"startFrame": 0
|
||||
}
|
||||
},
|
||||
"walk": {
|
||||
"file": "skin.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 128,
|
||||
"frameHeight": 128,
|
||||
"frameCount": 2,
|
||||
"columns": 5,
|
||||
"fps": 4,
|
||||
"startFrame": 2
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"file": "skin.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 128,
|
||||
"frameHeight": 128,
|
||||
"frameCount": 2,
|
||||
"columns": 5,
|
||||
"fps": 8,
|
||||
"startFrame": 2
|
||||
}
|
||||
},
|
||||
"sprint": {
|
||||
"file": "skin.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 128,
|
||||
"frameHeight": 128,
|
||||
"frameCount": 2,
|
||||
"columns": 5,
|
||||
"fps": 16,
|
||||
"startFrame": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 568 B |
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "Doux",
|
||||
"version": "1.0.0",
|
||||
"author": "arks",
|
||||
"source": "https://arks.itch.io/dino-characters",
|
||||
"license": "CC0",
|
||||
"description": "像素风小恐龙 Doux",
|
||||
"style": "pixel",
|
||||
"format": "sprite",
|
||||
"size": { "width": 128, "height": 128 },
|
||||
"animations": {
|
||||
"idle": {
|
||||
"file": "skin.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 24,
|
||||
"frameHeight": 24,
|
||||
"frameCount": 4,
|
||||
"columns": 24,
|
||||
"fps": 6,
|
||||
"startFrame": 0
|
||||
}
|
||||
},
|
||||
"walk": {
|
||||
"file": "skin.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 24,
|
||||
"frameHeight": 24,
|
||||
"frameCount": 6,
|
||||
"columns": 24,
|
||||
"fps": 6,
|
||||
"startFrame": 5
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"file": "skin.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 24,
|
||||
"frameHeight": 24,
|
||||
"frameCount": 8,
|
||||
"columns": 24,
|
||||
"fps": 16,
|
||||
"startFrame": 6
|
||||
}
|
||||
},
|
||||
"sprint": {
|
||||
"file": "skin.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 24,
|
||||
"frameHeight": 24,
|
||||
"frameCount": 6,
|
||||
"columns": 24,
|
||||
"fps": 16,
|
||||
"startFrame": 17
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "Glube",
|
||||
"version": "1.0.0",
|
||||
"author": "SketchesWithKevin",
|
||||
"source": "https://sketcheswithkevin.itch.io/glube-platformer",
|
||||
"description": "像素风小怪兽 Glube",
|
||||
"style": "pixel",
|
||||
"format": "sprite",
|
||||
"size": { "width": 65, "height": 38 },
|
||||
"animations": {
|
||||
"idle": {
|
||||
"file": "idle.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 44,
|
||||
"frameHeight": 31,
|
||||
"frameCount": 6,
|
||||
"columns": 6,
|
||||
"fps": 6,
|
||||
"startFrame": 0
|
||||
}
|
||||
},
|
||||
"walk": {
|
||||
"file": "walk.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 65,
|
||||
"frameHeight": 32,
|
||||
"frameCount": 8,
|
||||
"columns": 8,
|
||||
"fps": 6,
|
||||
"startFrame": 0
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"file": "run.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 65,
|
||||
"frameHeight": 32,
|
||||
"frameCount": 8,
|
||||
"columns": 8,
|
||||
"fps": 12,
|
||||
"startFrame": 0
|
||||
}
|
||||
},
|
||||
"sprint": {
|
||||
"file": "run.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 65,
|
||||
"frameHeight": 32,
|
||||
"frameCount": 8,
|
||||
"columns": 8,
|
||||
"fps": 24,
|
||||
"startFrame": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,6 @@
|
||||
License is CC0 - https://creativecommons.org/public-domain/cc0/
|
||||
|
||||
YOU CAN:
|
||||
|
||||
-> You can do whatever you want with this asset, including modifying it for commercial use.
|
||||
-> Credit is not required, but is greatly appreciated!
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "Line",
|
||||
"version": "1.0.0",
|
||||
"author": "itch.io",
|
||||
"source": "https://itch.io",
|
||||
"description": "Line 角色皮肤",
|
||||
"style": "pixel",
|
||||
"format": "sprite",
|
||||
"size": { "width": 128, "height": 128 },
|
||||
"animations": {
|
||||
"idle": {
|
||||
"file": "skin.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 156,
|
||||
"frameHeight": 185,
|
||||
"frameCount": 4,
|
||||
"columns": 28,
|
||||
"fps": 6,
|
||||
"startFrame": 0
|
||||
}
|
||||
},
|
||||
"walk": {
|
||||
"file": "skin.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 156,
|
||||
"frameHeight": 185,
|
||||
"frameCount": 8,
|
||||
"columns": 28,
|
||||
"fps": 6,
|
||||
"startFrame": 4
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"file": "skin.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 156,
|
||||
"frameHeight": 185,
|
||||
"frameCount": 8,
|
||||
"columns": 28,
|
||||
"fps": 10,
|
||||
"startFrame": 12
|
||||
}
|
||||
},
|
||||
"sprint": {
|
||||
"file": "skin.png",
|
||||
"loop": true,
|
||||
"sprite": {
|
||||
"frameWidth": 156,
|
||||
"frameHeight": 185,
|
||||
"frameCount": 8,
|
||||
"columns": 28,
|
||||
"fps": 24,
|
||||
"startFrame": 12
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 567 B |