514 Commits
Author SHA1 Message Date
Liang Jiaqing 3be6ed35ba feat: add Tauri desktop shell + desktop_bridge (url mode, no-console) 2026-05-15 18:29:00 +08:00
nianyucatfish fe47bb29cb feat(tui): click-to-expand folds, streaming spinner, layout polish (#390)
- Per-fold click-to-expand: assistant messages render as a sequence of
  text/fold-header/fold-body widgets; FoldHeader is a clickable Static
  that toggles m._toggled_folds and remounts just that message
- Ctrl+O is authoritative: clears all per-fold overrides so the state
  is uniformly all-collapsed or all-expanded
- Streaming spinner: dedicated tiny Static (`m._spinner_widget`) after
  segment widgets, driven by an independent 100ms timer. Bypasses
  Markdown completely so unclosed code fences / paragraph whitespace
  trimming can't eat it
- Hide `**LLM Running (Turn N) ...**` markers at render time only;
  fold_turns still uses them for splitting
- Ctrl+B sidebar toggle: invalidate width-keyed cache and reuse the
  resize debounce path so the chat reflows reliably after toggling
- #input min-width: 10 — guards Textual TextArea.render_lines against
  `range() arg 3 must not be zero` when sidebar is shown on a very
  narrow window and #main collapses to ≤ 0 cols
2026-05-15 18:19:29 +08:00
Liang Jiaqing c7edd749a9 chore: remove unused tgapp stream segment tests 2026-05-15 17:59:23 +08:00
nianyucatfish 90a3dff557 fix(tui): file paste, block-delete, ctrl+c copy, per-session input (#389)
- Pasting non-image files (.docx/.pdf/.py …) was folded as `[Image #N]`
  because clipboard helper returned any file path. Split into image vs
  file by extension; non-images now fold as `[File #N]` and expand to
  the path on submit, same flow as images.
- Backspace/Delete used to nibble placeholders one char at a time.
  Override `action_delete_left/right`: when the caret sits flush against
  a `[Pasted text #N +X lines]` / `[Image #N]` / `[File #N]` placeholder
  on the matching side, remove the whole span and drop its dict entry.
- Ctrl+C inside the input was clearing the whole field. Root cause:
  `screen.get_selected_text()` doesn't see TextArea's internal selection,
  so the handler fell through to the input-reset branch. Fixed by
  preferring the focused InputArea's `selected_text` before screen-level
  drag selection.
- Input text/history/pastes were globally shared across agent sessions
  (single InputArea widget). Moved the four state slots
  (text/history/pastes/paste_counter) onto `AgentSession`, with a
  `_swap_input_for_session` snapshot/restore on session switch.
2026-05-15 15:38:53 +08:00
Liang Jiaqing fc6b5ad309 refactor: parameterize compress/trim intervals, add stop/kill subagent actions, remove web/pet CLI commands 2026-05-15 12:18:33 +08:00
LJQ ffb268554e Merge pull request #380 from desmonna/main
refactor(langfuse/chatapp):
2026-05-14 23:15:01 +08:00
Liang Jiaqing 1d30b98280 conductor: cleanup style, filter aborted agents, add auto-cleanup thread; llmcore: conditional _drop_unsigned_thinking for non-claude models; task_planning: minor edit 2026-05-14 22:55:50 +08:00
nianyucatfish dce09f8fdd feat&fix(tui): paste/scroll polish, /continue & /export pickers, mac aliases (#379)
* fix(tui): compat _select_state for textual 8.2.6 (was _select_start)

* feat(tui): right-click paste, sticky scroll, /continue & /export pickers, mac aliases

- InputArea 右键粘贴:复用 _insert_paste_text 多行折叠,统一文本/图片/Ctrl+V 三条粘贴路径
- action_paste 兜底通过 tkinter 读 OS 剪贴板,修复 Ctrl+V 在非 bracketed-paste 终端拿不到外部内容
- 消息区 sticky-bottom 跟随:用户上滚自动取消跟随;提交消息或斜杠命令完成后强制 scroll_end
- /continue 无参打开 ChoiceList(上限 20 项,超出标注 [仅显示最近 N/M]);恢复操作通过 call_after_refresh 推迟,避免折叠 widget 引用失效
- /export 选 "file" 改为预填可编辑文件名:/export <timestamp>.md,可改名/改后缀后回车
- macOS 防御性 Cmd 别名:cmd+c / cmd+v / cmd+n / cmd+/ 镜像对应 Ctrl 绑定;on_key disarm 兼容
- 顺手清理:_handlers dict 取代手写 if-elif 链;注释精简;修复 /branch deepcopy crash、/clear+/rewind DOM 未清的 bug;sidebar 末次提问解耦 sess.messages,改读 LLM 端 history 以保持 /clear 后预览不变
2026-05-14 22:48:10 +08:00
desmonna ce11952daf refactor(langfuse/chatapp):
- Fix 1: _patched_log function signature mismatch
  - Location: plugins-langfuse_tracing.py
  - Problem: monkey-patch function only accepts 2 parameters, but the original function has 3 parameters (including the default parameter log_path)
  - Fix: add log_path=None parameter and pass it to the original function
- Fix 2: Agentmain module import failure
  - Location: frontends-chatapp_common.py
  - Problem: frontends directory cannot be imported into the agentmain of the upper directory
  - Fix: Add the parent directory to sys.path at the beginning of the file
2026-05-14 22:46:06 +08:00
AspasZhang 2cb1314b6b docs: update WeChat group 18 QR code (#370) 2026-05-14 12:13:43 +08:00
a60c938e89 refactor(configure): 重构配置向导 — 16 家厂商、启动流程优化、粘贴修复、新增微信平台 (#364)
* feat: add cross-platform GA command entry system

* fix: hide code runner console window

* fix(tgapp): preserve streaming overflow segments (#330)

Keep Telegram streaming overflow messages tied to the primary live message, reuse them across edits, and delete stale overflow replies when streamed content shrinks.

* refactor: rename command/ to ga_cli/ to avoid namespace collision

* feat(tgapp): add /btw command handling (#335)

* fix(launch): skip paste interception when clipboard contains text

PPT/Word copies include both image/png and text/plain items.
Only intercept paste when no text item is present (pure screenshot/image).

* docs: update WeChat group 17 QR code (#338)

* feat(tui): multi-line input with Enter submit, Ctrl+Enter newline, paste-safe

* feat(reflect): add init() for runtime args with fallback to env/config

* feat(tui): add tuiapp_v2.py — refined Textual frontend (#350)

新版 TUI 与 tuiapp.py 并存,共享 GenericAgent 调度/队列/线程协议,
重做视觉与交互:
- 多行 InputArea:≤3 行自适应、>3 行三行滑动窗口、粘贴占位符
- 命令面板(OptionList + 模糊匹配 + Tab 补全)
- /llm、command 选择走 ChoiceList,选完区域收缩
- Markdown HardBreak 渲染 + ANSI 缓存
- 响应式布局(窄屏隐藏侧栏 / 宽屏按比例放大)
- selection-aware 自动滚动(选文本时不抢滚动)
- 新增 /btw、/continue、/export、/restore

不改 cli / hub / README / tuiapp.py 入口。

* docs: add Evaluation — Five Dimensions section (EN & CN) (#339)

* feat: dynamic tool_result maxlen based on parallel tool count

* feat(configure): major rewrite — 16 providers, new startup flow, paste fix

- Expand model providers from 12 to 16 (add Qwen, Stepfun, Tencent,
  Volcengine, Baidu Qianfan, Xiaomi MiMo, Zhipu Coding Plan, GMI)
- Merge duplicate entries (MiniMax/Kimi/Qwen/CRS) with protocol choice
- Add WeChat to messaging platforms (frontends/wechatapp.py existed but
  was not discoverable via configure.py)
- Add coding plan / token plan endpoint selection for applicable providers
- Rework startup flow: no mykey.py → suggest both; has mykey.py → modify/new
- Fix paste race condition in masked_input (batch read + deferred repaint)
- Fix _configure_advanced using provider['type'] instead of cfg['type']
- Fix backup filename containing '/' (invalid on Windows paths)
- Simplify Python version check to recommend 3.12 only
- Add manual model name input option regardless of probe result
- Remove dead code (_read_char, import time)

---------

Co-authored-by: Liang Jiaqing <l.j.q.light@gmail.com>
Co-authored-by: manaskarra <106862611+manaskarra@users.noreply.github.com>
Co-authored-by: YooooEX <me@yooooex.com>
Co-authored-by: AspasZhang <24210980075@m.fudan.edu.cn>
Co-authored-by: nianyucatfish <156216359+nianyucatfish@users.noreply.github.com>
Co-authored-by: Jinyi Han <15224975562@163.com>
2026-05-13 23:10:28 +08:00
nianyucatfish 508d5692c5 Feat/v2 keybindings (#365)
* fix(tui): debounce v2 layout reflow on resize

Same-size resize events are deduped, layout reflow is debounced to 80ms,
and message remount is deferred to the next frame so #messages.content_region
reports the post-resize width instead of the pre-resize one.

* feat(tui): rebind v2 keys (Ctrl+C dual, Ctrl+↑↓ sessions, Ctrl+O fold) + help overlay

- Ctrl+C is the single stop/quit key: stops a running task; otherwise
  clears non-empty input; otherwise arms quit (press again within 2s to
  exit). Removes Ctrl+S, Ctrl+Q, and InputArea's prior Ctrl+C
  copy-or-clear binding. Any non-Ctrl+C key disarms the pending quit.
- Ctrl+O toggles turn fold (was Ctrl+F, which collides with terminal
  app search shortcuts on macOS). Topbar gains a small "▾ fold"
  indicator when fold mode is on.
- Ctrl+Up / Ctrl+Down switches sessions (was Ctrl+Left / Ctrl+Right)
  to match the vertical session-list mental model.
- Esc is the single one-press cancel for ChoiceList — drops the prior
  two-step "Left to leave, Left to cancel" flow. Esc also continues to
  close the command palette and now closes the help overlay.
- Adds a Ctrl+? help overlay listing every keybinding. Bound on both
  ctrl+? and ctrl+question_mark to survive terminal reporting quirks.
- Newline keeps Ctrl+J / Ctrl+Enter / Shift+Enter as cross-terminal
  fallbacks (Ctrl+J is the portable one for macOS Terminal.app); drops
  Alt+Enter.
- Bottom bar simplifies to four essentials + Ctrl+? and / hints, and
  shows a transient "再按 Ctrl+C 退出" while quit is armed.

* fix(tui): switch help shortcut from Ctrl+? to Ctrl+/

Ctrl+? (= Ctrl+Shift+/) is unreliable across terminals — many emulators
either swallow it or report it as something Textual can't parse. Ctrl+/
is the conventional VS Code / command-line help shortcut and ships with
the legacy Ctrl+_ fallback (ASCII 0x1F is what older terminals send for
Ctrl+/). All three forms are bound (ctrl+slash, ctrl+/, ctrl+underscore)
so the binding survives terminal-reporting differences.

Bottom hint bar collapses to a single left-aligned line carrying the
command-palette and help hints alongside the action keys, since the
prior right column repeated information the left column already had room
for. The help overlay header reads "快捷键帮助" instead of "快捷键".

* fix(tui): ctrl+c dual-press with non-empty input, centered help modal

- Ctrl+C with non-empty input now clears AND arms quit; a second Ctrl+C
  within 2s exits. Previously typing in the input let one Ctrl+C exit
  silently after the clear, which was easy to trigger by accident.
- Help is now a ModalScreen pushed over the main screen so opening it
  no longer shifts the input box. Centered with a lighter background
  (#21262d on #30363d border) to read as a lifted panel.
- Esc-cancelling a ChoiceList removes the prompt cleanly with no
  "已取消" output — the canceled prompt simply disappears from the
  chat, matching expectations for in-flight tool prompts.

* fix(tui): keep Ctrl+C copy semantics and update choice hints

When there is selected text, Ctrl+C now copies the selection and returns
without triggering stop, input clear, or quit-arming. This preserves the
TUI selection/copy path after Ctrl+C became the global stop/quit key.

Choice prompts now advertise the current Esc-cancel behavior instead of
the removed Left-arrow flow, and stale comments/docs mentioning the old
left-arrow return path were cleaned up.
2026-05-13 23:04:23 +08:00
Liang Jiaqing 05c6b427c6 feat: add conductor system, code_review_principles; update llmcore, supervisor_sop, goal_mode constraints, gitignore whitelist 2026-05-13 23:02:52 +08:00
Jiaqing Liang a06a9ad20a feat: tighten context budget - maxlen 15k, trim refactor with staged compress 2026-05-13 23:02:52 +08:00
ozy 006d44c4f4 fix(tui): 仅在输入框真正边界时触发历史回溯 (#362)
* fix: tighten tui input history edge navigation

* 回退一些注释的修改
2026-05-13 19:26:27 +08:00
nianyucatfish 0accd0e319 fix(tui): restore wheel scroll on macOS in tuiapp_v2 (#361)
PR #360 disabled Textual mouse support on darwin (run(mouse=False)) to
keep terminal-native selection working. Side effect: ?1006 SGR mouse was
never enabled, while macOS Terminal.app / iTerm2 default ?1007 (alternate
scroll mode) translated wheel events into Up/Down keys — which then
triggered the newly added input-history navigation.

Re-enable mouse globally and explicitly disable ?1007 on mount so wheel
events flow through the SGR channel without spawning duplicate arrow
keys. Keyboard Up/Down history navigation is unaffected. The ?1007
disable is a no-op on Linux / Windows Terminal where it defaults off.
2026-05-13 18:58:58 +08:00
Shen HaoandAI Agent Review 95d858ade8 feat(tui): v2 image paste folding, history nav, /llm & /export choice (#360)
Co-authored-by: AI Agent Review
2026-05-13 16:04:58 +08:00
nianyucatfish 6bb31046cc fix(tui): keep v2 output scrolled during stream updates (#356)
When an assistant message already has a mounted body widget, streaming updates only refreshed the widget content. Scroll the messages container after those in-place updates so both streaming chunks and final responses remain visible at the bottom.
2026-05-13 10:56:26 +08:00
nianyucatfish 09cd8577e0 fix(tui): avoid duplicate paste in tuiapp_v2 (#352)
Handle paste events directly for both short and multi-line input instead of mixing TextArea's default paste path with the custom placeholder path. This keeps single-line paste from being inserted twice while preserving the existing multi-line placeholder behavior.
2026-05-13 10:34:32 +08:00
Liang Jiaqing 3205f4a017 feat: dynamic tool_result maxlen based on parallel tool count 2026-05-13 10:21:13 +08:00
Jinyi Han d2840ae0b3 docs: add Evaluation — Five Dimensions section (EN & CN) (#339) 2026-05-13 08:47:05 +08:00
nianyucatfish 2468801050 feat(tui): add tuiapp_v2.py — refined Textual frontend (#350)
新版 TUI 与 tuiapp.py 并存,共享 GenericAgent 调度/队列/线程协议,
重做视觉与交互:
- 多行 InputArea:≤3 行自适应、>3 行三行滑动窗口、粘贴占位符
- 命令面板(OptionList + 模糊匹配 + Tab 补全)
- /llm、command 选择走 ChoiceList,选完区域收缩
- Markdown HardBreak 渲染 + ANSI 缓存
- 响应式布局(窄屏隐藏侧栏 / 宽屏按比例放大)
- selection-aware 自动滚动(选文本时不抢滚动)
- 新增 /btw、/continue、/export、/restore

不改 cli / hub / README / tuiapp.py 入口。
2026-05-12 22:26:35 +08:00
Jiaqing Liang cf6551516f feat(reflect): add init() for runtime args with fallback to env/config 2026-05-12 12:59:30 +08:00
Liang Jiaqing 736f88a00a feat(tui): multi-line input with Enter submit, Ctrl+Enter newline, paste-safe 2026-05-11 21:12:28 +08:00
AspasZhang c247abb78a docs: update WeChat group 17 QR code (#338) 2026-05-11 20:38:38 +08:00
Liang Jiaqing f3f5b76d68 fix(launch): skip paste interception when clipboard contains text
PPT/Word copies include both image/png and text/plain items.
Only intercept paste when no text item is present (pure screenshot/image).
2026-05-11 20:27:26 +08:00
YooooEX 1b9fe1f539 feat(tgapp): add /btw command handling (#335) 2026-05-11 20:06:04 +08:00
Liang Jiaqing 2381d48a56 refactor: rename command/ to ga_cli/ to avoid namespace collision 2026-05-11 10:01:03 +08:00
WindCat e248aa8c02 feat: 添加跨平台 GA 命令行入口系统 (#329)
新增 command/ 包和 ga.cmd/ga 入口脚本,用户可通过 `ga <command>` 启动各前端。
2026-05-11 09:57:51 +08:00
manaskarra ee02e7eba5 fix(tgapp): preserve streaming overflow segments (#330)
Keep Telegram streaming overflow messages tied to the primary live message, reuse them across edits, and delete stale overflow replies when streamed content shrinks.
2026-05-11 09:40:18 +08:00
Liang Jiaqing dd2f96ba65 fix: hide code runner console window 2026-05-11 09:39:52 +08:00
nianyucatfish 9024af7499 feat(stapp): add /export command (clip/file/all) (#322) 2026-05-09 23:08:20 +08:00
Liang Jiaqing 3c5c99fd6d chore: replace alarming warning with neutral "(无文本输出)" in feishu display 2026-05-09 19:51:35 +08:00
AspasZhang 321ad8ad4f docs: update WeChat group 16 QR code (#318) 2026-05-09 18:37:48 +08:00
Liang Jiaqing a2f0f0d46a fix(stapp): restore finally abort guard in agent_backend_stream 2026-05-09 18:12:09 +08:00
Liang Jiaqing a6c010d25a fix: prevent empty response from corrupting history & add _fix_messages to non-native paths 2026-05-09 17:28:11 +08:00
Liang Jiaqing 67d135ab4d fix: avoid empty content messages in native tool flow 2026-05-09 16:28:44 +08:00
Liang Jiaqing 4af02c5f98 fix: unify retry logic in do_no_tool with shared counter to prevent infinite loop (#312) 2026-05-09 15:40:39 +08:00
Shen HaoandClaude Opus 4.7 03d19c9821 feat: add /btw side-question subagent (#317)
Mirrors Claude Code's /btw: ask the agent a quick question without
interrupting the in-flight main task.

- frontends/btw_cmd.py: snapshot backend.history under lock + deepcopy →
  background-thread backend.raw_ask single-turn answer; never writes
  backend.history; never touches task_queue. Reuses backend.raw_ask +
  make_messages, no new LLM instance.
- frontends/chatapp_common.py: register /btw in HELP_COMMANDS; handle
  /btw in AgentChatMixin (sync via asyncio.to_thread); install() at
  module bottom so the slash hook is wired for any frontend that
  imports chatapp_common.
- frontends/stapp.py: persist display_queue + partial_response in
  st.session_state so the streaming bubble survives Streamlit reruns;
  new render_main_stream() helper covers both new tasks and resume;
  /btw branch uses plain st.rerun() (preserves dq) so the main task's
  remaining output renders in a follow-up bubble; non-/btw prompts
  explicitly call agent.abort() to keep the original "submit cancels"
  UX. Strip <summary> meta tags from text segments and drop dangling
  "LLM Running (Turn N) ..." markers in resumed partials to clean up
  the rendered chat.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:26:05 +08:00
KailigithubandKailigithub 2a80e1e931 修复:清理 TMWebDriver 未使用的导入,修正裸 except 语法 (#311)
Co-authored-by: Kailigithub <Kailigithub@users.noreply.github.com>
2026-05-09 15:00:39 +08:00
Liang Jiaqing ca79699492 feat(tui): add /clear and /close commands 2026-05-09 14:56:52 +08:00
AspasZhang 268ceab408 fix: disable Streamlit dev shortcuts to prevent Ctrl+C triggering Clear Caches on Windows (#309) 2026-05-09 12:31:28 +08:00
wangjc683 cde1620710 fix: filter whitespace-only text blocks in NativeToolClient.chat() (#307)
When the model only outputs tool calls with no meaningful text,
combined_content may contain only whitespace text blocks (e.g. '\n').
Strict API proxies reject this with HTTP 400 'empty content' error.
Filter out such blocks before sending to the API.
2026-05-09 10:41:29 +08:00
Liang Jiaqing 2ddeab20a0 hub.pyw: add EXCLUDES set for goal_mode/chatapp_common/tuiapp; ga.py: fix missing summary prompt wording 2026-05-09 09:51:32 +08:00
AspasZhang dcab4b7608 docs: replace WeChat group 15 QR code (#302) 2026-05-08 22:34:19 +08:00
AspasZhang 7b07b6649a docs: update WeChat group QR code from 14 to 15 (#300) 2026-05-08 19:57:44 +08:00
Liang Jiaqing d7f5a3445a docs: add configure_mykey.py references to README & GETTING_STARTED 2026-05-08 17:49:24 +08:00
Liang Jiaqing 787bc1f9f3 move configure_mykey.py to assets/, fix PROJECT_ROOT 2026-05-08 17:44:26 +08:00
Liang Jiaqing c6d8d87e64 rename: configure.py -> configure_mykey.py 2026-05-08 17:40:10 +08:00
WindCatandguowenjiao54 e1c128e32f feat: add configure.py — interactive initialization wizard (#295)
* feat: add setup.py -- interactive initialization wizard

* fix: address PR #295 review — rename to configure.py, improve all feedback

- Rename setup.py → configure.py to avoid pip/setuptools conflict
- Fix lark exception: catch generic Exception instead of non-existent AppAccessDeniedError/AppExpiredError
- Add proxy support to probe_models via HTTPS_PROXY/HTTP_PROXY env vars
- Implement masked_input() with real-time API key masking
- Add pip install hints for messaging platform dependencies
- Add 2 new providers: CRS Gemini Ultra (Antigravity), Kimi/Moonshot OAI
- Restructure main menu: LLM only / platform only / or both with cascade prompts
- Add Python 3.14 compatibility warning and reconfiguration detection
- Fix welcome_message: WeCom only, correct variable name
- Show GETTING_STARTED example commands after configuration
- Add advanced LLM options: proxy, context_win, stream, user_agent

---------

Co-authored-by: guowenjiao54 <guowenjiao54@users.noreply.github.com>
2026-05-08 17:36:38 +08:00
Liang Jiaqing 1a7d54b82f Add lightweight goal mode reflect workflow 2026-05-08 17:28:16 +08:00
Liang Jiaqing 639da67916 feat: fsapp reconnect loop, tui branch/rewind, simphtml flow-child fix, sop updates 2026-05-08 15:48:16 +08:00
solopole 2fdbb289ed feat(discord): improve channel activation and progress display (#292) 2026-05-08 11:56:55 +08:00
TraderLife 8586310867 feat: add --wechat/--wx launch argument (#288) 2026-05-08 11:53:54 +08:00
Liang Jiaqing 7b51599088 fix: 堵死自主规划绕过subagent评审的漏洞
- subagent.md: 隐藏--nobg选项防止同步阻塞超时
- task_planning.md: 加第7条无限重试禁自评+探测策略重排序
- autonomous_operation_sop.md: set_todo前缀提示
2026-05-08 08:02:23 +08:00
Liang Jiaqing c7cef56cb2 fix: invalidate panel cache on fold toggle 2026-05-07 22:23:48 +08:00
Liang Jiaqing cc6b3fbee1 tuiapp: cache rendered panels; llmcore: simplify fast_ask 2026-05-07 22:18:25 +08:00
Liang Jiaqing 379b69e0ec refactor: simplify LLM client init with resolve_client; add fast_ask; auto-fold old tasks in TUI 2026-05-07 21:06:14 +08:00
Liang Jiaqing 072390e0fc README: remove --demo tips, split install deps for GUI/TUI 2026-05-07 18:19:48 +08:00
Liang Jiaqing 98e2e3f483 tuiapp: improve session nav (ctrl+arrows), CJK-aware sidebar, README restructure 2026-05-07 18:15:08 +08:00
Liang Jiaqing a9450b1f18 fix(tui): shorten sidebar Q/S to prevent wrapping, add Ctrl+1~9 session switch 2026-05-07 17:41:12 +08:00
Liang Jiaqing d4ab909144 feat: add Textual TUI frontend + minor fixes to agentmain/llmcore/stapp 2026-05-07 17:38:58 +08:00
Jiaqing Liang 93490d1634 refactor: rename GeneraticAgent, default bg for task, add fork history support 2026-05-07 15:12:46 +08:00
SONG ZHENRUI 71914044d7 Update README with virtual environment setup instructions (#243)
Added instructions for setting up a virtual environment and installing dependencies.

error: No virtual environment found; run `uv venv` to create an environment, or pass `--system` to install into a non-virtual environment
2026-05-07 13:11:20 +08:00
succ985andsucc985 0148446fab feat(wechat): add persistent typing indicator (#278)
The previous implementation called send_typing once with an empty
typing_ticket, which had no effect. The iLink API requires a valid
typing_ticket obtained from getconfig first.

Changes:
- Add get_typing_ticket() method to fetch ticket via getconfig API
- Replace single send_typing call with continuous typing loop
- Typing refreshes every 2 seconds until agent finishes processing
- Stops cleanly via threading.Event when message handling completes

Co-authored-by: succ985 <succ985@users.noreply.github.com>
2026-05-07 13:11:15 +08:00
LJQ 346d26bdf1 Merge pull request #279 from sontianye/fix/qqapp-reconnect-backoff
fix(qqapp): exponential reconnect backoff
2026-05-07 12:33:51 +08:00
LJQ b1fdd777f7 Merge pull request #283 from draj1e/feature/sophub
add sophub link to readme
2026-05-07 12:33:48 +08:00
draj1e 9ecc68ba59 add sophub 2026-05-07 11:18:08 +08:00
Tianye Song fd7bfcd9d3 fix(qqapp): exponential reconnect backoff
Replace fixed 5s sleep with the same backoff pattern already used in
dcapp.py: 5 -> 10 -> 20 -> ... -> 300s, resetting to 5s when a session
survived >=60s. Uses time.monotonic() to avoid clock-jump skew.
2026-05-07 10:29:37 +08:00
Liang Jiaqing 0807b130e9 refine: worker on_done + 120s window + prompt tweaks 2026-05-06 19:16:29 +08:00
Liang Jiaqing edbc473660 feat: agent BBS server + team worker + timeout bump 2026-05-06 18:55:18 +08:00
LJQ e08d7c7370 Merge pull request #266 from wellsoren/qt-ui-redesign
QT界面持续优化和bug修复
2026-05-06 13:33:36 +08:00
LJQ 04f7158aa7 Merge pull request #274 from yiqi-017/feat/acp-bridge
fix: ACP bridge streaming overlap and truncation
2026-05-06 13:33:07 +08:00
Jiaqing Liang 2d0b8e9648 Improve English UI defaults and task timeout 2026-05-06 13:23:33 +08:00
Liang Jiaqing 6e2ec39052 file_write: support content arg in native mode, strip it in text-emulated mode 2026-05-06 10:30:08 +08:00
yiqi-017andClaude Opus 4.6 a7a1487004 fix: use incremental streaming in ACP bridge to prevent text overlap
Switch to inc_out=True mode so queue items are incremental deltas
instead of cumulative text. The old cumulative approach broke when
agentmain's post-processing (</summary>\n\n insertion) shifted offsets,
causing the bridge's delta calculation to produce overlapping or
truncated content.

Now "next" items are forwarded directly, and "done" is only used as
content when no streaming occurred (error fallback).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 03:54:09 +08:00
LJQ f98f6eac39 Merge pull request #268
fix: remove duplicate backtick key in VK_CODE dictionary
2026-05-05 18:44:57 +08:00
LJQ 4299a83cc0 Merge pull request #269
fix: add request timeout and replace assert with proper exception
2026-05-05 18:41:56 +08:00
LJQ 7ed28f1e56 Merge pull request #271 from AspasZhang/docs/update-wechat-qr-group14
docs: update WeChat group QR to group 14, remove old group 13 image
2026-05-05 18:11:25 +08:00
LJQ a0e0cc66d8 Merge pull request #272 from yiqi-017/feat/acp-bridge
feat: add ACP bridge for codeg integration
2026-05-05 18:10:33 +08:00
yiqi-017andClaude Opus 4.6 7deb330c11 docs: add codeg integration section to README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-05 15:34:57 +08:00
yiqi-017andClaude Opus 4.6 dfe6bf0709 refactor: move acp bridge to frontends/
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-05 14:28:13 +08:00
AspasZhang ac17bacd91 docs: update WeChat group QR to group 14, remove old group 13 image 2026-05-05 13:32:36 +08:00
Liang Jiaqing a1a0cb815b feat: add contentSettings cmd to CDP bridge (bypass multi-download dialog); minor agentmain cleanup 2026-05-05 11:57:40 +08:00
Kailigithub af3ee66cd7 fix: add request timeout and replace assert with proper exception 2026-05-05 03:08:55 +00:00
Kailigithub fe1d8a1e1f fix: remove duplicate backtick key in VK_CODE dictionary 2026-05-05 03:07:35 +00:00
Liang Jiaqing f0ff7ee49e remove newtab, add terminal QR, refine task_planning sop 2026-05-04 12:39:41 +08:00
wellsoren 4f58b4e8d1 Merge branch 'main' into qt-ui-redesign 2026-05-03 22:59:44 +08:00
wellsoren 7ab18f1e99 style: QT界面持续优化和bug修复 2026-05-03 22:48:41 +08:00
wellsoren 3f712aeb66 Merge branch 'main' of github.com:lsdefine/GenericAgent 2026-05-03 22:25:44 +08:00
Liang Jiaqing 9f3ddec78f /resume prompt优化(v5) + wechatapp去or空占位 + summary截断80 2026-05-03 16:36:48 +08:00
wellsoren 4bbdf8cb26 chore: sync with upstream and update qtapp 2026-05-03 15:35:53 +08:00
wellsoren 5bd947b958 chore: sync with upstream and update qtapp 2026-05-03 14:31:13 +08:00
Liang Jiaqing 2afe4c3e39 feat: auto-inject summary text block when model outputs thinking+tool_use without text; stapp fallback uses content preview 2026-05-03 12:22:05 +08:00
Liang Jiaqing de24f0b2b9 fix: clear _pending_tool_ids when history is empty to prevent orphan tool_result after /new 2026-05-02 21:05:51 +08:00
Liang Jiaqing e66473f06e fix: include earlier_context in compress_history_tags pattern 2026-05-02 20:46:16 +08:00
Liang Jiaqing 95a7aeccb6 feat: peer_hint mechanism & history folding compression 2026-05-02 20:30:33 +08:00
yiqi-017andClaude Opus 4.6 e8bb346eb9 fix: mark ACP stdout fd as non-inheritable to prevent child process pollution
- os.set_inheritable(_stdout_fd, False) on both Windows and Unix
- Unix path now uses os.dup() + os.fdopen() consistent with Windows
- Prevents subprocess.Popen children from inheriting the ACP JSON-RPC
  file descriptor, which could corrupt the protocol stream

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-02 17:46:13 +08:00
hanshou101 dece612853 fix(fsapp): rollover oversized Feishu task cards (#209) 2026-05-02 14:54:46 +08:00
Liang Jiaqing 52f5d06766 fix: non-greedy file_content extraction, empty response retry limit, vision template pass 2026-05-02 12:59:40 +08:00
Liang Jiaqing a6416f6ef3 Revert "fix: make launcher resilient to pywebview startup failures (#203)"
This reverts commit dbb562f550.
2026-05-02 12:47:21 +08:00
CauchYoungandlaplace young dbb562f550 fix: make launcher resilient to pywebview startup failures (#203)
Co-authored-by: laplace young <yangqk12@whu.edu.cn>
2026-05-02 12:45:57 +08:00
KailigithubandKailigithub e22bdae74d fix: add Linux CJK font paths for desktop pet bubble text rendering (#248)
Co-authored-by: Kailigithub <Kailigithub@users.noreply.github.com>
2026-05-02 12:32:16 +08:00
Liang Jiaqing 9a02c28bfe docs: update WeChat group QR to group 13, remove old group images 2026-05-01 10:49:19 +08:00
LJQ 87a340e5b6 Merge pull request #245 from AspasZhang/pr/add-wechat-group13-image
docs: add missing wechat_group13.jpg
2026-05-01 10:47:03 +08:00
AspasZhang 3fd9e79b29 docs: add wechat_group13.jpg image 2026-05-01 10:46:15 +08:00
Liang Jiaqing 24f764082d Revert "Merge pull request #244 from AspasZhang/pr/update-readme-wechat-groups"
This reverts commit b22f3c45cc, reversing
changes made to 8bea96614e.
2026-05-01 10:46:11 +08:00
LJQ b22f3c45cc Merge pull request #244 from AspasZhang/pr/update-readme-wechat-groups
docs: 更新微信群二维码,去掉群8/9/11,保留群12,新增群13
2026-05-01 10:39:11 +08:00
AspasZhang 765068e272 docs: 更新微信群二维码,去掉旧群,只保留群13 2026-05-01 10:34:26 +08:00
tqjason 8bea96614e Fix pet reference in rightMouseDown_ method (#239) 2026-05-01 08:45:51 +08:00
Liang Jiaqing 0e08c31fb9 add official channel disclaimer; fix scroll/wechat-md/llm-msg 2026-05-01 08:41:10 +08:00
yiqi-017andClaude Opus 4.6 75d063a553 feat: enable verbose output in ACP bridge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 21:13:11 +08:00
Liang Jiaqing d4f8303053 feat: add Discord bot frontend; refine memory SOP index rule 2026-04-29 19:29:36 +08:00
Liang Jiaqing 6a3eecc07e fix: sanitize backticks in code_run output & simplify subagent turn header 2026-04-29 18:26:16 +08:00
yiqi-017andClaude Opus 4.6 be4d2f58c1 feat: add ACP bridge for codeg integration
Stdio-based JSON-RPC bridge implementing the Agent Client Protocol,
allowing codeg to connect to GenericAgent as a live ACP agent.

Handles Windows binary stdout/stdin, redirects stray prints to stderr,
and converts GenericAgent's cumulative text queue to ACP delta streaming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 15:46:47 +08:00
Liang Jiaqing d626c9f02c improve TMWebDriver connection error message and SOP 2026-04-29 14:02:43 +08:00
Liang Jiaqing 513fec91fc cleanup: remove NextWillSummary, add supervisor_sop, fix streaming fence protection, tighten L1 rules 2026-04-29 13:29:03 +08:00
Liang Jiaqing 9e19b8a8de refactor: deduplicate _parse_mixed_response by reusing _parse_text_tool_calls; fix orphan tool_result in _fix_messages 2026-04-28 22:15:07 +08:00
Liang Jiaqing 513b644252 统一流重试行为: 不管mixin/非mixin中途断连都重试; 更新ga.py错误检测条件 2026-04-28 20:41:14 +08:00
Tianye Song 68982c36dc fix(dingtalkapp): exponential reconnect backoff and token fetch retry (#210)
- Reconnect: exponential backoff 5→300s, reset on healthy session ≥60s
- Access token: retry once after 1s before returning None
2026-04-28 20:25:00 +08:00
LJQ 9390435cde Merge pull request #199 from AspasZhang/fix/plan-sop-remove-sop-index-dep
fix: remove sop_index.md dependency in plan_sop
2026-04-28 20:17:16 +08:00
LJQ eb9c4c4752 Merge pull request #214 from YooooEX/telegram-polish
fix(tgapp): handle Telegram stream rate limits
2026-04-28 20:16:08 +08:00
YooooEX 5e1a788af1 fix(tgapp): handle Telegram stream rate limits 2026-04-28 18:34:36 +08:00
YooooEX 972c55bcb2 Merge remote-tracking branch 'origin/main' into telegram-polish 2026-04-28 18:30:35 +08:00
Liang Jiaqing 7070880b4e refactor: extract _stream_with_retry, MixinSession failover on stream abort 2026-04-28 17:54:37 +08:00
YooooEX 442ab73c48 Merge remote-tracking branch 'origin/main' into telegram-polish 2026-04-28 12:15:50 +08:00
YooooEX baf0f27793 feat(tg): stream Telegram responses by turn with summaries (#208) 2026-04-28 12:01:07 +08:00
Liang Jiaqing f421913703 autonomous sop: 收尾改为4步,完成即停不贪多 2026-04-28 11:45:12 +08:00
YooooEX 41c79ac5c4 feat(tg): stream Telegram responses by turn with summaries 2026-04-28 11:21:09 +08:00
Liang Jiaqing e58934c744 fix: /resume normalize literal \n to real newlines before regex matching 2026-04-28 09:38:04 +08:00
AspasZhang aaf15b6797 fix: remove sop_index.md dependency in plan_sop, use L1 Insight index instead
sop_index.md is a user-specific file that doesn't exist in the shared repo.
Replace it with L1 Insight index which is auto-injected into context each turn.
2026-04-27 19:25:37 +08:00
LJQ 776f4e267d Merge pull request #196 from YooooEX/telegram-polish
feat(tgapp): add ask_user inline candidate menu
2026-04-27 18:51:29 +08:00
LJQ 0ed89ebdeb Merge pull request #198 from voidborne-d/fix/stapp-autonomous-enabled-fragment-rerun
fix(stapp): defend autonomous_enabled init against fragment-only reruns
2026-04-27 18:20:24 +08:00
Liang Jiaqing 78a9414e05 feat: add SSL verify option and enable TG draft 2026-04-27 18:15:57 +08:00
voidborne-d b8ecdc7922 fix(stapp): defend autonomous_enabled init against fragment-only reruns
closes #197
2026-04-27 17:51:32 +08:00
Liang Jiaqing 3c9496ffa2 refactor: 提取script_dir为模块级变量,清理procmem_sop涉敏感代码 2026-04-27 17:46:31 +08:00
YooooEX 7ef7283fad feat(tgapp): add ask_user inline candidate menu 2026-04-27 16:02:24 +08:00
Jiaqing Liang b83cf28e29 feat: ensure thinking blocks for deepseek models in history 2026-04-27 15:11:09 +08:00
Jiaqing Liang 16c22432e3 refactor: _openai_stream takes sess+messages; add service_tier support 2026-04-27 15:11:08 +08:00
UnsongK 11442847b0 feat(wecom): upgrade wecom bot to full media + progress streaming (#184)
* feat(wecom): upgrade wecom bot from text-only to full media + progress streaming

- Add image/file message handling with encrypted media download
- Add media upload & send (auto image/file type detection, fallback to text on failure)
- Add real-time per-turn progress streaming via _turn_end_hooks
- Add send_done with automatic file attachment extraction from agent replies
- Add terminal CLI: help/status/stop/exit for runtime control
- Add run stats tracking (received/completed counts)
- Refactor message parsing into shared _accept() method (dedup + auth + register)
- Harden None-safety on body.get(from) and dedup ID fallback
- Move state from module globals to instance attributes

* refactor(wecomapp): parameterize agent & client, remove dead code

- Remove module-level agent creation; inject via constructor
- Make WSClient injectable in start() for testability
- Clean up stale comments and version tag
- Add inline doc for split_limit
2026-04-27 14:13:28 +08:00
Junghwan 15112bccf1 fix(schema): align file_write mode default with implementation
assets/tools_schema.json declares file_write.mode default as 'append',
but assets/tools_schema_cn.json and ga.py both default to 'overwrite':

    # ga.py:369
    mode = args.get("mode", "overwrite")

Since the i18n switch in 7cadbd7 loads the English schema for en_*
locales, an LLM that trusts the schema default and omits the mode
parameter will silently overwrite instead of append. Align the English
schema with the Chinese schema and the Python handler so all three
agree on 'overwrite'.
2026-04-27 10:06:15 +08:00
LJQ 8e7cb8f559 Merge pull request #185 from AspasZhang/docs/update-wechat-group12-qr
docs: 更新微信群二维码,移除群8,新增群12
2026-04-27 09:34:21 +08:00
weijia db6bf00d0a fix(fsapp): show truncated tail instead of silent "..." fallback (#186)
When model output contains only stripped tags, surface warning + last 300 chars instead of silent "...".
2026-04-26 23:16:43 +08:00
Liang Jiaqing df77311516 chore: remove unused test files 2026-04-26 22:58:43 +08:00
AspasZhang fd58b85fe4 docs: 更新微信群二维码,移除群8,新增群12 2026-04-26 22:55:02 +08:00
Liang Jiaqing 1d0b86d110 fix: remove inherited HTTPS_PROXY to prevent WeChat long-poll SSL EOF 2026-04-26 22:09:55 +08:00
Liang Jiaqing f11a056d17 handle file paste in webview 2026-04-26 20:51:49 +08:00
Liang Jiaqing d949a59ebb file_read: add PARTIAL hint when count < total lines 2026-04-26 20:11:00 +08:00
Liang Jiaqing adfa805fe3 improve ask_user tool display and memory SOP prompt 2026-04-26 18:24:21 +08:00
Liang Jiaqing 13850b85a8 hotreload mykeys, fix SSE residual block, unify context_win, cleanup 2026-04-26 17:46:24 +08:00
Liang Jiaqing 6f1f02eddb wechatapp: add image thumbnail pipeline, UA coverage, refactor turn-based streaming 2026-04-26 17:24:32 +08:00
Liang Jiaqing cd0ce4d9b0 fix: summary提取与fold_turns防误切,强化thinking模型summary输出 2026-04-26 00:01:42 +08:00
Liang Jiaqing 69f66997fd fix: fallback to getpass.getuser() when os.getlogin() raises OSError 2026-04-25 22:55:17 +08:00
LJQ 567c7f582b Merge pull request #140 from huangrichao2020/feat/macos-desktop-app
feat: add macOS Desktop App installation script
2026-04-25 22:17:44 +08:00
LJQ adf393c0a5 Merge pull request #173 from Stark-X/fix/linux-desktop-pet-pyside6
fix: add Linux desktop pet implementation
2026-04-25 22:09:35 +08:00
LJQ 12f23138e0 Merge pull request #164 from kukudelaomao/fix/wecomapp-event-handlers
fix(wecomapp): 修复WebSocket连接和事件处理器问题
2026-04-25 22:04:31 +08:00
LJQ c19ac2dae6 Merge pull request #160 from YooooEX/telegram-polish
fix(tgapp): polish Telegram MarkdownV2 rendering and startup helpers
2026-04-25 21:59:49 +08:00
LJQ 5805566d88 Fix Telegram startup without a configured proxy
Fix Telegram startup without a configured proxy
2026-04-25 21:51:54 +08:00
LJQ da91a0643f fix(sandbox): patch Popen + block startfile to prevent window leaks
fix(sandbox): patch Popen + block startfile to prevent window leaks
2026-04-25 21:49:15 +08:00
Liang Jiaqing 5b0d96b1b5 tg: support document upload, disable draft streaming; wx: streaming flush during task; llmcore: safeprint, max_tokens fixes 2026-04-25 21:33:38 +08:00
Jiaqing Liang 10776fed51 Remove responses max output token limit 2026-04-25 14:03:21 +08:00
Jiaqing Liang afa4586e3d fix: _drop_unsigned_thinking对str content崩溃 + _to_responses_input丢弃thinking-only assistant消息导致tool_call被吞 2026-04-25 13:53:08 +08:00
Auston Li bd2dd42ba4 Fix Telegram startup when proxy is not configured
Only use a proxy for tgapp when mykeys['proxy'] is explicitly configured, avoiding the dead default local proxy and restoring Telegram polling startup.

Closes #175
2026-04-25 00:54:42 -04:00
Stark-X 0f77470fe0 Merge branch 'lsdefine:main' into fix/linux-desktop-pet-pyside6 2026-04-25 11:20:11 +08:00
MuziIsabel 3b93c6137d fix(sandbox): patch Popen + block startfile to prevent window leaks
subprocess.run was already patched with CREATE_NO_WINDOW, but Popen and
os.startfile were unprotected. Agent code could open visible GUI windows
via subprocess.Popen(['notepad.exe']) or os.startfile().
2026-04-25 10:59:15 +08:00
Stark-X 09cf6306af fix: add Linux desktop pet implementation 2026-04-25 10:58:38 +08:00
Liang Jiaqing 08181be4bf fix: use correct token limit params for OpenAI APIs 2026-04-25 10:54:44 +08:00
Liang Jiaqing cac3ba4769 feat: reasoning/thinking互通适配 + history窗口扩大 + summary提示强化 2026-04-25 10:33:42 +08:00
郭春飞 936501069c fix(wecomapp): 修复WebSocket连接和事件处理器问题
修复企业微信机器人无法正常连接的问题:

1. 修复WSClient初始化参数传递错误
   - 原代码错误地将参数传递给WSClient构造函数
   - 修正为正确传递host, port, path等参数

2. 修复连接方法调用错误
   - 将connect_async()改为connect()方法
   - AiBotSDK的WSClient使用同步connect方法

3. 修复事件处理器签名不匹配
   - on_connected/on_authenticated: 移除frame参数(这些处理器不需要参数)
   - on_disconnected: 添加reason参数
   - on_error: 添加error参数

修复后验证:
- WebSocket连接成功建立
- 认证过程正常完成
- 心跳机制正常工作
- 日志无错误信息

此修复解决了企业微信机器人启动后无法连接服务器的问题。
2026-04-25 01:18:28 +08:00
YooooEX 8e159a9773 feat(tgapp): add support for __ bold and ~~ strikethrough styles 2026-04-24 20:11:40 +08:00
YooooEX 0c5d3c8635 fix(tgapp): align startup with common helpers and render file marker basenames 2026-04-24 20:11:40 +08:00
Liang Jiaqing 114dfdb211 fix: wechatapp单例锁端口19528→19531 避免与qqapp/wecomapp冲突 2026-04-24 19:58:27 +08:00
LJQ 9047e7fe39 fix(cli): 修复中文退格显示残影 (#157)
fix(cli): 修复中文退格显示残影
2026-04-24 18:52:48 +08:00
Huang richao 446d7bf549 Merge remote-tracking branch 'origin/main' into feat/macos-desktop-app 2026-04-24 17:53:18 +08:00
Huang richao b34965b936 fix: narrow macOS app installer per review 2026-04-24 17:52:47 +08:00
Liang Jiaqing ee899d4dd5 fix(tgapp): remove hardcoded proxy default, use mykey.py config instead
The proxy was hardcoded to http://127.0.0.1:2082 which breaks for users
without a local proxy (e.g. international users with direct access).
Now defaults to None; users who need a proxy can set it in mykey.py.
2026-04-24 17:30:48 +08:00
Liang Jiaqing 888ce2f9bb fix(pyproject): correct dingtalk-stream version constraint (>=0.20, was >=1.0 which does not exist on PyPI) 2026-04-24 17:24:09 +08:00
VonEquinox f74cc39c33 fix(cli): 修复中文退格显示残影 2026-04-24 17:21:04 +08:00
LJQ e2a57e7a32 Merge pull request #153 from ViviqwerAsd/feat/fsapp-add-llm-command
feat(fsapp): add /llm command support for model switching
2026-04-24 16:01:14 +08:00
Xinyi Wang a6644d580c feat(fsapp): add /llm command support for model switching
- Add /llm command to list available models
- Add /llm [n] command to switch to specific model
- Improve /status command to show current LLM info with emoji indicators
- Update /help text to include /llm command
- Unify command parsing logic using parts and op variables

This brings fsapp.py (Feishu/Lark) frontend in line with other chat frontends (QQ, DingTalk, WeCom) that already support all slash commands.
2026-04-24 04:20:16 -03:00
LJQ 883366ce55 Merge pull request #148 from AspasZhang/docs/update-wechat-qr-codes-v3
docs: update WeChat community group QR codes (remove 6&10, add 11)
2026-04-24 12:32:00 +08:00
AspasZhang be2386661d docs: update WeChat community group QR codes (remove 6&10, add 11) 2026-04-24 11:08:20 +08:00
Liang Jiaqing 12c2fe1f79 add uv install guide & pyproject.toml; unify error prefix to !!!Error: 2026-04-24 10:00:23 +08:00
Liang Jiaqing f334994ab6 fix: thinking非空时不再误判为空响应触发重试 2026-04-24 10:00:23 +08:00
LJQ 6974853695 Merge PR #144: style: 重构并优化 qtapp 桌面窗口样式与交互功能
style: 重构并优化 qtapp 桌面窗口样式与交互功能
2026-04-24 09:26:08 +08:00
YooooEX f516c52a72 feat(tgapp): stream MarkdownV2 replies with draft fallback and file segments (#135) 2026-04-24 09:01:49 +08:00
wellsoren 8065c0d759 style: 重构并优化 qtapp 桌面窗口样式与交互功能 2026-04-24 01:50:09 +08:00
Liang Jiaqing fd46d61543 fix: SSE tool_call streaming compat for non-standard index/id, MockToolCall handle list args & None fallback; sop: CDP bringToFront note 2026-04-23 23:44:50 +08:00
Huang richaoandQwen-Coder 7255515607 feat: add macOS Desktop App installation script
- Add scripts/install-macos-app.sh for one-click macOS app installation
- Uses bundled assets/images/logo.jpg as app icon
- AppleScript prompts for project folder on first run
- Updates README with macOS Desktop App installation option
- Supports both interactive and --auto non-interactive modes

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-23 23:19:24 +08:00
Liang Jiaqing 8a6a5715ff Fix responses call_id mapping for converted tool history 2026-04-23 20:53:44 +08:00
Liang Jiaqing df2a7749b4 refactor: unify system prompt injection into _openai_stream; disable gpt done_hooks 2026-04-23 20:07:20 +08:00
Jiaqing Liang 6091bf0c6f fix: add requests to pip install & tune max_turns/prompt 2026-04-23 17:21:22 +08:00
weijiaandwjl2023 fd4c833511 feat(stapp): restore chat history bubbles after /continue N (#138)
When the user runs '/continue N' in stapp, the agent's in-memory context
is restored, but the UI previously showed only a single '✅ restored' line
— all prior chat bubbles were missing.

This change parses the target session log and reconstructs the
user/assistant message pairs into st.session_state.messages, so reopening
a session feels like the conversation was never interrupted.

* continue_cmd.py: add extract_ui_messages(path)
  - parses model_responses log into [{role, content}, ...]
  - groups multi-turn LLM calls (prompts whose text starts with the
    '### [WORKING MEMORY]' header) into a single assistant bubble,
    inserting the existing '**LLM Running (Turn N) ...**' marker so
    fold_turns() renders them as collapsible segments.
  - two small helpers (_user_text / _assistant_text) keep parsing local.

* stapp.py: in the /continue branch, resolve the target log path BEFORE
  calling handle_frontend_command (which snapshots the current log and
  would otherwise shift list_sessions indices), then replace
  session_state.messages with the reconstructed history on success.
  Falls back to the previous behavior for bare /continue or failure.

Co-authored-by: wjl2023 <wjl2023@users.noreply.github.com>
2026-04-23 16:36:41 +08:00
Liang Jiaqing 1678114f1f fix: split concatenated JSON tool args in _parse_openai_sse to prevent _raw death spiral 2026-04-23 11:53:35 +08:00
Liang Jiaqing 97573e6f46 fix: agent-level retry for intermittent SSL errors during streaming
- llmcore: prefix all error outputs with !!! marker
- ga: detect !!!Error: [SSL: in tail of content as incomplete response and retry
- add len(content)>100 guard to avoid false positives on short responses
2026-04-23 11:24:54 +08:00
Liang Jiaqing 17971f642a fix: guard all id/call_id fields with or "" to prevent None leaking into payload 2026-04-23 11:00:04 +08:00
Liang Jiaqing a53b7a8593 refine L0 sync rule: default L3, bracket trigger words only for non-intuitive scenarios 2026-04-22 19:49:27 +08:00
Liang Jiaqing 08c583d1c6 fix: filter empty text blocks in message conversion & add prompt continuation line 2026-04-22 19:37:06 +08:00
LJQ 928633e4b2 Merge pull request #134 from shenhao-stu/main
template: upload mykey_template_en.py
2026-04-22 19:11:43 +08:00
Shen Hao 542aa89130 template: add mykey_template_en.py 2026-04-22 19:09:07 +08:00
Shen Hao eb2a3bf824 template: add Kimi for Coding configuration 2026-04-22 19:05:40 +08:00
Liang Jiaqing 274d47f35f Normalize OAI messages before conversion and update tool-call prompt 2026-04-22 17:16:05 +08:00
weijia b34cffe801 feat(fsapp): render agent run as one continuously-updating Feishu card (#131)
Rework the Feishu frontend so each user turn renders as a single
collapsible task card that patches itself in place, replacing the
dq-based streaming path that produced many fragmented messages.

- One _TaskCard per turn; hook reacts to summary / exit_reason events
  from the agent loop and patches the same card.
- Each step is a foldable panel: header shows the summary, expanding
  reveals three sections (auto-hidden when empty):
    * Thinking   - from response.thinking (separate field, not content)
    * Tool Calls - tool name + truncated JSON args
    * Output     - response.content, with protocol tags stripped so
                   the header summary is not duplicated inside
- Final reply rendered as a schema 2.0 markdown card for consistency.
- Code-review pass per code_review_principles.md:
    * _TaskCard owns only stateful card lifecycle (start/step/done/fail)
    * Pure formatting extracted to module-level _build_step_detail and
      _fmt_tool_call (no more reaching into card._private from the hook)
    * Hook is a ~10-line dispatcher
    * Flattened a 4-level nested lambda into a named function
2026-04-22 14:12:51 +08:00
Liang Jiaqing 132edb3154 improve GPT done_hook: require informative summary on completion 2026-04-22 10:53:29 +08:00
Liang Jiaqing 7b2ff1e73a feat(tgapp): handle photo messages, pass temp path to agent 2026-04-21 23:39:46 +08:00
LJQ f1db2e56df feat: add Telegram slash command parity and menu sync (#125)
feat: add Telegram slash command parity and menu sync
2026-04-21 23:14:38 +08:00
song f14ad0c693 fix(llmcore): preserve thinking block signature in streaming SSE parser (#123)
Anthropic's extended thinking streaming protocol emits two delta types
for a single thinking block: `thinking_delta` (the textual reasoning)
and `signature_delta` (a base64 HMAC tag appended at the end of the
block). Both must be accumulated into the same `content_block`.

Current code only handles `thinking_delta`, so `signature_delta` events
are silently dropped. When the assistant's reply (with thinking) is
echoed back on the next turn, Anthropic's server validates the
signature and rejects the request with 400:

    "Invalid `signature` in `thinking` block"

Downstream effects observed in production (via sub2api relay logs):
- Every request with history triggers a 400 signature error
- The relay strips thinking blocks and retries, which changes the
  cache prefix and invalidates prompt caching, forcing a full rebuild
  of cache_creation_tokens (~20k-30k per affected request)
- Measured in a 5h window: 5/25 requests suffered cache invalidation,
  accounting for 53.5% of total spend that was otherwise avoidable

Fix:
1. Initialize `current_block` with an empty `signature` field when a
   thinking block starts, so the dict shape matches Anthropic's spec
   (`{type, thinking, signature}`).
2. Handle `signature_delta` events by appending `delta.signature` to
   `current_block["signature"]`. Using `+=` (rather than assignment)
   mirrors how `thinking_delta` is accumulated and is robust against
   future chunked signatures.

No behavior change for clients that disable extended thinking, or for
upstreams that don't emit `signature_delta`. For `tool_use` threads
that require valid thinking signatures to preserve reasoning context,
this fix is required — the previous behavior silently corrupted them.

Verification:
- Before fix: upstream returns 400 + retry; cache_creation_tokens
  spike to ~25k on every 4th-5th request in a conversation
- After fix: upstream accepts the first attempt; cache_read_tokens
  dominate, cache_creation_tokens only appear on the first request
  of a fresh 5m prompt-cache window
2026-04-21 12:07:14 +08:00
LJQ 7ce39e29d0 Merge pull request #124 from ViviqwerAsd/docs/update-wechat-qr-codes-v2
docs: update WeChat community group QR codes
2026-04-21 11:52:37 +08:00
Xinyi Wang 09a78573f5 docs: update WeChat community group QR codes
- Replace 3 old QR codes (group 5/6/7) with 4 new ones (group 6/8/9/10)
- Update both English and Chinese sections in README.md
- Remove obsolete wechat_group5.jpg and wechat_group7.jpg
- Add wechat_group8.jpg, wechat_group9.jpg, wechat_group10.jpg
2026-04-21 11:47:55 +08:00
YooooEX ef9d218f91 feat: add Telegram slash command parity and menu sync 2026-04-21 11:43:53 +08:00
Liang Jiaqing 055fe882fc docs: add arXiv link (2604.17091) to README 2026-04-21 10:57:51 +08:00
Liang Jiaqing d25cfa5a2a refactor: vision_sop精简改写 + 新增vision_api.template.py 2026-04-20 23:22:36 +08:00
Liang Jiaqing 5c1dd1270c refactor stapp rerun reset logic and simplify vision SOP 2026-04-20 21:10:49 +08:00
LJQ bf4bc5d2ea feat: add /continue and /new command support to all chat frontends (#120)
feat: add /continue and /new command support to all chat frontends
2026-04-20 21:01:09 +08:00
Xinyi Wang d1a491ff3b feat: add continue/new support to chat frontends 2026-04-20 19:27:31 +08:00
Jiaqing Liang db3a807141 docs: add link to Datawhale hello-generic-agent tutorial 2026-04-20 16:26:07 +08:00
Jiaqing Liang 116d7d3d23 refactor: plugins dir + opt-in langfuse via __getattr__ guard
- mv langfuse_tracing.py -> plugins/langfuse_tracing.py
- llmcore: load plugin lazily inside __getattr__ when langfuse_config present
  (PEP 562 module __getattr__ naturally fires only once after globals().update)
- llmcore: extract _record_usage() from 4 scattered [Cache] print sites
- agentmain: /resume scans only latest 10 files
2026-04-20 15:56:06 +08:00
totoyangandtotoyang 8e6270e3a3 feat: optional Langfuse tracing for agent execution (#115)
Self-activating langfuse tracing via monkey-patch: independent module, zero impact when langfuse_config unset. Captures LLM generation, tool calls, token usage from SSE streams.

Co-authored-by: totoyang
2026-04-20 15:27:55 +08:00
Jiaqing Liang adc702d3f8 docs: add GitHub badge linking to GA-Technical-Report (data & reproduction repo) 2026-04-20 12:37:17 +08:00
Jiaqing Liang 765fcb6c2e docs: move disclaimer to License section (less intrusive) 2026-04-20 12:19:29 +08:00
Jiaqing Liang ae796482e8 docs: add disclaimer about unofficial commercial sites 2026-04-20 12:17:28 +08:00
Liang Jiaqing 63c7c83c51 refactor: update agent loop and tool extraction 2026-04-19 23:21:37 +08:00
Liang Jiaqing 86ca4625ad refactor: simplify HTTP error handling in _openai_stream, add non-stream support, broadcast history in MixinSession 2026-04-19 20:57:07 +08:00
LJQ d1f4ee9aaf Merge pull request #112 from shenhao-stu/main
feat: add /continue cmd
2026-04-19 19:15:41 +08:00
Shen Hao 8d07a0b4eb feat: Import chatapp_common for command activation
Added import for chatapp_common to enable command functionality.
2026-04-19 19:12:36 +08:00
Shen Hao faf5c9ce01 feat: Add imports for GeneraticAgent and continue command
Import GeneraticAgent and install continue command.
2026-04-19 19:11:39 +08:00
Shen Hao 9d5e4b18eb feat: update continue_cmd.py 2026-04-19 19:10:38 +08:00
Liang Jiaqing 2d753a6930 improve /resume: use regex to extract history blocks instead of naive tail reading 2026-04-19 17:15:38 +08:00
Liang Jiaqing 745220e62c Revert "Merge pull request #108 from ggandmee-cloud/feat/cch-signing"
This reverts commit 3e61155b33, reversing
changes made to 4abb8e205d.
2026-04-19 16:18:45 +08:00
LJQ 0214388d10 Merge pull request #110 from shenhao-stu/main
update mykey_template.py and add user_agent
2026-04-19 15:40:19 +08:00
Shen Hao 9217fee211 [feat]: Add user agent configuration to llmcore 2026-04-19 15:38:41 +08:00
Shen Hao 7c2785efe2 [config]: update mykey_template.py 2026-04-19 15:37:30 +08:00
LJQ 3e61155b33 Merge pull request #108 from ggandmee-cloud/feat/cch-signing
feat: NativeClaudeSession 在 fake_cc_system_prompt 模式下支持可选 CCH 签名
2026-04-19 15:27:13 +08:00
ggandmee-cloud c79b1c5140 feat: NativeClaudeSession 加入 CCH 签名
匹配真实 Claude Code 客户端协议,兼容需要验证客户端身份的代理。
2026-04-19 01:20:01 -04:00
Liang Jiaqing 4abb8e205d sys_prompt_en: 去掉强制英文,跟随用户语言 2026-04-19 11:29:30 +08:00
Liang Jiaqing c6319594c7 清理SOP: web_setup去TM方案, tmwebdriver排查流程优化 2026-04-19 11:16:29 +08:00
Liang Jiaqing 77d44c8cd0 update technical report 2026-04-19 10:03:32 +08:00
Liang Jiaqing 5d015d42c9 update technical report: language polish and table layout improvements 2026-04-18 19:58:56 +08:00
Liang Jiaqing f8a380d4fd fix: increase default timeout for non-stream mode (10/240s vs 5/30s) 2026-04-18 16:45:37 +08:00
Liang Jiaqing 2632478d96 fix: get_llm_name case-insensitive match & refactor to accept optional backend param 2026-04-18 14:46:05 +08:00
Liang Jiaqing c529abce3c fix: clarify start_long_term_update should only be called when task is completed (not mid-task) 2026-04-18 13:09:01 +08:00
Liang Jiaqing 11c41e7c4a refine memory_cleanup_sop: 4 compression principles, RULES triage, anti-intuitive trigger words, layered placement 2026-04-18 12:33:41 +08:00
Liang Jiaqing 3c2d22b948 refactor: move chat bubble asset to frontends/ and rename to English 2026-04-18 11:13:38 +08:00
Keeper 5d0d4b7d27 add: right-click of changing skin for desktop pet (#99)
* fix: skin icon size

* fix: skin icon size

* fix: skin icon size

* fix: skin icon size

* fix: skin icon size

* fix: skin icon size

* add: right click logic of changing skin
2026-04-18 11:00:56 +08:00
LJQ 184948b772 Merge pull request #89 from mmgnosit-commits/fix/minimax-timeout-retry
fix(llmcore): 添加MiniMax超时错误码529支持重试机制
2026-04-18 10:59:16 +08:00
weijia f49d69cf92 fix: 桌宠气泡 (#102)
* refactor: 精简desktop_pet_v2 — 内联_find_bubble_asset、删debug log、去重复RGBA检查

- 内联 _find_bubble_asset() 到 build_bubble_image (减少一层函数调用)
- 删除2处 /tmp/pet_toast_debug.log 写入
- 去除 Mac load_skin 中重复的 RGBA 转换检查
- 所有气泡核心逻辑(ImageOps.contain/非对称padding/alpha crop/tail扫描/定位公式)完整保留
- 785行 → 763行 (-22行)

* fix: 修复气泡文字碰边框问题,基于不透明区域计算文字padding

* feat: 添加自定义聊天气泡图片

* style: shift bubble text up 3px for better visual centering
2026-04-18 10:48:30 +08:00
Liang Jiaqing ec34b7e1c0 Add technical report link to README 2026-04-18 00:13:48 +08:00
Liang Jiaqing 13b15f3255 Add technical report PDF 2026-04-18 00:09:36 +08:00
Liang Jiaqing 593eaa8cf7 Add Trendshift trending badge to README 2026-04-17 21:12:34 +08:00
Junghwan adcfb8f730 fix(L4): preserve raw sessions skipped as recent(<2h) (#90)
compress_session.py Phase 4 builds to_del from both processed files
and every skipped file. Phase 1 marks files younger than 2h as
'recent(<2h)' (line 175) so their still-active writer is not
interrupted, but Phase 4 then deletes them anyway.

Filter the skipped-loop by reason so 'recent' files are preserved.
Processed files and other skip reasons (dup, compression error)
still proceed through deletion as before.
2026-04-17 17:35:50 +08:00
Jiaqing Liang 0e3e602121 fix: inline_eval exec() exception escaping crash; update schema description 2026-04-17 16:06:13 +08:00
Jiaqing Liang 47f106c8db refactor: extract slash cmd handler; fix inline_eval param name; support extra_sys_prompt 2026-04-17 15:39:51 +08:00
宋明明 04b4818f87 fix(llmcore): 添加MiniMax超时错误码529支持重试机制
## 问题描述
MiniMax API在请求超时时返回HTTP 529状态码,该状态码未被包含在可重试状态码集合中, 导致即使配置了max_retries参数,重试机制也对MiniMax超时无效。
当前行为:MiniMax 529 → 不重试 → 直接报错
预期行为:MiniMax 529 → 触发重试逻辑 → max_retries生效 ## 解决方案
在llmcore.py的RETRYABLE集合中添加529状态码。 ## 关于其他改动的思考 最初的想法是:将 LLM API 重试的 HTTP 状态码从硬编码提取为可配置文件
assets/http_retry_codes.json,首次运行时自动生成默认配置

### 之前的初衷(配置文件方案)
- ✓ 不修改代码即可适配新的错误码
- ✓ 降低维护成本
### 现实考量(硬编码方案)
- 主流模型厂商就那几家:OpenAI、Claude、MiniMax、Kimi等 -
标准的超时错误码基本固化:408(timeout)、429(rate_limit)、5xx(server_error)
- MiniMax 529是特例但不频繁变化
- 硬编码更简洁直接,维护更清晰

## 受影响范围
- llmcore.py: _openai_stream() 函数的重试机制现在支持MiniMax 529错误
- 对应MiniMax API的超时场景现在能正确触发retry逻辑
2026-04-17 15:09:52 +08:00
Jiaqing Liang f418963585 refactor: omit temperature from payload when default (1), avoid errors on reasoning models 2026-04-17 13:11:57 +08:00
Liang Jiaqing 9dbb3315da docs: add English-only CONTRIBUTING.md 2026-04-17 10:46:09 +08:00
Liang Jiaqing b1f72a202e fix: clarify file_write usage — content before call 2026-04-17 00:04:12 +08:00
Liang Jiaqing aa674655a8 refactor: unify docstrings, add /resume command, simplify error msg 2026-04-16 21:04:04 +08:00
LJQ e7e560715e Merge pull request #80 from shenhao-stu/main
docs: update mykey_template.py
2026-04-16 19:52:02 +08:00
LJQ 2c0146232a Merge pull request #79 from ViviqwerAsd/docs/update-wechat-group-qrcodes
docs: 更新微信群二维码 (group 5/6/7)
2026-04-16 19:50:42 +08:00
Shen Hao e4e84f2b43 docs: update mykey_template.py 2026-04-16 19:50:11 +08:00
Xinyi Wang 753bde713f docs: update WeChat group QR codes (group 5/6/7) in README Support section 2026-04-16 19:45:57 +08:00
LJQ f6deb83fe0 Merge pull request #78 from AspasZhang/feat/update-plan-verify-sop
feat: update plan_sop & verify_sop
2026-04-16 19:18:14 +08:00
Liang Jiaqing c43c07487f feat: add English version of global_mem_insight_template 2026-04-16 18:52:07 +08:00
Liang Jiaqing 7cadbd7403 feat: i18n support - auto-detect system language for zh/en prompts 2026-04-16 18:47:40 +08:00
张洲嘉 82b8535b7c feat: update plan_sop & verify_sop
- plan_sop: improved planning workflow
- verify_sop: rewritten inspired by Claude Code verification agent design
  - Added two failure mode anchoring (verification avoidance & seduced by first 80%)
  - Anti-rationalization checklist
  - BEFORE PASS/FAIL guardrails
  - Compressed from 104 to 65 lines while increasing coverage
2026-04-16 18:42:03 +08:00
Liang Jiaqing 47bcb1b01a fix: compatible with Streamlit 1.56+ iframe API change 2026-04-16 17:48:58 +08:00
Liang Jiaqing 9e18ce26dc fix: empty file ZeroDivisionError in file_read; remove SiderLLMSession; init global_mem with header 2026-04-16 16:04:18 +08:00
Liang Jiaqing 8e2a96167e docs: add token efficiency highlight to Core Features (EN+CN) 2026-04-16 15:47:41 +08:00
LJQ 315ce293df Merge pull request #76 from kuishou68/fix/issue-75-scheduler-errors
fix: guard _parse_cooldown against malformed repeat values; close file handle with context manager
2026-04-16 13:22:48 +08:00
Liang Jiaqing 0591b68ff6 stapp: incremental freeze rendering for streaming segments
Replace full-redraw approach with frozen/live slot pattern:
- Completed segments are frozen into individual st.empty() slots
- Only the last (active) segment is re-rendered on each tick
- Reduces flicker and improves performance for long multi-turn conversations
2026-04-16 13:10:00 +08:00
Liang Jiaqing 3d7832a750 feat: multi code_run support, inline_eval logging & cwd fix, tool reinject upgrade 2026-04-16 12:56:17 +08:00
Cocoon-Break fefd9b99cc fix: guard _parse_cooldown against malformed every_ repeat; close file handle with context manager (Closes #75)
Signed-off-by: Cocoon-Break <54054995+kuishou68@users.noreply.github.com>
2026-04-16 11:31:35 +08:00
Liang Jiaqing a3c15c54af docs: add Star History chart to README 2026-04-16 07:58:48 +08:00
Liang Jiaqing 85480b16a6 docs: use wechat_group4.jpg to bypass CDN cache 2026-04-15 23:57:11 +08:00
Liang Jiaqing 4d6b8cd982 docs: update wechat group labels (Group 4 + Group 3) 2026-04-15 23:54:57 +08:00
Liang Jiaqing 39f90149fb fix: change desktop pet port 51983->41983 (avoid Hyper-V reserved range) & update wechat group QR code 2026-04-15 23:50:48 +08:00
Liang Jiaqing bdd2f3c2cd docs: add Chinese illustrated getting-started guide 2026-04-15 23:22:45 +08:00
Liang Jiaqing bd9e45f761 feat(file_read): fuzzy file suggestion on not-found with history dirs; sync L1 template 2026-04-15 20:58:29 +08:00
Liang Jiaqing da8bba99a5 fix: batch_process missing raw_dir arg causing L4 archive to fail 2026-04-15 18:31:29 +08:00
Jiaqing Liang e3a2526932 feat: add plan mode (enter/exit/completion check/verification gate/periodic hints) 2026-04-15 17:02:54 +08:00
LJQ d482f30188 Merge pull request #69 from kagura-agent/feat/github-contribution-sop
feat: add GitHub contribution SOP (memory/github_contribution_sop.md)
2026-04-15 15:42:22 +08:00
Kagura b2465df4e0 feat: add GitHub contribution SOP 2026-04-15 15:06:04 +08:00
wjl2023 bb0290f576 fix: normalize bubble text and adjust padding for better readability 2026-04-15 14:43:14 +08:00
Jiaqing Liang 16fe369f5a preserve history across llm switch 2026-04-15 14:34:06 +08:00
Jiaqing Liang d111db5454 fix prompt flow 2026-04-15 13:57:36 +08:00
Liang Jiaqing 273150a9ce feat: publish memory_cleanup_sop 2026-04-15 09:26:46 +08:00
LJQ 39bc29d93d Merge pull request #67 from shenhao-stu/main
config: update mykey_template.py with new configuration examples
2026-04-15 09:24:21 +08:00
Shen Hao ee4437d053 bug: fix a tiny bug about crs antigravity's config 2026-04-15 04:28:27 +08:00
Shen Hao 222a144858 config: update mykey_template.py with new configuration examples 2026-04-15 03:54:42 +08:00
Liang Jiaqing 97560a4fba feat: publish ocr_utils, vision_sop, ui_detect to public repo 2026-04-15 00:00:14 +08:00
Liang Jiaqing d0a9b5de08 fix: use script_dir for cwd path instead of relative path 2026-04-14 23:38:32 +08:00
Liang Jiaqing d18c8438d3 refactor: use with statement for requests.post in NativeClaudeSession 2026-04-14 23:35:51 +08:00
Liang Jiaqing 74abb77a0b refactor: rename default_model->model, add thinking_type support, fix pet urlopen error handling 2026-04-14 21:14:05 +08:00
Liang Jiaqing 0551967a7e style: arrange 3 QR code images horizontally in a table row 2026-04-14 18:47:00 +08:00
LJQ 2e0be429d9 Merge pull request #63 from JinyiHan99/docs/add-group-qr-codes
docs: add community group QR codes to README
2026-04-14 15:12:39 +08:00
Jinyi Han 9f004c059d docs: add community group QR codes to README 2026-04-14 15:08:22 +08:00
Liang Jiaqing 793bb3f1b8 refactor(pet): extract PetBase, deduplicate _start_server/safe wrappers, add singleton guard 2026-04-14 12:05:55 +08:00
LJQ fae778bb51 Merge pull request #62 from shenhao-stu/main
docs: add Linuxdo links
2026-04-14 11:13:37 +08:00
Shen Hao 5179fb2fac Merge branch 'lsdefine:main' into main 2026-04-14 11:10:31 +08:00
Shen Hao 7cb3982d89 docs: add friendly links section to README
Added a section for friendly links and community support.
2026-04-14 11:09:49 +08:00
Liang Jiaqing 0b54df85be feat: desktop pet v2 with 8 skins and state notifications 2026-04-14 11:07:58 +08:00
Shen Hao 3cc05be36b docs: add links to LinuxDo and support section
Added links to LinuxDo community and support section.
2026-04-14 11:07:38 +08:00
Liang Jiaqing acb0f93d5f refactor: reorder mykey_template, add name field to native configs 2026-04-13 22:48:51 +08:00
Liang Jiaqing 127a4e631c feat: add /session.key=value system command to set LLM params at runtime 2026-04-13 22:44:17 +08:00
Liang Jiaqing 499f0119bb refactor llm session params and thinking parsing 2026-04-13 20:20:31 +08:00
Liang Jiaqing 0da9bd15c9 refactor: turn_end_callback + desktop pet + keychain
- agent_loop: next_prompt_patcher -> turn_end_callback with full context
- agent_loop: exit logic unified (break + callback), no early return
- ga: summary extraction moved from tool_after_callback to turn_end_callback
- ga: _turn_end_hooks support for external subscribers
- stapp: desktop pet button with HTTP status push
- keychain: XOR-masked secret storage with SecretStr
- gitignore: whitelist keychain.py
2026-04-13 18:27:17 +08:00
LJQ 9da32c07ce docs: update README recent highlights and group image (#59)
docs: update README recent highlights and group image
2026-04-13 16:20:56 +08:00
Xinyi Wang c80cc2d823 docs: update README recent highlights and group image 2026-04-13 16:05:04 +08:00
Jiaqing Liang 086599a5d6 fix: scroll ghost height reflow via overflow toggle; extend cache markers to last 2 user msgs; simplify cursor & merge JS fixes 2026-04-13 14:59:38 +08:00
Liang Jiaqing ecb8ac9a80 fix(stapp): eliminate ghost duplicate of last text block on new message
Root cause: stream loop emitted a bare st.empty() heartbeat per tick, accumulating
dozens of empty slots under chat_message. On the next rerun the history replay
path had no such slots, so Streamlit's incremental DOM diff misaligned and left
the previous render of the last text block as a grayed-out ghost.

Fix: unify streaming and history replay under the same shell
(slot = st.empty(); with slot.container(): render_segments(...)). Heartbeat now
re-enters the same slot each tick; when response is unchanged Streamlit's diff
is a no-op (no flicker), but the container() call still lets StopException
propagate so abort keeps working. Simplified render_segments from 5 params
(placeholders/rendered_cache/force_text) to 2 (segments, suffix).

Net: +19 -20 lines. Verified: no ghost, abort interrupts mid-stream, per-turn
fold-collapse preserved.
2026-04-12 15:22:56 +08:00
Liang Jiaqing d94e404f64 fix: use local var for claude tools conversion, avoid mutating self.tools 2026-04-12 14:46:45 +08:00
Liang Jiaqing 142de0c45b refactor: remove claude_tools_format flag, auto-convert tools in NativeClaudeSession.raw_ask 2026-04-12 14:43:46 +08:00
Liang Jiaqing 2b5cbff7be feat: --verbose flag, lazy mykeys loading, temperature config support
- agentmain: add --verbose arg for subagent monitoring mode
- llmcore: lazy-load mykeys/proxies via module __getattr__
- llmcore: fix auto_make_url regex for trailing slash cases
- llmcore: support temperature override from session config
- docs: update subagent.md with --verbose usage note
2026-04-12 14:29:32 +08:00
Liang Jiaqing 9e03a675ae fix: MixinSession copy before override & add no-tools warning; refine subagent SOP 2026-04-11 18:50:26 +08:00
Liang Jiaqing 946c239c03 refactor: rename mem_scanner→procmem_scanner, add L4 layer, remove make_prompts.py 2026-04-11 18:30:00 +08:00
Liang Jiaqing 6f1585e88f fix: comment out MixinSession copy to prevent tools not propagating to original session 2026-04-11 18:25:54 +08:00
Liang Jiaqing 62ac73c773 feat: task模式干预机制 _stop/_keyinfo/_intervene + consume_file 2026-04-11 17:14:35 +08:00
Liang Jiaqing 3531146792 fix: restore stop signal detection broken by render_segments cache optimization
- stapp.py: add st.empty() after render_segments to force Streamlit StopException check on every iteration (incl. heartbeat)
- agentmain.py: fix nround type check, fix timeout comment
2026-04-11 16:25:19 +08:00
Liang Jiaqing de8adf76a9 feat: L4 session archiver + scheduler cron integration
- Add compress_session.py: compress raw model_responses into L4 archives
- Integrate 12h silent cron into scheduler.check() (runs before TASKS dir check)
- Whitelist compress_session.py in .gitignore (archives excluded)
- llmcore: refactor SSE warn handling, max_retries default 2->1
- scheduler: remove unused health_check(), INTERVAL 60->120
2026-04-11 15:55:35 +08:00
Liang Jiaqing a737523f0a llmcore: remove runtime model param, upgrade Claude beta headers, softer backoff 2026-04-11 14:34:27 +08:00
Liang Jiaqing 7bfd6e43e6 fix: _fix_messages for Claude API compliance, raw_ask simplify, no_tool orphan fix, summary extraction improvement 2026-04-11 13:24:33 +08:00
Liang Jiaqing 5e28902cb4 cleanup: rm user.js, untrack copilot_proxy.pyw; ljqCtrl: DPI via GDI + GrabWindow(hwnd) 2026-04-10 22:17:24 +08:00
Jiaqing Liang 5a1d3a41da fix: handle window object serialization in CDP bridge; improve file_write error msg; minor llmcore style cleanup 2026-04-10 14:04:41 +08:00
Liang Jiaqing 4b18ad683f Fix tool call conversion and working checkpoint result 2026-04-10 10:45:00 +08:00
Liang Jiaqing cc283a96f3 fix: verbose logic & insight wording & file_read anchor skip 2026-04-10 09:56:22 +08:00
Liang Jiaqing 2977be33c6 Update llmcore truncation, adb_ui parsing, stapp regex, and subagent docs 2026-04-09 18:43:20 +08:00
Liang Jiaqing 6628a3d987 tune: read_timeout 60s, simphtml print/newTabs fix, remove obsolete iframe sop 2026-04-08 20:45:06 +08:00
Liang Jiaqing 1c561db0b9 feat: enhance CDP bridge management and fix JS falsy returns
- background.js: Add management API, auto-return for JS, refactor new tab capture
- simphtml.py: Fix falsy return value bug, flatten return structure
- agent_loop.py: Simplify UI icons
- manifest.json: Add management permission
- tools_schema: Optimize prompts for web tools
- tmwebdriver_sop.md: Update documentation
2026-04-08 18:28:33 +08:00
LJQ 813eb29692 Merge pull request #57 from YangChen-pro/fix/restore-native-compat
修复 /restore 的日志路径兼容,并支持 Native 日志恢复
2026-04-08 15:45:15 +08:00
YangChen-pro 520550a2b9 Fix /restore path and native log compatibility 2026-04-08 10:16:55 +08:00
Liang Jiaqing f4eec277c5 docs: fuzzy numbers in README (3K/~100/9 tools) 2026-04-07 22:18:18 +08:00
Liang Jiaqing 1e39032c67 refactor: optimize non-verbose output - tool icons, compact args, clean content 2026-04-07 19:03:43 +08:00
Jiaqing Liang 25fe3e42fd fix: popup.js action->cmd; add disable_dialogs.js with toast; update manifest 2026-04-07 12:17:18 +08:00
Liang Jiaqing b525684378 fix: hoist filter should not catch sticky elements or oversized containers
- Remove position:sticky from hoist criteria (sticky is scroll-following, not popup)
- Add cover < 1.0 upper bound (real dialogs do not exceed viewport area)
2026-04-07 10:17:55 +08:00
Liang Jiaqing 054674baa1 simphtml: elementFromPoint弹窗修复 + findMainList评分优化 + _ljq ID注入 + smart_truncate重写 2026-04-07 10:10:27 +08:00
Liang Jiaqing b4741a9a39 Optimize: force aggressive tag compression before history truncation to save context 2026-04-06 22:50:23 +08:00
Liang Jiaqing 53a859058a Refactor: simplify smart_format to string-only and enhance tool output truncation limits 2026-04-06 22:50:23 +08:00
LJQ f44c05931c Merge pull request #55 from JinyiHan99/update-wechat-group-image
Update wechat_group.jpg
2026-04-06 21:57:09 +08:00
Jinyi Han 34d945d425 Update wechat_group.jpg 2026-04-06 21:52:52 +08:00
Liang Jiaqing 8e829730a9 smart truncation, iframe preservation, streamlit filter
- simphtml: truncate_biggest replaces naive string cut, targets fattest content block via 70% drill-down
- simphtml: iframe children preserved through div proxy (bypass BS4 limitation)
- background/content.js: filter streamlit tabs and skip content script injection
- ga.py: file_content tag regex allows attributes; maxchars 38k->35k
2026-04-06 12:19:20 +08:00
Liang Jiaqing aae6d810cd NativeClaudeSession: auto-convert OpenAI tools format to Claude; skip in NativeOAISession 2026-04-04 07:03:18 +08:00
Liang Jiaqing 14125ed57c refactor: move tools ownership from NativeToolClient to Session layer
- tools state now held by Session (NativeClaudeSession.tools)
- MixinSession.__setattr__ broadcasts tools/system to all sub-sessions
- NativeToolClient no longer duplicates tools storage
- fix: use type(s) is instead of isinstance to avoid catching NativeOAISession subclass
2026-04-03 23:05:28 +08:00
Liang Jiaqing 555eeabf56 simphtml: SVG className fix, CSS.escape, DOMRect compat, cutlist tuning; tmwebdriver_sop update 2026-04-03 22:34:41 +08:00
Liang Jiaqing 97abc43a40 refactor(simphtml): rewrite list detection & cutlist for multi-list support
- simphtml: replace center-point ancestor-chain approach with global container scan;
  support multiple lists per page; add container-scoped selector prefixes;
  inline FAKE ELEMENT hints with hidden item previews; remove findMainContent
- ga: hot-reload simphtml on each web_scan; fix file_read total_lines for keyword search;
  add errors='replace' for global_mem encoding safety
- llmcore: stabilize NativeClaude session/device IDs across requests;
  rename no_system_prompt to fake_cc_system_prompt; deep-copy message content
- launch: adjust window width 700->600
2026-04-03 19:38:51 +08:00
Liang Jiaqing 4a5043a658 Refactor: unify render_segments logic, add streaming cursor animation, and clean up unused container parameter 2026-04-03 12:14:08 +08:00
Liang Jiaqing 4e2b806917 Refactor: use native st.expander for folding turns, optimize stream trigger, and adjust window width 2026-04-03 11:47:07 +08:00
Liang Jiaqing 5bc763d319 fix(simphtml): improve bounding box calculation for transparent containers by using children union rect 2026-04-03 11:14:25 +08:00
Liang Jiaqing da40ba413b feat: fold_turns UI折叠 + 放宽输出限制 + 压缩history/key_info标签 + context_win调大 2026-04-03 10:43:15 +08:00
Liang Jiaqing 8d537e954b fix: simphtml visibility/dialog/SVG improvements & minor agent fixes
- simphtml: rewrite visibility inheritance (area-based maxC), add isVisible to childrenInfo,
  fix className SVG compat (getAttribute), expand isDialog detection, hoist deep fixed dialogs,
  clear SVG attrs, fix warning message concat
- ga: return error messages instead of None for code_run/web_execute_js failures
- stapp: disable unsafe_allow_html
- code_run_header: add excepthook hint for ImportError/AttributeError
- mykey_template: remove stale prompt_cache comment
2026-04-03 09:40:09 +08:00
Liang Jiaqing b0d4563ae8 Refine llmcore debug and tool parsing 2026-04-02 21:16:45 +08:00
Liang Jiaqing 88f32b208b feat: support NativeToolClient and optimize tool use format for native API 2026-04-01 23:09:48 +08:00
Liang Jiaqing 629e57ad83 Refactor: 统一消息格式和Session架构重构
核心改动:
- 统一所有Session内部使用Claude content-block格式
- 引入BaseSession基类,简化代码结构
- tool_results从字符串改为结构化字典列表
- NativeClaudeSession增强:支持cr_token、metadata、thinking提取
- ToolClient简化:删除structured分支,统一使用protocol prompt
- MixinSession支持按名称选择session
- ljqCtrl_sop增加DPI坐标陷阱警告
2026-04-01 22:20:18 +08:00
Liang Jiaqing 368d68baa5 fix: remove stray brace in tool_pattern regex 2026-04-01 09:47:53 +08:00
Liang Jiaqing 1c38412e42 revert: 恢复工具调用正则最小长度限制 2026-04-01 09:35:37 +08:00
Liang Jiaqing 404ea1947b fix: llmcore工具调用正则兼容tool_call标签,最小长度改为0 2026-04-01 09:30:55 +08:00
Liang Jiaqing d922303645 TMWebDriver: add debug logs + CDP bridge refactor (action→cmd, extract buildExecScript, handleWsExec routing) 2026-03-31 20:37:05 +08:00
Jiaqing Liang 86210296c1 fix: MV3 SW keepalive for WS connection via periodic alarm ping 2026-03-31 11:19:48 +08:00
Liang Jiaqing 106c6b04f1 docs: update save_to_file desc, add iframe scan note, cdp_bridge notice 2026-03-31 09:04:22 +08:00
Liang Jiaqing 72a85a0823 fix: buildCdpScript add smartProcessResult to prevent CDP serialization error on complex DOM (GitHub issues, Gmail etc); update SOP docs 2026-03-30 22:30:34 +08:00
Liang Jiaqing 18d5aafed8 gitignore: universal __pycache__ exclusion, remove tracked .pyc files 2026-03-30 20:39:46 +08:00
Liang Jiaqing 117028b5d9 simphtml: refine pathType detection, simplify secondary marking, init response, reduce sleep 2026-03-30 20:37:27 +08:00
Liang Jiaqing f320d141a0 feat: CDP bridge extension integration - ext_ws session type, DOM-channel CDP/batch/cookies/tabs, autofill bringToFront fix, await support in SOP 2026-03-30 20:01:27 +08:00
Liang Jiaqing bc5d1eafaf feat: add --bg/--input flags to agentmain, rename & simplify subagent SOP 2026-03-30 15:03:28 +08:00
Liang Jiaqing 6cec6d8014 feat: autonomous SOP refactor + helper.py with auto date/numbering
- Refactored autonomous_operation_sop.md with clearer path warnings and workflow
- Added helper.py: complete_task() auto-prepends R-number + date to history
- Updated scheduled_task_sop.md and web_setup_sop.md
2026-03-30 14:30:30 +08:00
Liang Jiaqing 473659ba6e refactor: agent loop done_hooks, inline_eval, scheduler logging & health_check
- agent_loop: for→while, _done_hooks callback mechanism, max_turns=40
- agentmain: pass self to handler, abort clears task queue
- stapp: heartbeat yield for Streamlit StopException detection
- ga: _inline_eval param for in-process eval, no_tool thresholds tuned
- scheduler: logging, max_delay_hours, weekday repeat, health_check()
2026-03-30 13:51:21 +08:00
Liang Jiaqing 7b704b5dbc fix: _parse_openai_sse防御None值迭代(tool_calls/delta可能为null) 2026-03-30 11:19:56 +08:00
Liang Jiaqing 5459ebbb59 fix: output窗口滚动漂移 - 用行偏移代替fraction恢复位置,无变化时跳过刷新 2026-03-29 21:32:40 +08:00
Liang Jiaqing f0dea09123 fix: fallback tool_use parser handles both with/without space in type key 2026-03-29 21:04:34 +08:00
Liang Jiaqing 58e2f45793 fix: dump tool_use as array format to match fallback parser 2026-03-29 20:54:25 +08:00
Liang Jiaqing 844573387d fix: convert tool_use blocks to text in NativeOAISession history to avoid API content type error 2026-03-29 19:08:20 +08:00
Liang Jiaqing 08ac18be6e fix: code_run encoding compat; wechat truncate long code blocks & limit msg count 2026-03-29 17:43:06 +08:00
Liang Jiaqing 52151853b3 fix: encode str input when text=True is intercepted by header wrapper 2026-03-29 15:15:35 +08:00
Liang Jiaqing 53c492b682 agentmain: auto-switch cn schema for glm/minimax/kimi; tools_schema: trim descriptions; hub: fix scroll position on refresh 2026-03-29 15:08:11 +08:00
Liang Jiaqing 18ef86b69d llmcore: move logs to temp/model_responses/ subdir; enhance error prefix detection 2026-03-29 15:06:19 +08:00
LJQ d76842c549 Merge pull request #52 from octo-patch/feature/add-minimax-provider
feat: add MiniMax as first-class LLM provider
2026-03-29 14:51:02 +08:00
Liang Jiaqing fe7e4c0952 refine: tool schema descriptions, docstring format, context_win 18000→20000 2026-03-29 13:06:06 +08:00
Liang Jiaqing 45679509ea MixinSession: spring-back to primary + translate tools_schema to English 2026-03-29 12:48:36 +08:00
Liang Jiaqing 226fa69cc3 refactor MixinSession._raw_ask: simplify retry logic, skip yielding first-only error chunk 2026-03-29 10:53:26 +08:00
PR Bot b6f697d40f feat: add MiniMax as first-class LLM provider
- Temperature auto-clamping for MiniMax models: (0, 1] range enforcement
- <think> tag handling for MiniMax M2.7 reasoning output (alongside existing <thinking> support)
- MiniMax configuration example in mykey_template.py
- Updated README.md and GETTING_STARTED.md with MiniMax provider docs
- 19 unit tests + 6 integration tests (3 live tests with MINIMAX_API_KEY)

MiniMax models (M2.7, M2.7-highspeed, M2.5, M2.5-highspeed) are accessed via
the standard OpenAI-compatible interface at https://api.minimax.io/v1, using
the existing LLMSession with an 'oai'-prefixed config key.
2026-03-29 07:09:46 +08:00
Liang Jiaqing 1d94384ee5 feat: add Qt/Streamlit alternative frontends, fix hub output auto-scroll, update README 2026-03-28 20:56:14 +08:00
Liang Jiaqing a5dbce396a refactor: remove XAI/Gemini sessions, add MixinSession fallback, improve error handling
- Remove GeminiSession and XaiSession from llmcore.py
- Add MixinSession: multi-endpoint fallback with exponential backoff
- ga.py: handle empty/incomplete/max_tokens responses
- Update mykey_template.py and GETTING_STARTED.md accordingly
2026-03-28 08:14:35 +08:00
Liang Jiaqing 38e2a460e3 refactor: optimize code extraction priority in tool handlers & update autofill SOP 2026-03-27 19:43:46 +08:00
Liang Jiaqing a1c289dbd1 feat(web_driver): support await in executeCode via AsyncFunction fallback 2026-03-27 18:57:47 +08:00
Liang Jiaqing 55a5eae4f6 perf: replace MutationObserver with setInterval to fix performance issue 2026-03-27 18:41:39 +08:00
Jiaqing Liang 04448dcee9 update userscript guard and scheduler flag 2026-03-27 15:41:42 +08:00
Jiaqing Liang f6c5011eb0 refactor: rewrite GETTING_STARTED, cleanup README, add hub.pyw, remove QUICK_START.pdf 2026-03-27 12:58:13 +08:00
Liang Jiaqing ff258dd0a9 refine: context window, timeout, abort guard, code_run multi-call check, clean logs 2026-03-26 21:31:48 +08:00
Liang Jiaqing 47ad98239f enhance SSE parsing, add stream error/truncation detection, fix tool_call fallback extraction 2026-03-26 18:42:09 +08:00
Liang Jiaqing 650c14dc1a update wechat group QR code (group 2, as group 1 is full) 2026-03-26 13:37:41 +08:00
Liang Jiaqing 551666a53b refactor: unify OpenAI streaming into _openai_stream(), add Responses API support
- Extract shared _openai_stream() from LLMSession and NativeOAISession
- Add Responses API compatibility: system->developer role, flat tools format, no temperature
- Improve error handling: read body before raise_for_status in stream mode
- Add retry mechanism to NativeOAISession (was missing)
- Add api_mode, connect_timeout, read_timeout config to NativeOAISession
2026-03-25 23:50:28 +08:00
Liang Jiaqing 507e886322 refactor llmcore: extract _parse_claude_sse, simplify ToolClient, enhance logging and context trimming 2026-03-25 21:41:33 +08:00
Liang Jiaqing 1b60d2c773 minor: tweak prompt wording, improve OAI history compression 2026-03-25 10:00:16 +08:00
Liang Jiaqing 85250c2694 fix: unify assistant content format for prompt caching to work 2026-03-25 09:45:43 +08:00
Liang Jiaqing b92d2bc97d fix: always compress history tags, adjust context trim thresholds 2026-03-25 08:37:46 +08:00
Liang Jiaqing ddce7ef549 feat: reflect hot-reload + enhance prompt caching & history compression 2026-03-24 22:58:20 +08:00
Liang Jiaqing 274bbcb9e4 docs: update WeChat group QR code 2026-03-24 22:55:04 +08:00
Liang Jiaqing 28f22c3973 feat: add prompt caching for NativeClaudeSession 2026-03-24 20:00:44 +08:00
Liang Jiaqing f9a0aa71fa fix: add tool_result placeholder for NativeClaudeSession to prevent 502 2026-03-24 19:54:27 +08:00
Liang Jiaqing 651e0d2bc8 refactor: extract code_run header to external file, add subprocess gbk fallback patch 2026-03-24 09:52:55 +08:00
Liang Jiaqing 5643bfb5f0 code_run自动注入常用import+memory路径; ljqCtrl_sop补充文本输入方法 2026-03-24 00:00:20 +08:00
Liang Jiaqing 515b0cd985 feat: unified media download in wechatapp + proxy support for NativeOAISession 2026-03-23 20:57:39 +08:00
Liang Jiaqing 60f40e3c5e feat(wechat): add send_file via CDN upload; fix _strip_md robustness
- Add WxBotClient.send_file() with AES-128-ECB encryption and CDN upload
- Extract [FILE:] tags before _clean() to preserve filenames
- Fix _strip_md: support 3+ backticks via backreference, stop stripping underscores
- Remove verbose key_info echo from update_working_checkpoint
2026-03-23 17:55:20 +08:00
Jiaqing Liang eb8834b9df feat: enhance text_only mode - add form element annotations, block-level line breaks, and whitespace cleanup 2026-03-23 15:26:05 +08:00
Jiaqing Liang b4bdaf11da style: remove trailing periods in tool schema descriptions (-31 tokens/round) 2026-03-23 13:56:03 +08:00
Jiaqing Liang 47e9a55feb feat: add file ref expansion syntax for file_patch and file_write 2026-03-23 13:50:11 +08:00
Jiaqing Liang 66dff812fb docs: add personal WeChat bot frontend to Latest News 2026-03-23 12:05:23 +08:00
Jiaqing Liang 4b4dc51336 fix: tool_use regex negative lookahead to prevent cross-tag matching; tgapp relative path resolve 2026-03-23 11:46:10 +08:00
Liang Jiaqing 6f6d9f6570 feat: add NativeOAISession + minor fixes (code_run script fallback, thinking prompt tags) 2026-03-22 22:19:46 +08:00
Liang Jiaqing fd5150ffad feat: strip markdown formatting in wechat output 2026-03-22 20:45:40 +08:00
Liang Jiaqing ba6ab901f8 feat: add WeChat bot frontend (wechatapp.py) 2026-03-22 20:20:33 +08:00
Liang Jiaqing 8794c85cfe docs: update paths for frontends/ migration 2026-03-22 17:47:56 +08:00
Liang Jiaqing 87d557260c refactor: move frontend apps to frontends/ and fix path references 2026-03-22 17:45:20 +08:00
Liang Jiaqing a9f0e9628f fix: llm session tweaks & agent init refactor 2026-03-22 09:55:24 +08:00
Liang Jiaqing 122b6c431c improve mykey_template: add apibase rules, all session types with full cfg comments 2026-03-21 12:23:58 +08:00
Liang Jiaqing fdc24e4dd0 add NativeClaudeSession+NativeToolClient, support parallel tool calls, fix tool_calls[-1:], add llm log 2026-03-21 12:13:46 +08:00
Liang Jiaqing 85f17db487 feat: prompt_cache skip compress + style 2026-03-20 23:47:15 +08:00
Jiaqing Liang 05800a4f48 Merge PR #40: Fix feishu group chat reply and add docs 2026-03-19 14:42:20 +08:00
Jiaqing Liang 4ebb421fd5 Merge PR #39: Add reasoning_effort config support 2026-03-19 14:38:23 +08:00
Jinyi Han 34452e2d4c fix: 飞书机器人群聊回复逻辑 2026-03-19 09:57:00 +08:00
YangChen-pro 70ed09ad2b Support configurable OpenAI reasoning effort 2026-03-18 22:54:58 +08:00
Jinyi Han 34abe90bb2 Merge upstream/main with local fsapp.py preserved 2026-03-18 12:15:49 +08:00
Liang Jiaqing 00038cd1ae refactor: unify LLM session constructors to accept cfg dict 2026-03-17 16:52:12 +08:00
Jiaqing Liang d896355bad fix: key_info regex residue accumulation bug - non-greedy match stopped at first 工作记忆 leaving tail behind 2026-03-16 13:57:52 +08:00
Jiaqing Liang 065a5ee4bf refactor: working memory dict, merge chat paths, simplify SKILL docs 2026-03-16 13:18:41 +08:00
Jiaqing Liang 3e6be97ec9 feat: add IME composition fix for macOS (closes #33)
Based on PR #33 by UnsongK. Compressed to 4 lines with OS guard
so Windows users are completely unaffected.
2026-03-16 13:15:48 +08:00
Jinyi Han 806f1e9767 update the operation in feishu app 2026-03-15 23:15:13 +08:00
LJQ 5fdc2c60b2 Merge pull request #31 from wjl2023/fix-readme-date
fix: Update release date to 2026-03-10
2026-03-14 21:14:44 +08:00
wjl2023 523ce6133e fix: Update release date to 2026-03-10 in README 2026-03-14 21:00:05 +08:00
LJQ da00664a00 Merge pull request #29 from wjl2023/fix-moonshot-temperature
fix: Add moonshot API temperature=1.0 constraint
2026-03-14 20:47:44 +08:00
wjl2023 2809a54b29 fix: Add moonshot API temperature=1.0 constraint
- Moonshot API (like Kimi) only accepts temperature=1.0
- Added 'moonshot' detection alongside 'kimi' in both raw_ask methods
- Prevents 400 errors when using moonshot models
2026-03-14 20:44:29 +08:00
Liang Jiaqing 68a936eb2c debug print result; kimi temp=1.0 compat 2026-03-14 20:34:57 +08:00
Liang Jiaqing bbde5399cf add skill_search SOP to tracked memory files 2026-03-14 20:33:54 +08:00
Liang Jiaqing afa288f240 docs: add note about 2 memory management tools alongside 7 atomic tools 2026-03-14 08:53:11 +08:00
Liang Jiaqing 75a0e86820 docs: update README with enhanced structure, news section, bot interfaces, and comparison table (from hjy) 2026-03-14 08:49:07 +08:00
Jinyi Han 0839edd134 update the readme 2026-03-14 01:49:03 +08:00
Jinyi Han d8a32313a8 update the readme 2026-03-14 01:47:29 +08:00
Jinyi Han 80ced3081d update the readme 2026-03-14 01:45:14 +08:00
Jinyi Han ab84bc686c update the readme 2026-03-14 01:42:41 +08:00
Jinyi Han cc450a87ab update the readme 2026-03-14 01:14:51 +08:00
Jinyi Han 7977820500 update the readme 2026-03-14 01:12:22 +08:00
Jinyi Han 5a9736b7f0 update the readme 2026-03-14 00:52:51 +08:00
LJQ d5f1d03a59 Add MIT License 2026-03-13 22:31:29 +08:00
Liang Jiaqing 90bfd2324a Remove license section from README 2026-03-13 22:14:37 +08:00
Liang Jiaqing 0100bf10af refactor: replace insight template comments with real out-of-box entries 2026-03-13 20:50:04 +08:00
Liang Jiaqing 908c1a52ed perf: localhost→127.0.0.1 fix IPv6 2s delay; simphtml radio/checkbox/svg/dropdown fixes; poll interval 0.5→0.2s 2026-03-13 20:13:33 +08:00
LJQ 081aa12657 Merge PR #25: Refactor chat app adapters with shared chatapp_common
Refactor text-first chat app adapters to reduce duplicated scaffolding
2026-03-13 18:28:27 +08:00
Xinyi Wang 44bda94a36 Refactor Feishu adapter to trim duplicated helpers 2026-03-13 16:51:50 +08:00
Xinyi Wang f16b5b7214 Refactor chat app adapters to reduce duplicated scaffolding 2026-03-13 16:46:16 +08:00
LJQ b380e395c0 Merge pull request #24 from ViviqwerAsd/codex/chat-app-adapters
Add chat app adapters for QQ, Feishu, WeCom, and DingTalk
2026-03-13 16:14:44 +08:00
Vivi 8n24 6fe93e9cb4 Add chat app adapters for QQ, Feishu, WeCom, and DingTalk 2026-03-13 16:04:18 +08:00
Liang Jiaqing 3f66368689 add autonomous_operation_sop folder to tracking; update .gitignore whitelist; agent_loop changes 2026-03-13 14:16:43 +08:00
Liang Jiaqing fd7bf241d0 rename sidercall.py to llmcore.py; add cache-aware cd to compress_history_tags 2026-03-13 13:21:32 +08:00
Liang Jiaqing 2a98cc44e5 chore: update repo name from pc-agent-loop to GenericAgent 2026-03-13 12:48:38 +08:00
Liang Jiaqing 029cd91c9a refine autonomous_sop single-task enforcement + subagent input.txt length rule 2026-03-13 09:51:14 +08:00
Liang Jiaqing 3942fe2056 minor: round-end newline, web error msg, autonomous subagent review, tmwebdriver troubleshoot 2026-03-12 22:24:35 +08:00
Liang Jiaqing f52975fd95 Merge PR #23: update plan_sop.md and subagent_sop.md 2026-03-12 22:22:33 +08:00
Jiaqing Liang 9f10e55d5e fix: defensive CDP config init (makedirs+try/except); tweak autonomous sop wording 2026-03-12 12:59:05 +08:00
Jiaqing Liang 03069dbd89 修复自主行动SOP:规划完立即停止+避免模仿低价值任务 2026-03-12 09:29:20 +08:00
Jiaqing Liang 1520a85a9e simphtml: remove dead code (js_cleanDOM, isValidInteractiveElement, redundant return) 2026-03-11 15:18:23 +08:00
Jiaqing Liang 33f2df4270 feat: 添加web_scan的text_only参数支持Shadow DOM文本提取 2026-03-11 14:48:19 +08:00
Jiaqing Liang 006b915533 refactor: scheduler to JSON+cooldown, update SOP 2026-03-11 12:11:22 +08:00
Liang Jiaqing 5dc44ba637 refactor: 优化自主行动SOP - 强化路径提示和格式规范防止history错乱 2026-03-11 10:01:42 +08:00
Liang Jiaqing f0e5d25636 add factory memory entries to insight template 2026-03-10 22:08:57 +08:00
Jiaqing Liang 389fd09765 fix: disable unsafe_allow_html to prevent XSS in chat rendering 2026-03-10 13:55:40 +08:00
Liang Jiaqing 2b387f2dcf ref: simplify feishu history injection, tune web_scan/plan_sop/driver 2026-03-09 08:55:22 +08:00
Liang Jiaqing 378799f8ea fix: /status命令 is_busy() → is_running 2026-03-09 08:46:05 +08:00
AspasZhangand张洲嘉 7d444d065e feat: 添加飞书机器人集成 (#13)
* feat: add Feishu bot integration

- Add fsapp.py: Feishu bot webhook handler (root directory)
- Add assets/SETUP_FEISHU.md: Setup guide for Feishu integration
- Add assets/install_python_windows.bat: Windows Python installer script

* fix: 历史注入仅在飞书场景生效,避免混入本地CLI历史

* fix: fsapp调用put_task时传source='feishu'以触发历史注入

---------

Co-authored-by: 张洲嘉 <zhangzhoujia@zhangzhoujiadeMacBook-Air.local>
2026-03-09 08:45:11 +08:00
Liang Jiaqing 14f1009ddc fix: unified auto_make_url for all API base_url formats, closes #12 2026-03-08 10:24:54 +08:00
Liang Jiaqing 5eac3c1027 refactor: unified mykeys dict, support mykey.py and mykey.json dual format 2026-03-07 23:33:13 +08:00
Liang Jiaqing 61d07a16a6 update: minor fixes to agentmain, mykey_template, sidercall 2026-03-07 22:34:23 +08:00
Liang Jiaqing 7ab47af8e8 fix: zIndex auto coverage bug + monitor maxchars unlimit + autofill CDP sop update 2026-03-07 10:10:26 +08:00
Liang Jiaqing fd9f172588 style: simplify open/write + shorten prompt text 2026-03-06 21:49:43 +08:00
Liang Jiaqing 2156bc6fc6 restore context_win=10000 max_tokens=6144 2026-03-06 21:46:41 +08:00
Liang Jiaqing 959cf6a5a6 refactor: use script_dir for init paths 2026-03-06 21:44:49 +08:00
Liang Jiaqing cccbecb974 Merge PR #6: 火山引擎适配 + 路径修复
# Conflicts:
#	.gitignore
#	agentmain.py
2026-03-06 21:43:04 +08:00
Liang Jiaqing 2272f255f2 feat: add file sending support for telegram bot 2026-03-06 19:19:56 +08:00
Liang Jiaqing 524944dfc8 安全性提升:TID动态生成+Streamlit页面排除+工作记忆修复 2026-03-06 18:54:09 +08:00
Jiaqing Liang c6af92e612 feat: web_scan iframe穿透 + plan_sop术语优化 2026-03-06 00:09:52 +08:00
wangmc1024 ffd08bcd21 允许消息内容中包含HTML代码并直接渲染 | 将纯相对路径改为基于脚本文件的绝对路径,便于全局调用 2026-03-05 22:59:26 +08:00
wangmc1024 19d4e65133 增加了对火山引擎平台的模型适配,避免路径错误 2026-03-05 20:31:05 +08:00
Liang Jiaqing ed75d103f5 优化中间输出频率/plan_sop重构/autofill batch示例/README便携版链接 2026-03-04 17:14:13 +08:00
Liang Jiaqing 313f8a28f0 feat: CDP bridge batch support - cookies/tabs/cdp mixed commands, lazy attach, $N chain refs, file upload & screenshot verified 2026-03-04 13:06:21 +08:00
Liang Jiaqing 3f8407a853 CDP bridge扩展完善+SOP精简+insight修复+plan_sop+autonomous_sop更新 2026-03-04 12:02:50 +08:00
Liang Jiaqing ffe1f3c4c0 fix: update extension display name to TMWD CDP Bridge 2026-03-04 11:16:42 +08:00
Liang Jiaqing 0ab90af250 rename cookie_grabber -> tmwd_cdp_bridge (CDP bridge extension) 2026-03-04 11:15:32 +08:00
Liang Jiaqing dcf65d72c7 launch: bind streamlit to localhost + use high port range 18501-18599 2026-03-03 21:05:34 +08:00
Jiaqing Liang 9219042a8f feat: add --reflect mode with error handling + scheduler/autonomous scripts 2026-03-02 23:25:31 +08:00
Jiaqing Liang 5c3193f326 non-verbose output: exhaust stream silently, yield content only; add plan_sop; subagent_sop cleanup 2026-03-02 23:06:07 +08:00
Jiaqing Liang d010ab1995 refine insight: trim impl details, keep trigger keywords only (1049t) 2026-03-02 21:24:54 +08:00
Jiaqing Liang 066a2995e4 update start_long_term desc: remove single-call limit, add 15-turn rule 2026-03-02 17:25:37 +08:00
LJQ 82991c4a69 Merge pull request #2 from frank-marecogito/main
chore(gitignore): add mykey.py to ignore sensitive API keys and credentials
2026-03-02 09:49:19 +08:00
Frank Fu 4ba1dcc7a2 chore(gitignore): add mykey.py to ignore sensitive API keys and credentials
Add `mykey.py` to `.gitignore` to prevent accidental commits of files containing API keys or other sensitive credentials.
2026-03-02 02:23:21 +03:00
Jiaqing Liang 2655d79acb Update autonomous_operation_sop.md 2026-03-01 23:19:00 +08:00
Liang Jiaqing f27c067b1d autonomous_sop: 加强autonomous_reports路径防护(SOP文本+checkpoint模板双层) 2026-03-01 23:18:36 +08:00
Liang Jiaqing 75ddf8fd1d ljqCtrl: 兼容RDP断开场景的屏幕尺寸/DPI获取; ljqCtrl_sop: ClientToScreen避坑; .gitignore清理 2026-03-01 23:18:36 +08:00
LJQ 392747798a Merge pull request #1 from redreamality/fix/oai-responses-api-mode
Add responses api_mode support for OpenAI relay
2026-03-01 23:16:47 +08:00
remy df3b347653 Add retry/backoff and richer error logs for relay calls 2026-03-02 00:38:27 +10:00
remy 998382aebe Add responses api_mode support for OpenAI relay 2026-03-02 00:17:25 +10:00
Jiaqing Liang 66989fc2e0 chore: 清理 .gitignore 中文注释和乱码 2026-02-27 23:43:24 +08:00
Jiaqing Liang 1d07183d09 refactor: 清理ga.py无用字段,重组subagent_sop结构 2026-02-27 23:41:33 +08:00
Jiaqing Liang b271b48489 update L0: 优化 L1 描述,移除更新机制部分 2026-02-27 23:40:20 +08:00
Jiaqing Liang 66e3ccbf0d refactor: 优化file_read统计逻辑 - 紧凑化+路径修复+编码安全 2026-02-27 23:01:30 +08:00
Jiaqing Liang bb2d595fb6 重构轮次警告机制并修复路径问题
- 提取next_prompt_patcher回调让handler自定义警告策略
- 修复turn显示不一致问题
- 每10轮注入全局记忆防止上下文丢失
- 强化autonomous_reports路径警告
- 新增subagent测试驱动SOP优化方法
2026-02-27 21:41:52 +08:00
Jiaqing Liang 75f8ef2564 refactor: improve update_working_checkpoint description and adjust TG bot default behavior 2026-02-27 20:17:49 +08:00
Liang Jiaqing a3a3b55f1e feat: add timestamp to model_responses log 2026-02-27 12:33:17 +08:00
Liang Jiaqing 3a265e0f52 feat: add await detection and friendly prompt (v0.32)
- Detect await usage in sync context
- Auto-wrap with async function
- Return friendly prompt suggesting to avoid await
- Provide alternative solution using global variables
2026-02-27 11:47:06 +08:00
Liang Jiaqing cf1b122107 docs: 更新 QUICK_START.pdf - 添加 Python 3.14 兼容性警告和 requests 安装提示 2026-02-26 20:22:25 +08:00
Liang Jiaqing e30ee43298 update QUICK_START pdf, userscript v0.31, tmwebdriver postMessage docs 2026-02-26 18:11:43 +08:00
Liang Jiaqing 06f7f630e4 feat: iframe postMessage bridge, screenshot SOP, quick start pdf 2026-02-26 14:12:10 +08:00
Liang Jiaqing 63d9bea9b2 tweak: soften checkpoint reminder to optional hint 2026-02-26 12:36:41 +08:00
Liang Jiaqing c9a0b61bf2 tweak: soften checkpoint reminder wording in agent_loop 7-turn prompt 2026-02-26 12:36:13 +08:00
Liang Jiaqing afb0b57a06 fix: add yield in do_ask_user to prevent empty code block rendering as undefined in some Streamlit versions 2026-02-26 12:34:01 +08:00
Liang Jiaqing f0ce7215ff refine: working_checkpoint desc优化(增量更新/坑排首/全新任务清旧) + CLI最小启动 + web_execute_js兼容tab_id + mem_scanner坑点更新 2026-02-26 12:11:31 +08:00
Liang Jiaqing d417cecc45 mem_scanner: prefer sidebar click over search; sidercall: pid-isolated log path 2026-02-26 06:32:07 +08:00
170 changed files with 32208 additions and 2811 deletions

No files matched your search

BIN
View File
Binary file not shown.
+44
View File
@@ -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
+271
View File
@@ -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)。
+21
View File
@@ -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.
+564 -211
View File
@@ -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:&nbsp;<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>&nbsp;<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>&nbsp;<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!
[![LinuxDo](https://img.shields.io/badge/社区-LinuxDo-blue?style=for-the-badge)](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** 社区的支持!
[![LinuxDo](https://img.shields.io/badge/社区-LinuxDo-blue?style=for-the-badge)](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>
+43 -28
View File
@@ -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)
-61
View File
@@ -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 驱动采集多个页面信息,通过逻辑整合后在您的本地文件夹生成物理文件。
---
**💡 提示**:您可以直接复制上述 `指令` 发送给我,我将立刻执行对应的物理操作。
+85 -53
View File
@@ -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
+188 -84
View File
@@ -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]')
Binary file not shown.
+301
View File
@@ -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)
- 新增消息显示说明(⏳ 进行中标记、实时更新等)
+215
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
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)
+27
View File
@@ -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)
File diff suppressed because it is too large. Load diff
-24
View File
@@ -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; // 异步响应
}
});
-18
View File
@@ -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});
-32
View File
@@ -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"
}
]
}
-20
View File
@@ -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>
-56
View File
@@ -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);
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

+21 -16
View File
@@ -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
+22
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+7 -7
View File
@@ -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修改(除非新建)
+9
View File
@@ -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)
+179
View File
@@ -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 ""
+111
View File
@@ -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
-419
View File
@@ -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();
}
});
})();
-137
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
# Role: 物理级全能执行者
你拥有文件读写、脚本执行、用户浏览器JS注入、系统级干预的物理操作权限。禁止推诿"无法操作"——不空想,用工具探测。
## 行动原则
调用工具前在 <thinking> 内推演:当前阶段、上步结果是否符合预期、下步策略。
调用工具前先推演:当前阶段、上步结果是否符合预期、下步策略,必须在回复文本中用<summary>输出极简总结。
- 探测优先:失败时先充分获取信息(日志/状态/上下文),关键信息存入工作记忆,再决定重试或换方案。不可逆操作先询问用户。
- 失败升级:1次→读错误理解原因,2次→探测环境状态,3次→深度分析后换方案或问用户。禁止无新信息的重复操作。
+7
View File
@@ -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.
+405
View File
@@ -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());
+47
View File
@@ -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 });
}
}
})();
+24
View File
@@ -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; };
})();
+40
View File
@@ -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"
}
}
+19
View File
@@ -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>
+24
View File
@@ -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; }
}
+1
View File
@@ -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 内省都是通的。"}]}]
+36 -30
View File
@@ -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": {}}}
}
]
+74
View File
@@ -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": {}}}
}
]
+175
View File
@@ -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
+142
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

+347
View File
@@ -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)
+421
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>
+415
View File
@@ -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)
+315
View File
@@ -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
+407
View File
@@ -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())
+7
View File
@@ -0,0 +1,7 @@
# Rust build artifacts
src-tauri/target/
src-tauri/gen/
# Node
node_modules/
package-lock.json
+10
View File
@@ -0,0 +1,10 @@
{
"name": "genericagent-web2",
"version": "0.1.0",
"scripts": {
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
}
}
File diff suppressed because it is too large. Load diff
+16
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+86
View File
@@ -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();
}
+5
View File
@@ -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"]
}
}
File diff suppressed because it is too large. Load diff
+142
View File
@@ -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) }));
})();
+116
View File
@@ -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>
+897
View File
@@ -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); }
+6
View File
@@ -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
+616
View File
@@ -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)
+93
View File
@@ -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()
File diff suppressed because it is too large. Load diff
+151
View File
@@ -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())
+78
View File
@@ -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
+709
View File
@@ -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()
+375
View File
@@ -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())
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+126
View File
@@ -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())
+2478
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

+63
View File
@@ -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
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 B

+60
View File
@@ -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
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

+61
View File
@@ -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
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

+60
View File
@@ -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
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

+6
View File
@@ -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!
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+60
View File
@@ -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
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more