Files
GenericAgent/llmcore.py
T
+4 37225424cb feat(desktop): import desktop client and bridge from dd3xp/GenericAgent_Desktop fork (#637)
* GenericAgent Desktop: mockup-based client wired to bridge

Initial snapshot. Desktop frontend (frontends/desktop/static/) rebuilt as the served client: 3-card UI, sidebar nav, preset functions, sessions, streaming via HTTP poll + WS notify, markdown, modals (preset/add-model/settings), theme-variable styling. mykey.py excluded (gitignored).

* i18n: externalize all UI text (zh/en, empty defaults); separate dev annotation layer into annot.css/annot.js

- All static UI text via data-i18n + I18N{zh,en}; runtime applyI18n; language switch in settings, persisted.
- Empty defaults in HTML (no source language baked in).
- Annotation/dev scaffolding fully extracted to annot.css + annot.js (runtime-injected); index.html/styles.css/app.js carry zero annotation footprint; deletable via 2 include lines.
- Preset cards decoupled via data-preset; model chip default emptied.

* feat: session search, collapsible & resizable right panel

- Wire up search input to filter sessions by title/message content
- Add toggle button to collapse/expand the session panel
- Add drag handle for resizing panel width (160–400px)

* feat: token stats with persistent history, time filter, pagination & model-based cost

- Persist per-prompt token usage to ~/.GenericAgent/token_history.json
- Add GET /token-stats API endpoint with since/until filtering
- Token page: real data, expandable per-prompt details with model & cost
- Model price table for accurate cost estimation (GPT/Claude/DeepSeek/etc)
- Time range filter (datetime-local) with reset button
- Pagination (15 sessions per page)
- Stat cards (total/today/cost) follow time filter, today always absolute
- Migrate Rust settings path to ~/.GenericAgent/ga_desktop_settings.json
- Fix cost_tracker Python 3.9 compat (from __future__ import annotations)


* Revert "feat: token stats with persistent history, time filter, pagination & model-based cost"

This reverts commit 011824860d.

* feat: token stats page with per-prompt tracking, time filter & cost estimation

- Add /token-stats bridge endpoint exposing cost_tracker data + model name
- Frontend localStorage persistence for per-prompt token records (WS-triggered)
- Time range filter (datetime-local), pagination, expandable session details
- Model-based cost estimation (Claude/GPT/DeepSeek price table with cache rates)
- All UI text via i18n, all colors via CSS variables

* feat: complete message channel page

* feat: collapsible/resizable left sidebar + pin marker + preserve partial on stop

- Add sb-toggle/sb-resize mirroring rp-toggle/rp-resize; refactor into shared bindResize().
- Tuck both toggles into their panel's inner edge via transform; lighter color + opacity:.6 (uniform with collapsed state).
- Narrow body.gap 12→6 so cards sit closer, main effectively wider.
- Pin marker: tilted pin SVG (rotate 45deg, --blue) before title when sess.pinned; toggle pin/unpin via ⋯ menu with dynamic i18n label (ctx.pin/ctx.unpin). Unpin places session at top of the non-pinned area.
- Preserve streamed partial on stop: pollSession exit no longer drops draft text; finalizes it as an assistant message with [stopped] marker (i18n).

* feat(desktop): 设置页 — 弹窗与模型 CRUD

设置弹窗、模型列表增删改、表单必填/选填提示;bridge 提供 model-profiles CRUD 与 llmNo 传递。

* feat(desktop): 复杂主题色与外观调整

浅/深外观、素色模式、8 色色盘、语言列表 UI 与设置表单样式优化。

* refactor: 压缩 bridge 与主题 CSS 行数

合并 mykey 块改写 helper,--blue 改由 JS 设置,去掉重复 CSS 映射规则。

* feat: complete status panel page

* fix(bridge): protocol now required for add-model (no apibase auto-guess); UX: segmented control + flag SVGs

- _next_native_var now requires explicit protocol ('oai' or 'claude'); raises if missing. Removes the silent 'native_claude_config' default that wrongly routed all OAI-compatible relays through Anthropic protocol.
- Add-model form: replace native <select> with a segmented control (2 radio buttons styled as pills) since browsers can't style native dropdown panels. Required validation kept.
- Language list: inline SVG flags before labels (China / USA, per request), uses official flag colors. Reliable on Windows where flag emojis render inconsistently.
- i18n: model.protocol/protocolPick/protocolOai/protocolClaude in zh+en.
- Edit mode now backfills protocol radio from varName.

* fix: update setting board in message turnnel page

* feat(desktop): session persistence + token stats fixes + model pricing tooltip

- Sessions persist across refresh (localStorage metadata + bridge disk storage)
- Bridge restores LLM context on restart via /session/{sid}/restore endpoint
- Sort sessions: pinned first, then by most recent activity
- Fix token stats: remove double .json() parse, handle negative deltas after restart
- Add model pricing tooltip on hover in token statistics page
- localStorage only stores session metadata (not messages) to prevent quota overflow
- Remove duplicate tokPollBridge() call in pollSession

* feat(chat): chat UI — Plan/Auto, presets, image/file upload, model dropdown (#3)

* i18n: add empty keys for chat-page button text (chip/upload/model)

* html: bind chat composer chips to i18n (plan/auto/upload/model)

* feat(chat): add Plan/Auto sticky toggle state with localStorage hydration

* style(chat): chip.on toggle, dropdown menu, image thumb strip

* style(chat): anchor .composer for .ga-menu dropdown

* feat(chat): image upload via hidden file input + thumb strip

* feat(chat): sendPrompt prepends Plan/Auto SOP, passes pending images

* fix(chat): drop hardcoded '[image]' session-title fallback

* feat(chat): model chip dropdown menu (replaces click-to-cycle)

* i18n: fill chat composer + Plan/Auto preset prompt values

* feat(chat): drag-drop + paste image upload (chat panel overlay)

* feat(chat): image lightbox + custom/built-in preset management

- Image lightbox: click composer thumbnails or sent message images to preview full-screen
- Custom presets: add via modal, delete via hover ×, persisted in localStorage (ga_custom_presets)
- Built-in preset hide: × on builtin cards hides them (ga_hidden_builtins); "restore defaults" button in preset-modal head brings them back
- Plan/Auto mutex: clicking one turns off the other
- Unified preset rendering: makeCardEl + renderAllPresets covers both grids (chat-start and preset-modal)

* feat(upload): tuiapp_v2-style file upload (any file, path placeholders)

Bridge:
- POST /upload: save dataUrl → <tempdir>/ga_web_uploads/<uuid>__<name>, return abs path
- DELETE /upload: remove uploaded file (path-traversal guarded)
- Wipe ga_web_uploads/ on bridge startup (no orphan accumulation)

Frontend:
- Pick / drag / paste any file → POST /upload → insert [Image #N] or [File #N] placeholder in textarea
- Attachment strip shows image thumbnail or filename chip with × to remove
- × also fires DELETE /upload + strips placeholder from textarea
- sendPrompt expands placeholders to bare paths in-place; no longer sends images field
- Limits: 50 MB/file, 10 files/batch
- i18n: upload.* keys reworded for "file" (was "image"); added upload.failed

* fix(chat): drop dead langSel block, hook renderAllPresets into selectLang

Post-merge cleanup: feature/chat-ui's old langSel <select> handler was
left behind after origin/main switched to the lang-list radio control,
causing 'langSel is not defined' at top level which aborted app.js
before applyI18n() could run (entire UI rendered blank).

Also wire renderAllPresets() into selectLang() so dynamically-built
preset cards refresh on language change.

* fix(chat): drop fake 'auto' model menu item, align llmNo with profile id

The model dropdown injected a synthetic 'auto-select' row at data-llmno=0
and offset real profiles by +1 (no = i+1). Backend list_llms() / next_llm
both index from 0, so the labels were off by one — clicking 'auto'
selected the first real profile, clicking the first real label selected
the second, and clicking the last wrapped via modulo back to the first.

Render real profiles only, using each profile's id directly as data-llmno.
Menu click now routes through selectModel() so the choice persists to
localStorage and bridge config.

---------

Co-authored-by: Yiwei Gu <yiwei.gu@sap.com>

* fix(desktop): persist stopped output, decouple user display from LLM prompt, anti-flicker boot

- Bridge cancel() saves partial assistant content as a stopped message so the truncated output survives a refresh
- submit_prompt accepts a separate display field; UI now shows the original user input while the bridge stores the composed Plan/Auto SOP + expanded file paths for LLM/restore_context
- Boot-time chat-start is hidden via early data-boot-has-sessions attribute when localStorage has sessions, removing the welcome-page flash on refresh
- Conv-item click on non-chat pages now jumps back to the chat page with that session selected
- Remove dead refresh button from topbar (no handler, no id)


* style(desktop): drop hardcoded colors/strings, remove placeholder page-subs

- Move pricing-tooltip text in modelPriceTip to i18n keys (tok.priceInput/Output/CacheW/CacheR/pricingUnknown), drop lang === 'zh' ? : ternaries
- Replace remaining hex literals in selectors with CSS vars: chan-log-pre and mykey-editor backgrounds now respect --field-bg / --card (dark mode no longer shows white panels)
- Introduce --sample-light-* / --sample-dark-* vars for appearance preview cards (intentionally static, do not follow current theme)
- Remove placeholder page-sub on Channels / Status / Token pages (annotation copy, never meant to ship); keep on Collab page since that page is still unimplemented

* feat(chat): frontend rendering enhancements — markdown + LaTeX + code highlight + typewriter (#4)

Adapted from wjl2023's PR by HYC-hsy on top of latest main. Adds:
- Markdown rendering with LaTeX protect/restore + KaTeX
- Code blocks: highlight.js syntax coloring + per-block copy button
- Fold-block class differentiation (thinking/tool/result/turn)
- postRenderEnhance: hook applied after markdown render for both assistant messages and streaming drafts
- Typewriter streaming: time-based character reveal, ~400 chars/sec

Closes #4

Co-Authored-By: dd3xp <zhangjingming131@gmail.com>

* fix(chat): post-merge cleanup for PR #4 rendering — i18n + theme vars + typewriter race

- Replace hardcoded 'Copy'/'Copied!'/'TeX' in postRenderEnhance with t('act.copy/copied/copyTex'); add zh/en keys
- Fix var(--accent) reference (undefined) in .cursor-blink -> var(--blue)
- Add dark-mode overrides for --fold-thinking/tool/result border+bg so fold blocks don't glow pastel on dark backgrounds
- Typewriter: timer now reads r.draftText each tick (was a stale closure on full -> never picked up streamed extensions); drop the visible || full fallback that immediately revealed the entire content on first paint


* fix(upload): surface payload-too-large errors, reject empty files, use toast not chat-log

Root cause of "uploads silently land as 0-byte __file": aiohttp default body
limit is 1 MiB; PDFs almost always exceed it, request.json() raises
HTTPRequestEntityTooLarge, the generic read_json() swallows it as bare
Exception, upload_handler then sees an empty dict and happily writes an
empty file with name='file', returning ok=true. Front-end thinks the
upload worked.

Bridge:
- Set web.Application(client_max_size=1MB) explicitly so the limit is
  visible at the call site (kept at 1MB intentionally for now; bump later
  if larger uploads are needed)
- upload_handler reads body itself instead of read_json so it can catch
  HTTPRequestEntityTooLarge and HTTPBadRequest and return a real error
  to the client instead of silently writing 0 bytes
- Reject blob == b'' as a second line of defence

Frontend:
- addFiles skips files with size === 0 (handles drag-folder ghost File
  and edge-case clipboard items) with a dedicated 'upload.empty' toast
- 4x showSystem(...) in addFiles -> showChanToast(title, detail, 'err').
  Upload failures should be transient toasts, not permanent system
  messages glued into the chat transcript.


* fix(desktop): interrupt running turn on resend, lock composer + loading hint (#5)

Sending while a reply is still streaming now cancels the in-flight turn,
waits for the session to go idle, then submits the new prompt — instead of
silently dropping the new message. During the interrupt the composer is
locked, a sticky loading hint shows in the chat area, and the typed text +
pending attachments are preserved. Reuses the bridge cancel -> stopped-message
path so the interrupted turn's partial output is kept.

- interruptBeforeSend(): cancel + waitSessionIdle + min loading duration
- removeUsedPendingFiles(): only clear attachments actually consumed by send
- showChanToast gains an 'info' kind
- submitInput async + _submitInFlight guard; setComposerLocked / setMsgLoading

Closes #5

Co-Authored-By: dd3xp <zhangjingming131@gmail.com>

* style(desktop): send-button busy spinner uses --on-accent, not hardcoded white

PR #5 left the .send.is-busy::after spinner at rgba(255,255,255,.35) / #fff.
Route it through var(--on-accent) so it follows the accent foreground color
(white by default, dark on theme 7's yellow accent) instead of always white.
Bump styles.css cache bust v8 -> v9.

* style(chat): Gemini-style image bubble (#6)

* i18n: add empty keys for chat-page button text (chip/upload/model)

* html: bind chat composer chips to i18n (plan/auto/upload/model)

* feat(chat): add Plan/Auto sticky toggle state with localStorage hydration

* style(chat): chip.on toggle, dropdown menu, image thumb strip

* style(chat): anchor .composer for .ga-menu dropdown

* feat(chat): image upload via hidden file input + thumb strip

* feat(chat): sendPrompt prepends Plan/Auto SOP, passes pending images

* fix(chat): drop hardcoded '[image]' session-title fallback

* feat(chat): model chip dropdown menu (replaces click-to-cycle)

* i18n: fill chat composer + Plan/Auto preset prompt values

* feat(chat): drag-drop + paste image upload (chat panel overlay)

* feat(chat): image lightbox + custom/built-in preset management

- Image lightbox: click composer thumbnails or sent message images to preview full-screen
- Custom presets: add via modal, delete via hover ×, persisted in localStorage (ga_custom_presets)
- Built-in preset hide: × on builtin cards hides them (ga_hidden_builtins); "restore defaults" button in preset-modal head brings them back
- Plan/Auto mutex: clicking one turns off the other
- Unified preset rendering: makeCardEl + renderAllPresets covers both grids (chat-start and preset-modal)

* feat(upload): tuiapp_v2-style file upload (any file, path placeholders)

Bridge:
- POST /upload: save dataUrl → <tempdir>/ga_web_uploads/<uuid>__<name>, return abs path
- DELETE /upload: remove uploaded file (path-traversal guarded)
- Wipe ga_web_uploads/ on bridge startup (no orphan accumulation)

Frontend:
- Pick / drag / paste any file → POST /upload → insert [Image #N] or [File #N] placeholder in textarea
- Attachment strip shows image thumbnail or filename chip with × to remove
- × also fires DELETE /upload + strips placeholder from textarea
- sendPrompt expands placeholders to bare paths in-place; no longer sends images field
- Limits: 50 MB/file, 10 files/batch
- i18n: upload.* keys reworded for "file" (was "image"); added upload.failed

* fix(chat): drop dead langSel block, hook renderAllPresets into selectLang

Post-merge cleanup: feature/chat-ui's old langSel <select> handler was
left behind after origin/main switched to the lang-list radio control,
causing 'langSel is not defined' at top level which aborted app.js
before applyI18n() could run (entire UI rendered blank).

Also wire renderAllPresets() into selectLang() so dynamically-built
preset cards refresh on language change.

* fix(chat): drop fake 'auto' model menu item, align llmNo with profile id

The model dropdown injected a synthetic 'auto-select' row at data-llmno=0
and offset real profiles by +1 (no = i+1). Backend list_llms() / next_llm
both index from 0, so the labels were off by one — clicking 'auto'
selected the first real profile, clicking the first real label selected
the second, and clicking the last wrapped via modulo back to the first.

Render real profiles only, using each profile's id directly as data-llmno.
Menu click now routes through selectModel() so the choice persists to
localStorage and bridge config.

* style(chat): Gemini-style image bubble — image floats free above text bubble

Image attachments now render as a fixed-size 120x120 rounded thumbnail above
the text bubble (separate elements, both right-aligned), instead of being
embedded inside the bubble. Lightbox click-to-preview rebound to .user-imgs.

---------

Co-authored-by: Yiwei Gu <yiwei.gu@sap.com>

* feat(desktop): chat font-size control + dark-mode Markdown readability (#9)

Settings gains a 10–20px chat font stepper (default 14, slider with aria),
applied only to chat bubbles + composer via --chat-font; pre/code/fold switch
to em-relative sizing so they scale with it. Dark mode swaps highlight.js to
the github-dark theme and adds link/code/pre/katex/table/blockquote styling so
markdown is legible on dark backgrounds.

Persistence rides the same ui-prefs mechanism as #7 (ui.fontSize in
~/.ga_desktop_settings.json via /config), so this PR supersedes #7 — it
contains the full server-first persistence layer (bootUiFromDom / persistUiPrefs
/ syncBootCache) plus fontSize. Keeps #5's interrupt-on-resend behavior intact.

Closes #9

Co-Authored-By: dd3xp <zhangjingming131@gmail.com>

* style(desktop): dark md-link mix anchor uses --txt-strong, not hardcoded #fff

PR #9 left the dark-mode markdown link color as
color-mix(... var(--blue) 42%, #ffffff). Route the white anchor through
var(--txt-strong) (which is #ffffff in dark) so no hex literal sits in a
selector. Bump styles.css cache bust v17 -> v18.


* feat(chat): file attachment cards in user message + click-to-open (#10)

Non-image attachments now render as Gemini/ChatGPT-style file cards above
the user text bubble (icon disc + bold filename + kind subtitle). Click on
a file card opens it via the system default app — bridge `/path/open` gains
a `kind=upload` mode that whitelists \`_WEB_UPLOAD_DIR\` to prevent the
frontend from opening arbitrary local paths.

Composer thumb-strip uses the same card style for non-image pending files,
also click-to-open. Image thumbs (48×48) unchanged.

Co-authored-by: Yiwei Gu <yiwei.gu@sap.com>

* feat(desktop): run-state becomes display-only; send key turns into a stop button while running

The top-left run-state is now a pure status indicator (green=ready, red=running)
and no longer handles clicks. Stopping the current turn moves to the send key:
while the agent is running the send button turns into a red stop square — clicking
it cancels the current turn (ChatGPT/Claude style). The Enter key still submits, so
"type while running + Enter" keeps lyx's interrupt-and-resend behavior (#5) intact.

- setBusy toggles .is-stop on the send button + aria-label (stop/send)
- send click: cancelPrompt() when busy, submitInput() otherwise
- remove run-toggle click handler + .stopped state; .run-state.busy goes red
- .send.is-stop: red bg + white rounded square (hide arrow); i18n act.send
- cache bust styles v19->20, app v25->26


* feat(desktop): frontend enhancements v2 — IME, fold-by-turn, copy buttons, smart scroll (#11)

Redo of #8 on top of latest main (97f613d), keeping all merged work intact:
- F4 IME: Enter ignores composition (isComposing / keyCode 229) so CJK input
  confirmation no longer fires a send
- F1 copy buttons unified to SVG clipboard/check icons
- F5 assistant bubble width fills the conversation column
- F3 fold history by turn (last turn flat); tool-call/thinking regex aligned
  with agent_loop streaming
- F2 per-bubble "copy this turn" (last turn only, strips <summary>)
- F6 smart scroll: only follow when isNearBottom(80px); typewriter rerender
  preserves user-expanded <details>
- F7 fold header gets the turn's <summary> as subtitle

Verified to keep #5 interrupt-and-resend, the 97f613d stop-button behavior,
#6 image bubble, and #10 file cards untouched. No hardcoded colors/strings.

DEV_NOTEBOOK.md from the PR is intentionally dropped — it's wjl2023's personal
dev journal (local paths, fork notes), not project documentation.

Closes #11

Co-Authored-By: dd3xp <zhangjingming131@gmail.com>

* feat(desktop):
>>   move session persistence from localStorage to backend API
>>
>>   Sessions are now loaded from GET /sessions and metadata updates (pin,
>>   title) go through PATCH /session/{sid}, eliminating cross-browser
>>   isolation issues between Chrome and Safari.

* @
feat(desktop): persist token usage history to local file instead of localStorage

Token stats (history + snapshot) now stored in temp/desktop_token_history.json
via GET/POST /token-history endpoints, ensuring cross-browser consistency.
@

* feat(chat): file preview routing — open vs reveal-in-finder (#12)

Click on a file attachment now picks behavior by extension: previewable
types (PDF, images, text, code, Office docs, audio/video) open in the
system default app; everything else (zip, binaries, unknown) reveals the
file in Finder/Explorer instead of opening it directly — avoids garbled
output for non-textual formats.

- frontend: PREVIEWABLE_EXTS whitelist + isPreviewableByName()
- bridge: /path/open accepts mode='reveal', uses 'open -R' (macOS),
  'explorer /select,' (Windows), parent-dir xdg-open (Linux fallback)
- pending thumb-strip chips also pass data-name for the same routing

Co-authored-by: Yiwei Gu <yiwei.gu@sap.com>

* @
feat(desktop): mark deleted sessions in token stats page

Show a "此会话已删除 / Session deleted" tag next to session titles
in the token usage table when the session no longer exists.
@

* feat(desktop): file attachments survive refresh + remote download + fix Windows open

Three fixes for the chat file-attachment flow:

1. Attachments vanished on refresh — sendPrompt now sends files/imageMetas
   (name+path) to the bridge, submit_prompt persists them on the user message,
   and msgNode renders images via GET /upload/raw URL instead of the in-memory
   dataUrl. So after a page refresh (bridge still running) file cards and image
   thumbnails come back. (Hard limit: restarting the bridge rmtree's
   ga_web_uploads, so a restart still drops the files.)

2. Remote can't open files — opening a file runs os.startfile/xdg-open on the
   BRIDGE machine, useless when the browser is on another box. openUploadFile
   now detects non-localhost and downloads the file to the client instead
   (GET /upload/raw?download=1). Remote image preview works too (lightbox uses
   the inline /upload/raw URL).

3. Windows opened pdf/docx in Notepad — PR #12's open path used
   _open_path_in_editor ('edit' verb), which falls back to Notepad for types
   with no edit verb (pdf, etc.). Uploads now use _open_path_default (default
   'open' verb → registered default app). mykey still uses the editor path.

New bridge endpoint: GET /upload/raw?path=&download= — whitelisted to
_WEB_UPLOAD_DIR, inline preview or attachment download, restores original
filename. Serves both the refresh-preview and remote-download cases.


* fix(desktop): prevent summary text from rendering twice in last turn

The last turn's <summary> tag content was rendered both in the turn
header (via extractTurnSummary) and in the body (via foldBlocks).
Strip the <summary> tag from seg.body before passing to foldBlocks
when a turn number is present, so it only appears once in the header.

* chore: sync framework to upstream lsdefine/GenericAgent @ 1eb8c4c

Content-overlay update of all GA framework files from upstream base
b4393af (2026-05-18) to 1eb8c4c (2026-05-25). Applied as an overlay
because GADesktop and upstream share no common git history.

- 26 framework files brought to upstream: tui_v3 (new), fsapp rework,
  llmcore hardening, plugins/hooks lifecycle hooks, goal_mode prompt
  rewrite, supergrok_proxy, qq/tg/wechat/st apps, cost_tracker py3.9
  compat, docs; removed obsolete install scripts.
- Desktop frontend (frontends/desktop/*) and desktop_bridge.py left
  untouched. Upstream's only overlapping change (bridge mykey.txt->
  mykey.py) is already present in our version, so it is a no-op.


* fix(desktop): persist uploads in GA temp dir with 30-day retention

Uploaded files were stored in the OS temp dir, and the attachment dir was
wiped wholesale on every bridge startup (shutil.rmtree) — so attachments in
past sessions became dead links after any restart (e.g. "File not found:
...\ga_web_uploads\...__thesis.pdf").

- Move both _UPLOAD_DIR (images) and _WEB_UPLOAD_DIR (attachments) under
  ga_root/temp/ (gitignored): desktop_image_uploads/ and desktop_uploads/.
- Drop the rmtree-on-startup; uploads now survive bridge restarts.
- Add _sweep_stale_uploads(): on startup, delete only files older than
  UPLOAD_RETENTION_DAYS (30) by mtime, so temp storage can't grow unbounded.
- Remove now-unused tempfile/shutil imports.


* feat(frontend): add real-time message timing badge

- Show elapsed time badge on assistant messages during task execution
- Timer starts on busy, stops on idle, displays final duration
- Fix badge flicker: preserve badge across typewriter innerHTML rewrites
- Fix layout jump: CSS fixed min-height for .task-elapsed
- Fix summary duplication: strip extracted summary from last turn body
- i18n: zh '已运行 {t}' / en 'Elapsed {t}'

* docs: add frontend beauty development notebook

* fix: persist elapsed badge on refresh via message ts

- normalize() now preserves ts field from API response
- restoreElapsedBadges() moved to pollSession finally block
  (messages are empty during renderAllMessages)
- sendPrompt() adds ts to local user messages
- restoreElapsedBadges() resets lastUserTs when user msg has no ts
  (prevents stale accumulation from previous rounds)

* refactor(desktop): drop dead image-upload path; images & files share one dir

The 05-20 image mechanism (_save_image_data -> desktop_image_uploads, images
sent as dataUrl in the prompt) was superseded on 05-21 by the placeholder-path
flow: the frontend uploads every attachment via /upload to desktop_uploads/ and
inlines the file path into the prompt text, sending only path metadata
(files/imageMetas) — never dataUrl. So _save_image_data was never called and
desktop_image_uploads stayed empty (dead code).

- Remove _save_image_data and the _UPLOAD_DIR (desktop_image_uploads) dir.
- normalize_prompt now only flattens prompt text; image handling dropped
  (images arg kept for backward compat, ignored).
- _sweep_stale_uploads now sweeps only desktop_uploads.

Net: images and files share one dir (desktop_uploads), no duplicate storage.


* style: remove bubble design, use font-weight to distinguish user/assistant messages

* feat(desktop): group uploads by session id; purge on session delete

Uploads now land in temp/desktop_uploads/<sid>/ instead of a flat dir, so a
session's attachments can be cleaned up when the session is deleted.

- upload_handler reads `sid` from the body and saves under desktop_uploads/<sid>/
  (sanitized; missing sid -> _misc bucket).
- delete_session purges the session's upload subdir.
- _sweep_stale_uploads recurses into sid subdirs (rglob) and drops empty dirs.
- Frontend: uploadOne sends the active session's bridgeSessionId; addFiles
  ensures a session exists (reusing newSession/ensureBridgeSession) before
  uploading, so a sid is available — the empty-app case creates one first.

Whitelist checks (upload_raw / upload_delete) already validate "under root",
so the deeper subdir paths pass unchanged.


* refactor(fold): Apple-style disclosure with left border line

- Remove colored borders and backgrounds from fold components
- Use 2px left border line for visual hierarchy
- Keep subtle color coding (blue=tool, green=result, gold=thinking)
- Fix .bubble.md pre specificity override for fold pre elements

* fix(fold-turn): fallback to tool names when no <summary> tag in turn body

* style(chat): responsive width with clamp() for large screens

Replace hardcoded max-width (760px/560px) with CSS variable
--chat-max-w: clamp(600px, 75%, 1000px) so the chat area
scales naturally on wider displays.

* fix(desktop): after deleting active session, select the topmost one

closeSession picked state.sessions.keys().next().value — the Map's first
insertion-order entry, unrelated to the visible list, so it felt like jumping
to the "bottom" session. Now it picks the first of the displayed order
(sortedSessions()[0]): pinned first, then most-recently-active = the topmost.

Extracted sortedSessions() shared by renderSessionList and closeSession.


* 实现Conductor页前端

* feat(ask_user): require structured questions array for wizard UI
Replace single question/candidates params with a required questions array
({question, options?}, 1–5 items) in ga.py and tool schemas. Add desktop
wizard rendering with per-question options, inline custom input, and
batch submit after all answers.

* revert(ask_user): restore question/candidates backend API

Roll back ga.py, agent_loop, and tool schemas to the pre-wizard protocol so ask_user stays question + candidates only.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(desktop): ask_user card UI for question/candidates

Render ask_user tool calls as readable cards with markdown question, optional candidate buttons, inline custom input, and composer lock until answered. Frontend-only; backend keeps question + candidates protocol.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(token): replace native datetime-local with Flatpickr datepicker

- Add Flatpickr CDN (CSS + JS + zh locale) to index.html
- Change input type to text with .tok-date class
- Add Flatpickr theme override using CSS variables (light + dark)
- Initialize Flatpickr instances with ISO dateFormat for Safari compat
- Update reset button to use fp.clear() API

* fix(token/flatpickr): dark mode calendar + friendlier date display

- Add !important to all Flatpickr CSS overrides to beat CDN specificity
- Change dateFormat from ISO 'Y-m-d\TH:i' to 'Y-m-d  H:i' (double space)
- Update tokGetFiltered to use .replace(/\s+/,'T') for Safari compat

* feat(desktop): robust attachment placeholders — atomic delete + input reconcile + send cleanup

Composer attachment placeholders ([Image #N]/[File #N]) are plain editable text
with no sync to pendingFiles, so deleting/breaking one silently dropped the file
(chip lingered) or sent broken text to the agent. Hardened with three pieces:

- #2 atomic delete: Backspace/Delete adjacent to a placeholder removes the whole
  token + its file at once (a <textarea> can't make a substring non-editable, so
  this delivers the same UX without a contenteditable rewrite).
- #1 input reconcile: on every edit, drop any pending file whose valid placeholder
  is gone/broken — removes the chip and DELETEs the uploaded file on the bridge.
- #3 send cleanup: expandFilePlaceholders strips dangling placeholders (no backing
  file) instead of leaving them, so no garbage reaches the agent.
- Refactor: extract removePendingFile() shared by the chip × button and the above.


* style(flatpickr): align calendar font-family and font-size with app

- Add font-family:inherit !important to .flatpickr-calendar
- Add font-size:13px !important to match body font-size

* style(flatpickr): bold month/year header for better visibility

* style(flatpickr): bold + normalize header font-size to 13px

- Add font-weight:600 !important to month dropdown and year input
- Add font-size:13px !important to override CDN's 135% enlargement
- Ensures calendar header matches app's global 13px baseline

* fix(token): remove duplicate placeholder text from date inputs

The label already shows '从'/'到', so data-i18n-ph on inputs
caused duplicate text display. Removed placeholder attributes.

* chore: remove dev notebook from PR branch

* feat(desktop): contenteditable composer with atomic attachment chips

Replace the plain <textarea> composer with a contenteditable div so attachment
placeholders render as atomic, non-editable chips (QQ @-mention style): the caret
can't enter them and Backspace deletes the whole chip + its file. Stays a plain-text
box otherwise — no rich formatting, paste is forced to plain text.

- index.html: <textarea class="input"> -> <div class="input" contenteditable>.
- composerText() serializes content back to plain text (chips -> [Image #N]/[File #N],
  <br>/<div> -> newline) so all downstream send/expand/collect logic is unchanged.
- insert/remove placeholder now create/remove <span.ph-chip contenteditable=false>
  nodes; reconcile compares pendingFiles against chips present in the DOM.
- Drop the textarea-only atomicPlaceholderDelete (contenteditable=false is native).
- Enter-to-send + IME guard kept; paste forced to plain text (+ image/file paste).
- setComposerLocked toggles contentEditable (readOnly is a no-op on a div).
- i18n placeholder hint via :empty::before + data-ph; .ph-chip pill styling.


* style(desktop): attachment chip = translucent theme-color block, drop emoji icons


* fix(desktop): bump asset cache-bust (css v21/js v29) so chip styles load; make chip bg more visible


* feat(desktop): ask_user notice UI with schema hints and question formatting
Replace the card/button wizard with a lightweight notice block: users reply in the main composer with dynamic placeholders. Add formatAskUserQuestion to merge numbered lines and break between questions only. Update tool schemas to guide multi-question Markdown in question and single-question candidates. Hide redundant candidates lists by default (preview flag on).

* feat: update the style of agent questioning

* [feat]:增加删除subagent的功能,增加跨域请求,增加subagent卡片点击展开的功能,并且把原本的collab.js全部放进去app.js里面

* refactor(desktop): rename theme var --blue -> --accent; plain-icon sidebar toggles

- Rename the misleadingly-named --blue theme accent var to --accent (incl.
  --blue-bg/-soft -> --accent-bg/-soft) across styles.css + the applyTheme
  setter in app.js. Keep --blue* as back-compat aliases of --accent* so
  unmerged branches (e.g. PR#1) still render until they migrate.
- Sidebar / right-panel toggles: plain icon button when expanded (no bg/border);
  restore the edge-tab look (bg + border) only when collapsed.
- Bump asset cache-bust (styles.css v23 / app.js v30).


* [feat]:增加rename会话的功能

* refactor(ask_user): treat single-question as a special case of multi-question

Unify the single-question and multi-question ask_user rendering instead of
having two divergent paths:

- Single-question options (from `candidates`) are now folded into the question
  body as inline A./B./C. lines via foldAskCandidates(), rendered exactly like
  multi-question inline options (.ask-option-line). The separate numbered <ul>
  candidate list is gone, which also removes the "1. A." double-numbering.
- Fold with a single newline so question + options render as one <p>; every
  option (including the first) follows a <br>, so all get consistent indent.
- Placeholder is unified to the free-form ask.placeholderOpen for both; the
  per-single-question "type 1/2/3 to pick" placeholder is removed.
- Drop now-dead code: ask.placeholderOpts i18n (zh+en) and the
  .ask-candidates / .ask-candidate-key / .ask-candidate-label CSS.


* feat(desktop): adopt PR#3 rectilinear design + clearer PLAN/AUTO + session restore

Re-skin the desktop frontend in ethan su's "rectilinear workspace" visual
language (PR#3) without dropping any feature, plus two UX fixes. Done as a
content-overlay, NOT a git merge of PR#3 — PR#3's app.js reverts ask_user /
conductor / composer / timing, so only its visual layer is adopted.

Design (PR#3):
- Bundle brand fonts (Azonix wordmark / Lexend / Noto Sans / JetBrains Mono)
  + phosphor-icons.js (auto-hydrates [data-ga-icon]).
- styles.css: adopt ethan's full design-token system (accent #1f6f53,
  --radius 4px/2px squared corners, brand fonts) via 3-way merge; keep all
  feature CSS (composer chips, ask_user notice, conductor, flatpickr, timing
  badge); normalize leftover --blue -> --accent.
- index.html: ethan's phosphor chrome; keep contenteditable composers, the
  real conductor UI, flatpickr links, and collab-badge.
- app.js: add GA_ICON() helper; swap dynamic chrome icons (pin, more, model
  edit/delete, add-preset, channel, ctx-delete, drawer-close) to Phosphor.
  Flags, copy/check, file-chip and preset-card icons left inline by design.

Fixes:
- PLAN/AUTO toggles now show an unmistakable on-state (solid accent fill);
  the .cb-left .chip.sm rule was overriding .chip.on by specificity, so the
  on/off difference was nearly invisible.
- On refresh, restore the session you were actually viewing (persist
  ga_active) instead of the bridge's roaming most-recently-updated session;
  show the empty new-session state when none is selected.


* fix(desktop-bridge): apply mykey edits to live agents without restart

Editing a model key / adding a profile wrote mykey.py but the running session
agents kept using the LLM sessions built at startup (old key), so the change
only took effect after a restart.

Root cause: llmcore tracks "changed since last load" via a single global mtime
flag, and load_llm_sessions() skips rebuilding when changed=False. Whichever
agent reloads first consumes that flag — e.g. the throwaway GenericAgent the UI
spins up to render the model list — so the real persistent session agent later
sees changed=False and keeps its stale sessions.

After saving a profile, force every live session agent to rebuild: reset
llmcore._mykey_mtime to None before each agent.load_llm_sessions() so
reload_mykeys() reports changed=True. Bridge-only (same trick as the existing
_invalidate_mykey_cache); no core changes to agentmain/llmcore; the rebuild
preserves conversation history via agentmain's oldhistory hand-off.


* fix: 去掉模型回复的灰色气泡背景(bubble.md background→transparent)

* fix: 去掉工具调用/结果折叠框的蓝绿色边框和背景(fold-tool/fold-result→transparent)

* feat: 折叠框箭头旋转动画(▶→▼, 0.2s ease transition)

* feat: TUI风格代码块渲染 - 语言标签头+复制按钮+终端面板样式

* fix(latex): support \(...\) \[...\] delimiters and protect code blocks

- Add \(...\) and \[...\] as recognized LaTeX delimiters in protectLatex
- Protect fenced code blocks and inline code before LaTeX extraction
- Prevents $...$ inside code blocks from being misinterpreted as math
- Processing order: protect code → extract LaTeX → restore code → marked.parse

* style: align LaTeX copy button with code block copy button

- Replace SVG icon with ⧉ character (same as code block)
- Match font-size, padding, border-radius, background
- Remove border and flex layout, use simple text button
- Both buttons now visually identical (~16.5x14.7px)

* feat: 精简分割线,聊天主体用单一矩形框包裹

- msg-area 四边 border:1px solid var(--line) 形成完整矩形框
- 去掉 topbar border-bottom(改由 msg-area border-top 承担)
- 去掉 composer border-top(改由 msg-area border-bottom 承担)
- 去掉 main.main 的 border
- 去掉 .sidebar-foot 的 border-top
- 去掉 aside.sidebar 的 border-right
- 去掉 #rightpanel 的 border-left
- 去掉 .chat-tools 的 border-bottom
- 矩形完全自包含于 msg-area,不受 rightPanel 遮挡影响

* feat: 自定义滚动条样式,完全方形窄滚动条

- 全局 webkit scrollbar: 6px宽,border-radius:0(完全方形)
- 滑块颜色 var(--line),hover 加深为 var(--line-strong)
- 轨道透明,不占视觉空间
- 去掉 scrollbar-width:thin(会覆盖webkit伪元素导致overlay模式)
- 匹配项目整体方形设计风格

* feat: ask_user通知加粗banner和问题文本

- .ask-user-banner-text font-weight: 600→700
- .ask-user-body 新增 font-weight:600
- 提升ask_user卡片视觉层级,问题更醒目

* feat: ask_user通知框去圆角,改为方形

- .ask-user-notice border-radius: var(--radius) → 0
- 匹配项目整体方形设计风格

* chore: remove dev_notebook.md (dev scaffold merged in via PR#5)


* feat(desktop): inline attachment chips in messages + fix conductor raw-path bubble

- Render [Image #N]/[File #N] placeholders as inline chips in-place (filename)
  in user messages, so you can see where an attachment sits in the text. Image
  thumbnails + file cards are unchanged. Applies to chat and conductor via a
  shared renderMsgTextWithChips() helper; conductor messages now carry sid+name
  so the chip can resolve a filename.
- Conductor: stripAttach() also strips inline desktop_uploads paths, and pushMsg
  folds the server echo into the local optimistic message (keep clean text +
  chips/cards, drop the raw path) so conductor matches chat instead of showing a
  second bubble with the full local upload path.

Known limitation (backend, not in scope here): conductor attachments + running
state don't survive a page refresh — conductor.py only stores/sends
{id,role,msg,ts,read} with no file refs and no run state, so the frontend can't
restore them on reload. Persisting those belongs to the conductor maintainer.


* chore: gitignore local BUG_*.md bug/limitation drafts


* feat(frontend): add input length limits to all text fields

* feat(desktop): preset/composer/topbar restructure + conductor persistence + TUI-style session naming

UI restructure (frontend static):
- PLAN moved from sticky toggle chip to a preset card (BUILTIN_PRESETS) that
  one-shot-sends `presetPrompt.plan`, now pointing at the real
  memory/plan_sop.md (Auto was considered too but dropped — it semantically
  overlaps with the existing "自主探索" preset). All plan/auto toggle
  plumbing removed: state, chip listeners, applyToggleClass, ga_plan/ga_auto
  localStorage, planPrefix/autoPrefix injection.
- Chat composer: model chip moved out of the joined .cb-left pill group to
  sit next to the send button (composer-bot is now right-aligned model+send).
- Conductor composer: gained its own #collab-model-chip + #collab-model-menu;
  openModelMenu/renderModelMenu/updateModelChip refactored to be ctx-aware
  (both chips share the model list; each pops its menu into its own
  composer; updateModelChip syncs both names).
- Topbar dissolved into per-page headers: the global .topbar was removed
  from <main>; the run-state moved into a .page-top inside the chat page,
  so the "topbar" now switches with the page instead of persisting.
- Removed the conductor "{n} 个任务进行中" sticky banner (#collab-sticky);
  the same info is already in the progress panel and the sidebar badge.

Conductor persistence (conductor.py + frontend wiring):
- conductor.py: add_chat() carries files/images on each chat item; WS
  receive passes data.files/data.images through; hello includes a
  running flag (any subagent with status="running"). 5 net edits.
- app.js: ws.send now posts {msg, files, images} (was msg only); onWsData
  hello/chat branches map files/images and set S.conductorTyping from
  data.running so attachments + run animation survive a page refresh.

Session naming (mirror TUI continue_cmd._preview_text):
- New helper displayTitle(sess): if user manually renamed, use sess.title;
  otherwise scan messages — prefer the last <summary>…</summary> in any
  assistant reply, fall back to the first user message text (≤40 chars,
  attachment placeholders stripped), final fallback t('conv.defaultTitle').
- newSession() now defaults sess.title to '' instead of '新对话'; the
  earlier "first message → title" auto-rename block in submitInput is
  gone — titles only get written when the user renames explicitly.
- isAutoTitle() recognises '新对话'/'New chat'/'agent-N' as placeholder so
  legacy sessions and the brief agent-N experiment get re-derived live
  in the sidebar without losing user renames.
- Sidebar render + search filter now go through displayTitle.

Note: requires a one-time restart of conductor.py for its part to take
effect; the static frontend changes are just a refresh.


* fix(desktop): token stats title fallback when sess.title is empty

两个修复:

1. tokRenderTable 里 title 显示修复 — 新的 session naming 把 sess.title 默认设为空字符串,导致 token 统计 fallback 到原始 session ID(sess-xxx)。改为用 displayTitle(sess) 实时推导标题,和侧边栏一致。
2. tokPollBridge 里写入 history 时的 title 修复 — 同样的问题,写入时 sess?.title || sid 对空标题 fallback 到 ID。改为 sess ? displayTitle(sess) : sid。


* feat(desktop): conductor token stats summary row in token page

- conductor.py: install cost_tracker + expose /token-stats endpoint (5 lines)
- app.js: fetch conductor token totals and display as a single summary row at top of token table (not expandable, with tooltip noting it's excluded from cumulative totals)
- styles.css: conductor row styling


* fix(desktop): skip token delta recording while session is busy

Prevents token records from splitting into two entries when polling
occurs mid-generation. The full delta is captured on the next poll
after the session becomes idle.


* feat(desktop): persist conductor token stats via bridge file storage

Two rows: cumulative total (survives conductor restart) and current
session. Restart detection via value comparison; data stored alongside
bridge token history in desktop_token_history.json.


* fix(desktop): update model pricing + add pricing disclaimer

- o4-mini: .10/.40 -> /usr/bin/bash.55/.20 (price drop)
- deepseek-v4-pro: /usr/bin/bash.55/.19 -> /usr/bin/bash.435//usr/bin/bash.87 (permanent 75% cut)
- Add claude-opus-4-8 at /5
- Add disclaimer: pricing may vary by API provider


* fix(desktop): conductor token accumulation loop on refresh

Always overwrite last snapshot with current values (even zero) so
restart detection only triggers once instead of every refresh.


* Conductor美化

Conductor 页 UI 重构:对齐主聊天布局,复用消息/composer 样式,独立 cdb-composer 与 progress rail/drawer。

* style(desktop): use conductor SVG icon in token stats rows


* chore(desktop): remove preset entry from conductor + menu (preset is chat-only)


* feat(desktop): token stats tab bar separating chat and conductor

- Two tabs: 聊天 (chat sessions) and Conductor (cumulative/current)
- Conductor tab hides date filter and stat-row summary
- Remove green background and expand triangle from conductor rows
- Bump app.js and styles.css cache versions


* feat: merge channels and status into one services page

* i18n(desktop): localize conductor token stats and pricing disclaimer


* feat(desktop): add collapsible daily cost trend chart to token stats

Pure SVG line chart showing daily spending (¥) over time. Respects
date filter, fills zero-cost gaps, uses local timezone for grouping.
Hidden on Conductor tab.


* feat(desktop): nav reorder + taller trend chart + token tabs use svc style

1. Sidebar nav: move 后台服务 (services) to AFTER 指挥家 (collab).

   New order: 聊天 → 指挥家 → 后台服务 → Token 统计.

2. Token trend chart: was flat (container 160px tall, viewBox 600×140).

   Now container 280px + max-width 760px to stop it stretching on wide

   screens; viewBox bumped to 600×260 with a bit more padding so text

   does not get distorted under preserveAspectRatio="none".

3. Token stats tabs (聊天 / Conductor) reuse the .svc-tabs underline

   look from the services page — same visual language. CSS-only;

   .tok-tabs/.tok-tab classes and the JS click handler are unchanged.

4. Bump app.js?v=81→82 (app.js really changed this time, and the bump

   also flushes stale i18n cache for previously-untranslated keys).


* chore(desktop): bump styles.css?v=93→94 to flush stale CSS cache

Previous commit changed .tok-tabs/.tok-tab/.tok-chart CSS but did not bump the styles.css version, so the browser kept serving the cached v=93 file and the visual changes (underline tabs, taller chart) did not appear in the page.


* feat(desktop): per-page top bar with caret toggles + drop annotation scaffold

1. Removed the two floating arrow buttons (.sb-toggle / .rp-toggle) that

   used to sit at the side seams between sidebar/main/rightpanel.

2. Each page section now owns its own .page-top bar with:

   - left: .pt-sb-toggle (caretLeft, borderless icon button) — collapses

     left sidebar; svg rotates 180° when sidebar is collapsed.

   - right: .pt-rp-toggle (caretRight) — same logic for right panel.

   - chat/collab keep run-state centered between the two icons.

   - services/token use a .pt-spacer to fill the middle gap.

3. .pt-icon style is borderless / transparent / opacity-0.7 (no boxy

   bordered button look); page-top padding tightened to 4px so the

   carets hug the screen edges. Bump styles.css?v=95→97.

4. Wiring: app.js queries .pt-sb-toggle/.pt-rp-toggle by class (4 of

   each, one per page) and binds them to the existing

   .body.classList.toggle('sb-collapsed') / .toggle('rp-collapsed')

   handlers. Behavior identical to the old single buttons.

5. Dropped the DEV annotation scaffold: removed annot.css link in head,

   annot.js script at end of body, and deleted both files.


* refactor(desktop): centralize bridge/conductor port + URL into constants

Per teacher request, pull the 14168 / 8900 magic numbers and the URL

construction out of ~17 inline call sites into a single block at the top

of app.js:

  const BRIDGE_PORT = 14168;

  const CONDUCTOR_PORT = 8900;

  const BRIDGE_ORIGIN = `...`;        // http(s)://host:14168

  const BRIDGE_WS_ORIGIN = `...`;     // ws(s)://host:14168

  const CONDUCTOR_ORIGIN = `...`;     // http://host:8900

  const CONDUCTOR_WS_ORIGIN = `...`;  // ws(s)://host:8900

All fetch / WebSocket call sites now use those origins. No behavioral

change — bridge still calls 14168, conductor still calls 8900. Sets up

future fixes (same-origin bridge, bridge-proxied conductor, runtime port

swap) to be a one-line change in the constants block.

Bump app.js?v=83 -> 84.


* fix(desktop): CONDUCTOR_ORIGIN follows location.protocol (not hardcoded http)

BRIDGE_ORIGIN already used ${location.protocol}//; CONDUCTOR_ORIGIN was

hardcoded to http://, which would have broken under HTTPS deployment

with a Mixed Content block. Now both behave consistently — page over

HTTPS uses https for conductor calls and wss for the WS already (the

WS constant was already correct).

Triggered by SakuraFrp requiring HTTPS access (TCP tunnels reject

plain HTTP with 501). Tunnels now run with 自动 HTTPS enabled, so

browser hits https://...:14168 / :18900, and frontend needs to use

https in its constructed conductor URL.

Bump app.js?v=84 -> 86 (skipping 85 — that was an auth-gate-only bump).


* fix(desktop): bridge WS auto-reconnect + visibilitychange handler

When mobile browser is backgrounded the OS pauses/kills WebSocket; on

return the page stays on a dead connection and the next send shows

"bridge 未连接". Conductor's WS already had reconnect+backoff; bridge

did not.

Added to bridge WS:

- close handler now schedules reconnect with exponential backoff

  (1s, 2s, 4s, ... capped at 30s)

- visibilitychange listener: when page becomes visible again and ws is

  closed, reset retry counter and reconnect immediately

- wsRetries resets on successful open

- skip retry while page is hidden (battery saver)

Bump app.js?v=86 -> 87.


* fix(desktop): pollSession retries with backoff before giving up

When the phone goes to background, JS is paused and HTTP keepalive

connections die. On return, the first poll's fetch can hit "Failed to

fetch" because of a stale connection / DNS / TLS state. The original

code showed an error immediately on a single failure.

Now: same polling loop tolerates up to 5 consecutive errors with

exponential backoff (1s, 2s, 4s, 8s) before surfacing showError. Any

successful poll resets the error counter. This rides through the

typical 1-3 second post-resume blip without any user-visible failure.

Pairs with the earlier WS auto-reconnect + visibilitychange handler

(both bridge channels — WS and HTTP polling — now recover cleanly).

Bump app.js?v=87 -> 88.


* style(desktop): lighten dark appearance one step on GitHub Primer scale

Shift existing dark palette tokens up one tier for less harsh contrast while keeping the same color system; sync settings preview swatches and accent-on colors.

* 流式未收口的工具块先折进「输出中」折叠不再乱闪,并修稳工具假围栏误截、工具结果误拆成多个空折叠、刷新重播打字机、滚代码块弹回和难点展开。

* feat(desktop): Plan 模式 todo 卡片初版

poll 响应附带 plan 事实数据(plan_state + bridge 两行接入),composer 上方渲染进度与任务行;完成/读空 grace 在前端处理。plan_state 原 API 与 TUI 完全兼容,Desktop 仅新增 *_for_desktop 辅助函数。

* feat(desktop): Conductor 呼吸灯与进度 rail/drawer 状态优化

顶栏/Conductor 运行态改为绿色呼吸灯(含 connecting/offline 分态);
进度侧栏 rail 增加失败计数徽章,卡片与统计区统一使用呼吸标记。

* 1. 聊天框和Conductor对齐
2. 尝试修复聊天渲染卡顿问题

* fix(desktop): 打字机步长自适应 + 修复 details 自冻结循环

- renderDraft 步长按上次渲染耗时自适应:轻文档小步封顶 160 字/帧
  平滑打字,大块到达时数十帧内追平而非一帧瞬跳一大坨;重文档加大
  步长用更少次昂贵渲染排空积压,避免单帧卡死数百 ms。
- rewriteDraftBubble 程序化恢复 details open 态会异步触发 toggle,
  置 _suppressToggleFreeze 标记让 guard 忽略,setTimeout(0) 在已排队
  的 toggle 任务后清除,消除自冻结循环。
- bump app.js?v=102→103 刷新缓存。

* fix: 合并后以远程流式为准,修复自动合并损坏并接 composer DOM

* fix(desktop): Plan 与 TUI 对齐,按会话绑定且不因预设文案误激活

* fix(desktop): Plan 条稳定显示,对齐 TUI grace 与按会话 cwd 解析

* fix(desktop): Plan 按会话绑定 plan_path,修复完成态与跨会话串扰

* fix(desktop): Plan 完成态立即用最新 items 刷新卡片

* 优化plan card视觉

* feat(desktop): 三栏布局对齐与会话加载态

统一顶栏/侧栏/右栏行高与间距,聊天区与输入框分框留白并同宽;切换会话 hydrate 时显示加载提示;历史列表隐藏滚动条且与「新对话」间距一致。

* feat(desktop): 统一聊天与 Conductor 对话布局并复用 composer

引入 page--chat-ui 共用壳层与 bindComposerInRoot;Conductor 提示词气泡独立于输入框;修正侧栏拖拽热区对齐缝隙。

* [update]:修改文件上传大小至500MB

* feat(desktop): bridge auto-starts non-IM services on boot (L4 cron)

The reflect/scheduler.py service drives the L4 archive cron every 12h,

but was only registered in the catalog and never auto-started. The UI

panel also wasn't surfacing it (WS snapshot filters to IM only), so

there was effectively no user-facing way to turn L4 archiving on.

Net effect: every GA deployment (local Tauri, Squishy, kw, etc.) had

L4_raw_sessions/ permanently empty. compress_session.py never ran.

Fix: on bridge startup, autostart everything in _catalog that isn't an

IM service. Currently that's just reflect/scheduler.py. IM services stay

manual because they need mykey.py config + explicit user opt-in.

After this lands:

  - bridge boots -> scheduler subprocess spawned

  - ~2 min later -> first L4 archive runs (since _l4_t starts at 0)

  - every 12h thereafter -> next L4 archive cycle

  - users get L4 with zero config


* fix(reflect/scheduler): ensure sche_tasks/ exists before opening log file

scheduler.py:23 immediately does logging.FileHandler(sche_tasks/scheduler.log)

at module load. .gitignore excludes sche_tasks/ entirely, so any fresh

deploy (clone + run) crashes with FileNotFoundError before scheduler

even gets to its main loop.

The author's intent (line 76: 'if not os.path.isdir(TASKS): return None')

is that sche_tasks/ should be optional — scheduler gracefully no-ops

when it's missing. The top-level FileHandler call violates that.

Fix: makedirs before opening the log. Mirrors the makedirs already done

for DONE/ inside check() at line 78.

After this lands, any GA deployment (Tauri / Squishy / kw / launch.pyw)

can start scheduler without manual mkdir, and L4 archive cron runs from

day one.


* [FIX]:估算价格改成缓存率

* fix: 持久化 llm_history 修复重启后模型幻觉工具调用


* fix(reflect/scheduler): switch to lsdefine's mkdir fix (drop my variant)

lsdefine/main b365945 had independently landed the same makedirs fix

I added in ac2779d, but at module top-level and creating DONE/ instead

of dirname(_LOG).

Functionally equivalent (both unblock fresh-deploy scheduler import), but

to keep this fork aligned with upstream we adopt lsdefine's version

verbatim. After future content-overlay syncs from upstream, no diff in

this file.


* fix(reflect/scheduler): switch to lsdefine's mkdir fix (drop my variant)

lsdefine/main b365945 had independently landed the same makedirs fix

I added in ac2779d, but at module top-level and creating DONE/ instead

of dirname(_LOG).

Functionally equivalent (both unblock fresh-deploy scheduler import), but

to keep this fork aligned with upstream we adopt lsdefine's version

verbatim. After future content-overlay syncs from upstream, no diff in

this file.


* fix(desktop): 会话 loading 与 hydrate 生命周期分离

切会话先清遮罩;hydrate 首拍拉历史后即关 loading,running 交后台 poll;10s 超时兜底。

* fix(desktop): hydrate 用 limit=0 拉全量历史

避免与 bridge 尾部截断语义冲突导致超过 200 条会话缺开头消息;抽取 fetchSessionPoll 复用 poll 逻辑。

* fix(desktop): 流式结束后 tool call 不再被 openIdx 误保持展开

rewriteDraftBubble 跨帧恢复 details 时跳过 fold-tool-live,仅保留用户手动展开的折叠。

* fix(desktop): 轮次标题单行显示,避免窄栏折行

fold-turn summary 与 turn-summary 加 nowrap,副标题过长 ellipsis。

* chore: sync framework to upstream lsdefine/GenericAgent @ b365945

上次同步点是 1eb8c4c (我们 fork 上为 8f89766),lsdefine 自那以来 102 个

commit,包括飞书 IM 插件、TUI v2 修、conductor OOP 重构、L4 scheduler

mkdir 修复、checklist 系统、computer_use SOP 等。

处理分桶:

  - 45 个 lsdefine-only 文件:整文件覆盖(safe,无我方改动)

  - 17 个 ours-only 文件:完全保留(桌面前端 + bridge + plan_state)

  - 4 个双方都动:逐个 3-way 合

      * .gitignore: 取并集(lsdefine 新增 + 我们 /BUG_*.md)

      * reflect/scheduler.py: 已字节对齐 lsdefine,无操作

      * frontends/conductor.py: lsdefine OOP 重构作 base,我们 6 处

          改动(CORS / add_chat files+images / cost_tracker / token-stats

          / WS running / WS receive files+images)原样塞进新结构

      * frontends/conductor.html: lsdefine 重组作 base,我们 40 行

          输入字数限制 + toast 原样塞进 keydown 监听之后

未触动 desktop_bridge.py(memory 规则约定 exclude bridge)。

已 grep 全文件无 <<<<<< / ======= / >>>>>> 冲突标记残留。

我们改动一行未漏,全部按原代码原文落实,没有自创新代码。


* style(desktop): Conductor 分工进度抽屉更紧凑

去掉标题/统计区分割线,缩小计数徽标与任务卡片,统一圆角与上下留白。

* feat: turn_segs 结构化渲染,支持按轮独立渲染与模型切换


* 适配流式渲染

* fix(desktop): rebuild assistant bubble from final m on done to avoid frp tail loss

Post-merge ljq's "reuse draftEl, update in-place on done" optimization relied
on partial stream having delivered the complete content by the time the done
signal arrives. Over the frp tunnel (~80-300ms RTT), the bridge can flip to
idle before the last partial chunk reaches the browser, leaving the bubble
with truncated content from the partial accumulation.

Replace both turn_segs and no-turn_segs branches with a single in-place rebuild
that takes the final m as canonical source:
- assistantTurnSegs(m) handles both turn_segs and content fallback (same as
  the refresh path through msgNode)
- bubble.innerHTML = renderAssistantTurnsHtml(...) + postRenderEnhance for
  code highlighting and other post-render hooks
- sync r.draftSegs/draftTurn/streamTurn/twState so later cursor/badge/copy
  logic reads consistent state

Keeps ljq's no-flicker bubble reuse; same DOM element survives, only its
innerHTML gets replaced by the canonical full content. Local low-latency
behavior unchanged.

* fix: 修复两个侧边栏拖动延迟(拖拽时禁用transition)

* fix(desktop): 切换会话后恢复「已运行 Xs」elapsed badge

切换到已hydrate的会话时,setActiveSession的else分支
漏调restoreElapsedBadges,导致DOM重建后badge丢失。

Co-authored-by: GenericAgent <292701730+GenericAgent@users.noreply.github.com>

* fix(desktop): onBridgeReady 无条件刷新状态条

有活跃会话时 refreshStatusLabel 未被调用,
导致状态条卡在「连接中」直到 hydrate 完成。

Co-authored-by: GenericAgent <292701730+GenericAgent@users.noreply.github.com>

* feat(desktop): 侧边栏新增 DeepSeek/Qwen 官方 API 快速接入入口与指引

- 侧边栏空白区新增「快速接入」卡片,DeepSeek / 通义千问 一键按钮
- 点击后打开预填好 API 地址/协议/模型的添加模型表单,仅需粘贴 Key
- 表单顶部接入指引横幅:分步说明 + 获取 Key 链接 + 复制按钮
- 中英文双语文案,语言切换实时生效


* feat(desktop): 快速接入卡片支持折叠,并改用 DeepSeek/Qwen 官方 logo

- 卡片头部新增向下箭头,可折叠/展开,状态记忆到 localStorage
- DeepSeek / 通义千问 图标替换为官方 logo(品牌色 + 浅色徽底)
- 添加模型弹窗指引横幅同步使用官方 logo


* style(desktop): 快速接入卡片改用中性白/浅灰配色,去除主题色调

- 外层卡片改为白色(--card)+ 中性描边(--line-base)
- 内层按钮改为浅灰(--bg-base),去除原 --line-soft 的主题绿色调


* fix(desktop): 修复首次打开状态栏卡在「连接中」

启动引导是 async IIFE,首行 await loadSessions() 会让出事件循环;
此期间 WS 一连上 bridge 即推送 bridge-ready(desktop_bridge ws_handler),
onBridgeReady 已把 state.bridgeReady 置 true 并刷成「就绪」。但随后引导
无条件执行 chatStatus.setConnecting() 又覆盖回「连接中」,且此后 WS 已连、
不再触发 bridge-ready,状态便一直卡住,直到发消息触发 refreshStatusLabel
才恢复。

改为:若 await 期间 bridge 已就绪(state.bridgeReady=true)则按真实状态
渲染,否则才显示「连接中」,由 onBridgeReady 后续翻转。


* Add desktop release test materials

* Revert "Add desktop release test materials"

This reverts commit 82442ce9df.

* feat(desktop): fix trend chart text squish + remove color theme picker

Trend chart fix:
- SVG had preserveAspectRatio="none" which stretched the viewBox 600x260
  non-uniformly to the container (max-width 760 x height 280), making text
  ~15% wider than tall ("扁").
- Switch to preserveAspectRatio="xMidYMid meet" so text renders at correct
  aspect, and replace fixed container height with aspect-ratio:600/260 +
  min-height:280px so chart fills naturally without distortion.

Color theme cleanup (user-requested):
- Drop 8-color mac-style palette, keep only a single gray light theme.
- --accent now hard-coded gray (#8e8e93); removed --swatch-2..8 and all
  html[data-theme="N"] / .swatch[data-theme="N"] CSS rules.
- Remove the entire "颜色" (set.theme) block from settings page.
- applyTheme() stubbed: still callable for backward compat with stored
  ga_theme values, but always normalizes to theme '1'.
- Drop set.theme i18n entries (zh + en).

Cache busters: styles.css 136→137, app.js 181→182.

* feat(desktop): sidebar + token page UI polish

Sidebar:
- 快速接入官方模型 header restyled to match 配置 button (foot-row style:
  transparent bg, padding, hover state, lightning icon on left,
  caretRight chevron on right that rotates 90deg when expanded).
- Drop the "填好 API Key 即可使用" subtitle for single-line consistency.
- Drop the "展开/折叠" tooltip on the head.
- Bump both .pq-head and .foot-row padding to 14px so the buttons look
  substantial.
- Set .sidebar-foot margin-top:6px to match .provider-quickstart's internal
  gap:6px — uniform 6px rhythm between pq-head→DeepSeek→Qwen→配置.

Services page:
- Remove the "IM 消息通道与后台进程,集中查看、启停与日志" subtitle.
- Align .svc-tabs margin with .tok-tabs (margin:10px 0 0) so the title
  position and tab bar position match the Token usage page.

Token page (i18n):
- zh: "Conductor" → "指挥家" on tab label, total/current row labels,
  tooltip, and the new tok.condOffline string (replaces hard-coded text).
- en remains "Conductor" (English UI keeps the English name).

Token table column widths:
- Switch to table-layout:fixed. Number columns 80px each, cache-rate 85px.
- First column (session name) gets the rest of the width; overflow truncates
  with ellipsis. JS adds title="<full name>" so hover shows the complete
  session title as a tooltip. No more column-width jitter when switching
  Chat/指挥家 tabs.

Color tweaks (no hardcoded values):
- .pq-btn background var(--bg-base) → var(--field-bg) (lighter).
- .model-guide border var(--accent) → var(--line-base); background
  var(--accent-bg) → var(--field-bg) (lighter, neutral).

* feat: typography alignment with system font baseline

- Consolidate font stack: remove GA Lexend/Noto Sans, use system sans (SF Pro/Segoe UI)
- Reduce CSS font variables from 6 to 3 (--font-sans, --font-mono, --font-brand)
- Converge font-weights to match reference spec (700→600 headings, 600→500 labels)
- Add explicit font-family to pre/code blocks
- Compact nav-item: 28px height, 8px padding, 16px icons
- Remove 'token' from all UI copy, simplify to 'Usage'/'用量'
- Add cache-buster to fonts.css link

* feat(desktop): collab offline UI — replace banner with topbar status + refresh icon

The previous collab page showed a wide banner with the long offline message
plus a "重试" chip button across the top, which looked clunky. Move both
into the existing page-top status bar:

- Drop .collab-offline and .collab-reconnect <div> banners entirely
  (HTML, CSS rules, and JS show/hide logic).
- Wrap .run-state and a new .collab-retry-btn in a .page-top-center flex
  container (grid-column:2 of the page-top grid).
- Topbar text replaces "未连接" with the new collab.offlineShort string
  ("无法连接 Conductor(8900)" / "Cannot reach Conductor (8900)") via
  collabStatus.set(longer, 'offline').
- The retry button is a 24×24 button styled like .new-conv (var(--line-soft)
  bg + 1px border + 4px radius, hover var(--hover-2)), holding a 14×14
  inline SVG refresh-cw icon. Hidden by default, shown only when offline
  AND no auto-reconnect is in progress.
- setConnUi simplified to just toggle the retry icon's [hidden] attribute.
- Existing click handler on #collab-retry preserved (id unchanged).

* style(desktop): collab retry button uses .preset-restore look (white card bg)

Drop the var(--line-soft) gray background — too dim against the topbar.
Match .preset-restore button styling:
- background: var(--card) (clean white panel)
- border: var(--line)
- hover: bg var(--hover-2), color + border become var(--accent)

styles.css?v=152→153.

* fix(desktop): UI polish, custom-task edit, input-limit fixes

- 自定义任务卡: 主图标改用户图标, 新增'编辑任务'(铅笔->预填弹窗->更新),
  标题/描述截断(…)+tooltip
- 字数上限告警重构: 每字段独立提示(消除串扰), 文案只提醒不提截断,
  2.5s自动消失, 超限尝试才触发, JS管控长度修复中文IME下不触发的问题
- Token统计: 重置按钮与日期框高度统一(30px)
- 会话项'更多'按钮: 放大并调深颜色, 解决几乎看不到
- 侧边栏选中态: 深色文字提对比 + text-shadow假加粗(不改字宽,避免抖动)
- 全局弱文字对比度: --muted/--muted2 浅色调深、深色调亮

* style: unify default font size to 13px

- --chat-font: 14px → 13px (bubble text baseline)
- --shell-head-h: 36px → 28px (tighter header)
- composer .input: 15px → 13px
- composer placeholder: 15px → 13px
- bubble h1: add font-weight:600, adjust padding

* refactor: relocate status indicator to sidebar footer

- Move .run-state button from page header to sidebar .foot-status
- Add .foot-status layout (flex, gap, version label inline)
- Remove old .foot-ver block (merged into .foot-status)
- Remove .page > .page-top .run-state positioning rule
- Extend .run-state animations to cover .foot-status context

* copy: optimize connection status i18n strings

- Unify Chinese naming to 指挥家 (remove English 'Conductor' from user-facing copy)
- Remove hardcoded port 8900 from error messages
- Status labels: 就绪→服务在线, 运行中→处理中, 未连接→服务离线, 空闲→待命
- collab.offline: simplified to generic guidance (no tunnel mention)
- Add Chinese err.bridge: 服务未响应

* ui: migrate inline SVGs to phosphor icon system

- Replace all inline <svg> in index.html with data-ga-icon attributes
- Add 7 new phosphor icons: crosshair, compass, gitFork, hexagon, fileText, star, liFork
- Preset cards in app.js now use GA_ICON() getter instead of raw SVG strings
- Bump cache-bust: phosphor-icons.js?v=2, app.js?v=186

* style: add --divider token and refine panel borders

- Introduce --divider-base and --divider CSS custom properties
- Light themed: rgba(15,74,133,0.22) blended with accent
- Dark mode: #569cd6 base
- Right panel now has visible border-left: 1px solid var(--divider)
- Resize handle narrowed to 6px with -3px margin for better grab UX
- Add #cdb-model-chip .model-name to monospace selector

* copy: update collab placeholder and preset card icons

- Collab placeholder zh: '请对指挥家描述你想完成的目标'
- Collab placeholder en: 'Describe the goal you want to accomplish'
- Preset cards now reference GA_ICON() for consistent icon rendering

* feat(desktop): bridge auto-starts conductor on boot (--no-browser flag)

Conductor used to be a separately-launched service that opens its own UI
in the user's browser. With the desktop app, users access conductor via
the built-in 「指挥家」 page connecting to port 8900, so the standalone UI
window is redundant and confusing.

- conductor.py: gain a --no-browser flag that skips the webbrowser.open
  call. Default behavior (no flag) unchanged for backward compat with
  start_conductor.ps1 / GAConductor scheduled task / manual python runs.
- desktop_bridge.py discover_extra_services: also enumerate conductor.py
  alongside scheduler.py, with --no-browser baked into the spawn cmd.
  autostart_extras then picks it up automatically on bridge boot.

* feat(desktop): drop trend chart + windowed pagination for token table

Trend chart removed from this release:
- index.html: drop the entire tok-chart-wrap block (toggle button + chart)
- app.js: drop renderTokChart() function, tokChartEl/tokChartToggle/tokChartWrap
  const+listener, all conditional display branches in tab switching and
  loadTokenPage, and the tok.chartToggle i18n key (zh + en)
- styles.css: drop .tok-chart-wrap, .tok-chart-toggle, .tok-chart rules

Token table paginator no longer dumps every page button:
- New renderTokPager() builds first / last / current ± 2 / ellipsis / prev /
  next, capped at ~9 DOM nodes regardless of total pages. 1000 pages and
  10 pages render the same shape, no more flex row overflow.
- Add .tok-pager-gap style for ellipsis spans, :disabled style for endpoint
  arrows, and flex-wrap:wrap fallback on .tok-pager.

Cache busters: styles.css 153→154, app.js 186→187.

* fix(desktop): bump mykey config editor max length 20000 → 1000000

Real mykey.py configs with many providers + IM tokens + Langfuse + heavy
comments easily exceed 20000 chars. 1M ceiling effectively removes the
limit for any realistic config while still preventing accidental
multi-megabyte paste from misbehaving content.

* feat(desktop): chat status restored to topbar + send btn ink-green + findwork preset + pager arrow polish

UI cleanup wave:

Chat status indicator:
- Move .run-state ('服务在线' chip) back from sidebar foot to chat page-top
  (reverts the PR #15 38e1670 relocation only; other PR #15 changes kept)
- Restore .foot-ver version label block in sidebar foot
- Restore .page > .page-top .run-state grid positioning rule
- Drop all .foot-status CSS + i18n + breathing-dot selectors
- .run-state height switched to var(--shell-head-h) so it sits flush with
  the 28px topbar grid (no more 2px overshoot)

Send button:
- .send background var(--accent) → var(--ink-green) (#1f6f53), hover →
  var(--ink-green-hover) (#175a44). Two new CSS vars introduced in :root
  to avoid hardcoded colors per project convention.
- Stop-state (.send.is-stop) still uses var(--danger) — no change

New preset card ('找点事做'):
- Adds BUILTIN_PRESETS entry between review and mine using phosphor robot
  icon. Sends the same fixed prompt as stapp.py's autonomous-action button:
  按照自主行动的规划部分,充分分析我的情况,给我生成一批 TODO,务必让我感兴趣。
- New i18n keys: preset.findwork.t/d + presetPrompt.findwork (zh + en)

Token pager arrows:
- Replace Unicode '‹' '›' with phosphor caretLeft/caretRight SVGs (12×12,
  muted2 → txt-2 on hover), matching the sidebar .chev style
- Drop the font-size 20px + padding-bottom workaround that tried to
  visually center the Unicode characters

* feat(desktop): replace 自主探索 preset with 自主行动 (reflect SOP trigger)

Old '自主探索' (auto-explore) was just a browse+summarize prompt, unrelated
to the framework's reflect/autonomous.py module. Teacher requested a real
trigger for autonomous mode — same behavior as the 30-min idle reflect cron
but user-initiated via preset click.

- BUILTIN_PRESETS: key 'explore' → 'autonomous', icon 'compass' → 'gridFour'
- i18n rename (zh + en):
    preset.explore.* → preset.autonomous.* ('自主行动' / 'Autonomous mode')
    presetPrompt.explore → presetPrompt.autonomous
- New prompt points at memory/autonomous_operation_sop.md so the agent
  reads the same SOP that reflect/autonomous.py uses. Mirror of the cron
  trigger '[AUTO]🤖 用户已经离开...' but framed as user-initiated.
- Side effect: users who had 'explore' hidden in localStorage will see the
  new '自主行动' card show up by default. Acceptable for a feature change.

Cache buster: app.js 189 → 190.

* fix(desktop): add standard line-clamp alongside -webkit prefix in .fc-d

Silences vendorPrefix linter warning; future-proofs preset card description
clamp for browsers that drop the -webkit alias.


* feat(desktop): add conductor to status panel; group system services above IM

list_panel_state now emits bridge → extras (conductor + scheduler) → IM,
giving conductor a friendly 指挥家 / Conductor label and grouping system
services together at the top of the panel.


* refactor(bridge): compact list_panel_state extras-first ordering into sort key

Same behavior (extras above IM in panel), one line instead of four.


* feat(desktop): merge tested desktop updates without release packaging


* chore: sync framework to upstream lsdefine/GenericAgent @ c85b59e (excluding conductor.py + frontends/desktop/)

* chore: sync conductor.py to upstream lsdefine/GenericAgent @ c85b59e

3-way merge (base=b365945, ours=lsdefine, theirs=desktop fork) finished
with 0 conflicts: lsdefine's prompt rewrites + int(time.time())
regression land cleanly; desktop fork's 6 patches (CORSMiddleware,
_desktop_llm_no, cost_tracker.install, /token-stats, add_chat
files+images, --no-browser) are preserved on top.


---------

Co-authored-by: yiqi-017 <22300246009@m.fudan.edu.cn>
Co-authored-by: weaselkkk <3524909275@qq.com>
Co-authored-by: lyx <2442548803@qq.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: 顾祎炜 <163639227+BadMoney006@users.noreply.github.com>
Co-authored-by: Yiwei Gu <yiwei.gu@sap.com>
Co-authored-by: HYC-hsy <3510069313@qq.com>
Co-authored-by: wjl2023 <weijialee2023@163.com>
Co-authored-by: YI QI LIM <163961261+yiqi-017@users.noreply.github.com>
Co-authored-by: GenericAgent <292701730+GenericAgent@users.noreply.github.com>
Co-authored-by: weaselkns <164005281+weaselkns@users.noreply.github.com>
Co-authored-by: yanghang <2390375302@qq.com>
Co-authored-by: ethan su <ethan@ethandeMacBook-Pro.local>
2026-06-23 09:32:59 +08:00

1078 lines
60 KiB
Python

import os, json, re, time, requests, sys, threading, urllib3, base64, importlib, uuid, pathlib
from datetime import datetime
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
_RESP_CACHE_KEY = str(uuid.uuid4()); _RESP_CODEX_KEY = str(uuid.uuid4())
_ROOT = os.path.dirname(os.path.abspath(__file__))
if _ROOT not in sys.path: sys.path.append(_ROOT)
def _load_mykeys():
global _mykey_path
try:
import mykey; importlib.reload(mykey); _mykey_path = mykey.__file__
return {k: v for k, v in vars(mykey).items() if not k.startswith('_')}
except ImportError as e:
if getattr(e, 'name', None) != 'mykey':
raise Exception(f'[ERROR] mykey.py found but failed to import: {e}') from e
except SyntaxError as e:
raise Exception(f'[ERROR] mykey.py has syntax error: {e}') from e
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mykey.json')
if not os.path.exists(p): raise Exception('[ERROR] mykey.py not found in sys.path and mykey.json not found. Run "python configure_mykey.py" or copy mykey_template.py to mykey.py and fill in your keys.')
with open(_mykey_path := p, encoding='utf-8') as f: mk = json.load(f)
if isinstance(mk, dict) and 'remote_url' in mk: return requests.get(mk['remote_url'], timeout=10).json()
return mk
_mykey_path = _mykey_mtime = None
def reload_mykeys():
global _mykey_mtime
try:
mt = os.stat(_mykey_path).st_mtime_ns if _mykey_path else -1
if mt == _mykey_mtime: return globals().get('mykeys', {}), False
mk = _load_mykeys(); _mykey_mtime = os.stat(_mykey_path).st_mtime_ns
print(f'[Info] Load mykeys from {_mykey_path}')
globals().update(mykeys=mk)
return mk, True
except: return globals().get('mykeys', {}), False
def __getattr__(name): # once guard in PEP 562
if name == 'mykeys': return reload_mykeys()[0]
raise AttributeError(f"module 'llmcore' has no attribute {name}")
def compress_history_tags(messages, keep_recent=10, max_len=800, force=False, interval=5):
"""Compress <thinking>/<tool_use>/<tool_result> tags in older messages to save tokens."""
compress_history_tags._cd = getattr(compress_history_tags, '_cd', 0) + 1
if force: compress_history_tags._cd = 0
if compress_history_tags._cd % interval != 0: return messages
_before = sum(len(json.dumps(m, ensure_ascii=False)) for m in messages)
_pats = {tag: re.compile(rf'(<{tag}>)([\s\S]*?)(</{tag}>)') for tag in ('thinking', 'think', 'tool_use', 'tool_result')}
_hist_pat = re.compile(r'<(history|key_info|earlier_context)>[\s\S]*?</\1>')
def _trunc_str(s): return s[:max_len//2] + '\n...[Truncated]...\n' + s[-max_len//2:] if isinstance(s, str) and len(s) > max_len else s
def _trunc(text):
text = _hist_pat.sub(lambda m: f'<{m.group(1)}>[...]</{m.group(1)}>', text)
for pat in _pats.values(): text = pat.sub(lambda m: m.group(1) + _trunc_str(m.group(2)) + m.group(3), text)
return text
for i, msg in enumerate(messages):
if i >= len(messages) - keep_recent: break
c = msg['content']
if isinstance(c, str): msg['content'] = _trunc(c)
elif isinstance(c, list):
for b in c:
if not isinstance(b, dict): continue
t = b.get('type')
if t == 'text' and isinstance(b.get('text'), str): b['text'] = _trunc(b['text'])
elif t == 'tool_result':
tc = b.get('content')
if isinstance(tc, str): b['content'] = _trunc_str(tc)
elif isinstance(tc, list):
for sub in tc:
if isinstance(sub, dict) and sub.get('type') == 'text': sub['text'] = _trunc_str(sub.get('text'))
elif t == 'tool_use' and isinstance(b.get('input'), dict):
for k, v in b['input'].items(): b['input'][k] = _trunc_str(v)
print(f"[Cut] {_before} -> {sum(len(json.dumps(m, ensure_ascii=False)) for m in messages)}")
return messages
def _sanitize_leading_user_msg(msg):
"""把 user 消息里的 tool_result 块改写成纯文本,避免孤立引用。
history 统一使用 Claude content-block 格式:content 是 list of blocks。"""
msg = dict(msg) # 浅拷贝外层 dict
content = msg.get('content')
if not isinstance(content, list): return msg
texts = []
for block in content:
if not isinstance(block, dict): continue
if block.get('type') == 'tool_result':
c = block.get('content', '')
if isinstance(c, list): # content 本身也可能是 list[{type:text,text:...}]
texts.extend(b.get('text', '') for b in c if isinstance(b, dict))
else: texts.append(str(c))
elif block.get('type') == 'text': texts.append(block.get('text', ''))
msg['content'] = [{"type": "text", "text": '\n'.join(t for t in texts if t)}]
return msg
_oldprint = print
def safeprint(*argv):
try: _oldprint(*argv)
except OSError: pass
print = safeprint
def trim_messages_history(history, sess):
cap = sess.context_win * 3
target = int(cap * getattr(sess, 'trim_keep_rate', 0.6))
def cost(): return sum(len(json.dumps(m, ensure_ascii=False)) for m in history)
compress_history_tags(history, interval=getattr(sess, 'cut_msg_interval', 5))
print(f'[Debug] Current context: {cost()} chars, {len(history)} messages.')
if cost() <= cap: return
compress_history_tags(history, keep_recent=4, force=True)
if cost() <= target: return
while len(history) > 9 and cost() > target:
history.pop(0)
while history and history[0].get('role') != 'user': history.pop(0)
if history and history[0].get('role') == 'user': history[0] = _sanitize_leading_user_msg(history[0])
print(f'[Debug] Trimmed context, current: {cost()} chars, {len(history)} messages.')
def auto_make_url(base, path):
b, p = base.rstrip('/'), path.strip('/')
if b.endswith('$'): return b[:-1].rstrip('/')
if b.endswith(p): return b
return f"{b}/{p}" if re.search(r'/v\d+(/|$)', b) else f"{b}/v1/{p}"
def _parse_claude_json(data):
content_blocks = data.get("content", [])
_record_usage(data.get("usage", {}), "messages")
for b in content_blocks:
if b.get("type") == "text": yield b.get("text", "")
elif b.get("type") == "thinking": yield ""
return content_blocks
def _parse_claude_sse(resp_lines):
"""Parse Anthropic SSE stream. Yields text chunks, returns list[content_block]."""
content_blocks = []; current_block = None; tool_json_buf = ""
stop_reason = None; got_message_stop = False; warn = None
for line in resp_lines:
if not line: continue
line = line.decode('utf-8') if isinstance(line, bytes) else line
if not line.startswith("data:"): continue
data_str = line[5:].lstrip()
if data_str == "[DONE]": break
try: evt = json.loads(data_str)
except Exception as e:
print(f"[SSE] JSON parse error: {e}, line: {data_str[:200]}")
continue
evt_type = evt.get("type", "")
if evt_type == "message_start":
usage = evt.get("message", {}).get("usage", {})
_record_usage(usage, "messages")
elif evt_type == "content_block_start":
block = evt.get("content_block", {})
if block.get("type") == "text": current_block = {"type": "text", "text": ""}
elif block.get("type") == "thinking": current_block = {"type": "thinking", "thinking": "", "signature": ""}
elif block.get("type") == "tool_use":
current_block = {"type": "tool_use", "id": block.get("id", ""), "name": block.get("name", ""), "input": {}}
tool_json_buf = ""
elif evt_type == "content_block_delta":
delta = evt.get("delta", {})
if delta.get("type") == "text_delta":
text = delta.get("text", "")
if current_block and current_block.get("type") == "text": current_block["text"] += text
if text: yield text
elif delta.get("type") == "thinking_delta":
if current_block and current_block.get("type") == "thinking": current_block["thinking"] += delta.get("thinking", "")
elif delta.get("type") == "signature_delta":
if current_block and current_block.get("type") == "thinking":
current_block["signature"] = current_block.get("signature", "") + delta.get("signature", "")
elif delta.get("type") == "input_json_delta": tool_json_buf += delta.get("partial_json", "")
elif evt_type == "content_block_stop":
if current_block:
if current_block["type"] == "tool_use":
try: current_block["input"] = json.loads(tool_json_buf) if tool_json_buf else {}
except: current_block["input"] = {"_raw": tool_json_buf}
content_blocks.append(current_block)
current_block = None
elif evt_type == "message_delta":
delta = evt.get("delta", {})
stop_reason = delta.get("stop_reason", stop_reason)
out_usage = evt.get("usage", {})
out_tokens = out_usage.get("output_tokens", 0)
if out_tokens: print(f"[Output] tokens={out_tokens} stop_reason={stop_reason}")
elif evt_type == "message_stop": got_message_stop = True
elif evt_type == "error":
err = evt.get("error", {})
emsg = err.get("message", str(err)) if isinstance(err, dict) else str(err)
warn = f"\n\n!!!Error: SSE {emsg}"; break
if not warn:
if not got_message_stop and not stop_reason: warn = "\n\n[!!! 流异常中断,未收到完整响应 !!!]"
elif stop_reason == "max_tokens": warn = "\n\n[!!! Response truncated: max_tokens !!!]"
if current_block:
if current_block["type"] == "tool_use":
try: current_block["input"] = json.loads(tool_json_buf) if tool_json_buf else {}
except: current_block["input"] = {"_raw": tool_json_buf}
content_blocks.append(current_block); current_block = None
if warn:
print(f"[WARN] {warn.strip()}")
insert_at = next((i for i,b in enumerate(content_blocks) if b.get("type") == "tool_use"), len(content_blocks))
content_blocks.insert(insert_at, {"type": "text", "text": warn}); yield warn
return content_blocks
def _try_parse_tool_args(raw):
"""Parse tool args string; split concatenated JSON objects like {..}{..} if needed.
Returns list of parsed dicts."""
if not raw: return [{}]
try: return [json.loads(raw)]
except: pass
parts = re.split(r'(?<=\})(?=\{)', raw)
if len(parts) > 1:
parsed = []
for p in parts:
try: parsed.append(json.loads(p))
except: return [{"_raw": raw}]
return parsed
return [{"_raw": raw}]
def _parse_openai_sse(resp_lines, api_mode="chat_completions"):
"""Parse OpenAI SSE stream (chat_completions or responses API).
Yields text chunks, returns list[content_block].
content_block: {type:'text', text:str} | {type:'tool_use', id:str, name:str, input:dict}
"""
content_text = ""
if api_mode == "responses":
seen_delta = False; fc_buf = {}; current_fc_idx = None
for line in resp_lines:
if not line: continue
line = line.decode('utf-8', errors='replace') if isinstance(line, bytes) else line
if not line.startswith("data:"): continue
data_str = line[5:].lstrip()
if data_str == "[DONE]": break
try: evt = json.loads(data_str)
except: continue
etype = evt.get("type", "")
if etype == "response.output_text.delta":
delta = evt.get("delta", "")
if delta: seen_delta = True; content_text += delta; yield delta
elif etype == "response.output_text.done" and not seen_delta:
text = evt.get("text", "")
if text: content_text += text; yield text
elif etype == "response.output_item.added":
item = evt.get("item", {})
if item.get("type") == "function_call":
idx = evt.get("output_index", 0)
fc_buf[idx] = {"id": item.get("call_id", item.get("id", "")), "name": item.get("name", ""), "args": ""}
current_fc_idx = idx
elif etype == "response.function_call_arguments.delta":
idx = evt.get("output_index", current_fc_idx or 0)
if idx in fc_buf: fc_buf[idx]["args"] += evt.get("delta", "")
elif etype == "response.function_call_arguments.done":
idx = evt.get("output_index", current_fc_idx or 0)
if idx in fc_buf: fc_buf[idx]["args"] = evt.get("arguments", fc_buf[idx]["args"])
elif etype == "error":
err = evt.get("error", {})
emsg = err.get("message", str(err)) if isinstance(err, dict) else str(err)
if emsg: content_text += f"!!!Error: {emsg}"; yield f"!!!Error: {emsg}"
break
elif etype == "response.completed":
usage = evt.get("response", {}).get("usage", {})
_record_usage(usage, api_mode)
break
blocks = []
if content_text: blocks.append({"type": "text", "text": content_text})
for idx in sorted(fc_buf):
fc = fc_buf[idx]
inps = _try_parse_tool_args(fc["args"])
for i, inp in enumerate(inps):
bid = fc["id"] or ''
if len(inps) > 1: bid = f"{bid}_{i}" if bid else f"split_{i}"
blocks.append({"type": "tool_use", "id": bid, "name": fc["name"], "input": inp})
return blocks
else:
tc_buf = {} # index -> {id, name, args}
reasoning_text = ""
for line in resp_lines:
if not line: continue
line = line.decode('utf-8', errors='replace') if isinstance(line, bytes) else line
if not line.startswith("data:"): continue
data_str = line[5:].lstrip()
if data_str == "[DONE]": break
try: evt = json.loads(data_str)
except: continue
ch = (evt.get("choices") or [{}])[0]
delta = ch.get("delta") or {}
if rc := delta.get("reasoning_content") or delta.get("reasoning", ""):
reasoning_text += rc; yield rc
if delta.get("content"):
text = delta["content"]; content_text += text; yield text
for tc in (delta.get("tool_calls") or []):
idx = tc.get("index", 0)
has_name = bool(tc.get("function", {}).get("name"))
if idx not in tc_buf:
if has_name or not tc_buf: tc_buf[idx] = {"id": tc.get("id") or '', "name": "", "args": ""}
else: idx = max(tc_buf)
if has_name: tc_buf[idx]["name"] = tc["function"]["name"]
if tc.get("function", {}).get("arguments"): tc_buf[idx]["args"] += tc["function"]["arguments"]
if tc.get("id") and not tc_buf[idx]["id"]: tc_buf[idx]["id"] = tc["id"]
usage = evt.get("usage")
if usage: _record_usage(usage, api_mode)
blocks = []
if reasoning_text: blocks.append({"type": "thinking", "thinking": reasoning_text})
if content_text: blocks.append({"type": "text", "text": content_text})
for idx in sorted(tc_buf):
tc = tc_buf[idx]
inps = _try_parse_tool_args(tc["args"])
for i, inp in enumerate(inps):
bid = tc["id"] or ''
if len(inps) > 1: bid = f"{bid}_{i}" if bid else f"split_{i}"
blocks.append({"type": "tool_use", "id": bid, "name": tc["name"], "input": inp})
return blocks
def _record_usage(usage, api_mode):
if not usage: return
if api_mode == 'responses':
cached = (usage.get("input_tokens_details") or {}).get("cached_tokens", 0)
inp = usage.get("input_tokens", 0); out = usage.get("output_tokens", 0)
print(f"[Cache] input={inp} cached={cached}")
if out: print(f"[Output] tokens={out}")
elif api_mode == 'chat_completions':
cached = (usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0)
inp = usage.get("prompt_tokens", 0); out = usage.get("completion_tokens", 0)
print(f"[Cache] input={inp} cached={cached}")
if out: print(f"[Output] tokens={out}")
elif api_mode == 'messages':
ci, cr, inp = usage.get("cache_creation_input_tokens", 0), usage.get("cache_read_input_tokens", 0), usage.get("input_tokens", 0)
print(f"[Cache] input={inp} creation={ci} read={cr}")
def _parse_openai_json(data, api_mode="chat_completions"):
blocks = []
if api_mode == "responses":
_record_usage(data.get("usage") or {}, api_mode)
for item in (data.get("output") or []):
if item.get("type") == "message":
for p in (item.get("content") or []):
if p.get("type") in ("output_text", "text") and p.get("text"):
blocks.append({"type": "text", "text": p["text"]}); yield p["text"]
elif item.get("type") == "function_call":
try: args = json.loads(item.get("arguments", "")) if item.get("arguments") else {}
except: args = {"_raw": item.get("arguments", "")}
blocks.append({"type": "tool_use", "id": item.get("call_id", item.get("id", "")),
"name": item.get("name", ""), "input": args})
else:
_record_usage(data.get("usage") or {}, api_mode)
msg = (data.get("choices") or [{}])[0].get("message", {})
reasoning = msg.get("reasoning_content") or msg.get("reasoning", "")
if reasoning:
blocks.append({"type": "thinking", "thinking": reasoning})
content = msg.get("content", "")
if content:
blocks.append({"type": "text", "text": content}); yield content
for tc in (msg.get("tool_calls") or []):
fn = tc.get("function", {})
try: args = json.loads(fn.get("arguments", "")) if fn.get("arguments") else {}
except: args = {"_raw": fn.get("arguments", "")}
blocks.append({"type": "tool_use", "id": tc.get("id", ""), "name": fn.get("name", ""), "input": args})
return blocks
def _stamp_oai_cache_markers(messages, model):
"""Add cache_control to last 2 user messages for Anthropic models via OAI-compatible relay."""
ml = model.lower()
if not any(k in ml for k in ('claude', 'anthropic')): return
user_idxs = [i for i, m in enumerate(messages) if m.get('role') == 'user']
for idx in user_idxs[-2:]:
c = messages[idx].get('content')
if isinstance(c, str):
messages[idx] = {**messages[idx], 'content': [{'type': 'text', 'text': c, 'cache_control': {'type': 'ephemeral'}}]}
elif isinstance(c, list) and c:
c = list(c); c[-1] = dict(c[-1], cache_control={'type': 'ephemeral'})
messages[idx] = {**messages[idx], 'content': c}
def _stream_with_retry(sess, url, headers, payload, parse_fn):
_RETRYABLE = {408, 409, 425, 429, 500, 502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 529}
def _delay(resp, attempt):
try: ra = float((resp.headers or {}).get("retry-after"))
except: ra = None
return max(0.5, ra if ra is not None else min(30.0, 1.5 * (2 ** attempt)))
for attempt in range(sess.max_retries + 1):
streamed = False
try:
with requests.post(url, headers=headers, json=payload, stream=sess.stream,
timeout=(sess.connect_timeout, sess.read_timeout), proxies=sess.proxies, verify=sess.verify) as r:
if r.status_code >= 400:
if r.status_code in _RETRYABLE and attempt < sess.max_retries:
d = _delay(r, attempt)
print(f"[LLM Retry] HTTP {r.status_code}, retry in {d:.1f}s ({attempt+1}/{sess.max_retries+1})")
time.sleep(d); continue
try: body = r.text.strip()[:500]
except: body = ""
err = f"!!!Error: HTTP {r.status_code}" + (f": {body}" if body else "")
yield err; return [{"type": "text", "text": err}]
gen = parse_fn(r)
try:
while True: chunk = next(gen); streamed = True; yield chunk
except StopIteration as e:
if not e.value and not streamed: raise requests.ConnectionError("empty response")
return e.value or []
except (requests.Timeout, requests.ConnectionError) as e:
#pathlib.Path(__file__).parent.joinpath('temp','bad_requests.json').write_text(json.dumps({"url":url,"headers":headers,"payload":payload,"err":str(e),"t":time.time()},ensure_ascii=False),encoding='utf-8')
err = f"!!!Error: {type(e).__name__}: {e}" if str(e) else f"!!!Error: {type(e).__name__}"
if attempt < sess.max_retries:
d = _delay(None, attempt)
print(f"[LLM Retry] {type(e).__name__}, retry in {d:.1f}s ({attempt+1}/{sess.max_retries+1})")
yield err; time.sleep(d); continue
yield err; return [{"type": "text", "text": err}]
except Exception as e:
err = f"\n\n[!!! 流异常中断 {type(e).__name__}: {e} !!!]" if streamed else f"!!!Error: {type(e).__name__}: {e}"
yield err; return [{"type": "text", "text": err}]
def _openai_stream(sess, messages):
model, api_mode = sess.model, sess.api_mode
ml = model.lower()
temperature = sess.temperature
if 'kimi' in ml or 'moonshot' in ml: temperature = 1
elif 'minimax' in ml: temperature = max(0.01, min(temperature, 1.0)) # MiniMax requires temp in (0, 1]
headers = {"Authorization": f"Bearer {sess.api_key}", "Content-Type": "application/json", "Accept": "text/event-stream", 'originator': 'codex_exec'}
headers["User-Agent"] = sess.user_agent
if api_mode == "responses":
url = auto_make_url(sess.api_base, "responses")
payload = {"model": model, "input": _to_responses_input(messages), "stream": sess.stream,
"prompt_cache_key": _RESP_CACHE_KEY, "instructions": sess.system or "You are an Omnipotent Executor.",
"client_metadata": {"x-codex-window-id": f"{_RESP_CACHE_KEY}:0","x-codex-installation-id": _RESP_CODEX_KEY},
'include': ['reasoning.encrypted_content']}
if sess.reasoning_effort: payload["reasoning"] = {"effort": sess.reasoning_effort}
if sess.max_tokens: payload["max_output_tokens"] = sess.max_tokens
else:
url = auto_make_url(sess.api_base, "chat/completions")
if sess.system: messages = [{"role": "system", "content": sess.system}] + messages
_stamp_oai_cache_markers(messages, model)
payload = {"model": model, "messages": messages, "stream": sess.stream}
if sess.stream: payload["stream_options"] = {"include_usage": True}
if temperature != 1: payload["temperature"] = temperature
if sess.max_tokens: payload["max_completion_tokens" if ml.startswith(("gpt-5", "o1", "o2", "o3", "o4")) else "max_tokens"] = sess.max_tokens
if sess.reasoning_effort: payload["reasoning_effort"] = sess.reasoning_effort
tools = getattr(sess, 'tools', None)
if tools: payload["tools"] = _prepare_oai_tools(tools, api_mode)
if sess.service_tier: payload["service_tier"] = sess.service_tier
parse_fn = (lambda r: _parse_openai_sse(r.iter_lines(), api_mode)) if sess.stream else (lambda r: _parse_openai_json(r.json(), api_mode))
return (yield from _stream_with_retry(sess, url, headers, payload, parse_fn))
def _prepare_oai_tools(tools, api_mode="chat_completions"):
if api_mode == "responses":
resp_tools = []
for t in tools:
if t.get("type") == "function" and "function" in t:
rt = {"type": "function"}; rt.update(t["function"])
resp_tools.append(rt)
else: resp_tools.append(t)
return resp_tools
return tools
def _to_responses_input(messages):
result, pending = [], []
for msg in messages:
role = str(msg.get("role", "user")).lower()
if role == "tool":
cid = msg.get("tool_call_id") or (pending.pop(0) if pending else f"call_{uuid.uuid4().hex[:8]}")
result.append({"type": "function_call_output", "call_id": cid, "output": msg.get("content", "")})
continue
if role not in ["user", "assistant", "system", "developer"]: role = "user"
if role == "system": role = "developer" # Responses API uses 'developer' instead of 'system'
content = msg.get("content", "")
text_type = "output_text" if role == "assistant" else "input_text"
parts = []
if isinstance(content, str):
if content: parts.append({"type": text_type, "text": content})
elif isinstance(content, list):
for part in content:
if not isinstance(part, dict): continue
ptype = part.get("type")
if ptype == "text":
text = part.get("text", "")
if text: parts.append({"type": text_type, "text": text})
elif ptype == "image_url":
url = (part.get("image_url") or {}).get("url", "")
if url and role != "assistant": parts.append({"type": "input_image", "image_url": url})
if len(parts) == 0: parts = [{"type": text_type, "text": str(content) if not isinstance(content, list) else '[empty]'}]
result.append({"role": role, "content": parts})
pending = []
for tc in (msg.get("tool_calls") or []):
f = tc.get("function", {})
cid = tc.get("id") or f"call_{uuid.uuid4().hex[:8]}"
pending.append(cid)
result.append({"type": "function_call", "call_id": cid, "name": f.get("name", ""), "arguments": f.get("arguments", "")})
return result
def _msgs_claude2oai(messages):
result = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
blocks = content if isinstance(content, list) else [{"type": "text", "text": str(content)}]
if role == "assistant":
text_parts, tool_calls, reasoning = [], [], ""
for b in blocks:
if not isinstance(b, dict): continue
if b.get("type") == "thinking" and b.get("thinking"): reasoning = b["thinking"]
elif b.get("type") == "text" and b.get("text"): text_parts.append({"type": "text", "text": b.get("text", "")})
elif b.get("type") == "tool_use":
tool_calls.append({
"id": b.get("id") or '', "type": "function",
"function": {"name": b.get("name", ""), "arguments": json.dumps(b.get("input", {}), ensure_ascii=False)}
})
m = {"role": "assistant"}
if reasoning: m["reasoning_content"] = reasoning
if text_parts: m["content"] = text_parts
elif not tool_calls: m["content"] = "."
if tool_calls: m["tool_calls"] = tool_calls
result.append(m)
elif role == "user":
text_parts = []
for b in blocks:
if not isinstance(b, dict): continue
if b.get("type") == "tool_result":
if text_parts:
result.append({"role": "user", "content": text_parts})
text_parts = []
tr = b.get("content", "")
if isinstance(tr, list):
tr = "\n".join(x.get("text", "") for x in tr if isinstance(x, dict) and x.get("type") == "text")
result.append({"role": "tool", "tool_call_id": b.get("tool_use_id") or '', "content": tr if isinstance(tr, str) else str(tr)})
elif b.get("type") == "image":
src = b.get("source") or {}
if src.get("type") == "base64" and src.get("data"):
text_parts.append({"type": "image_url", "image_url": {"url": f"data:{src.get('media_type', 'image/png')};base64,{src.get('data', '')}"}})
elif b.get("type") == "image_url": text_parts.append(b)
elif b.get("type") == "text" and b.get("text"): text_parts.append({"type": "text", "text": b.get("text", "")})
if text_parts: result.append({"role": "user", "content": text_parts})
else: result.append(msg)
return result
class BaseSession:
def __init__(self, cfg):
self.api_key = cfg['apikey']
self.api_base = cfg['apibase'].rstrip('/')
self.model = cfg.get('model', '')
default_context_win = 30000
if 'deepseek' in self.model.lower():
default_context_win = 70000; self.cut_msg_interval = 25; self.trim_keep_rate = 0.3
self.context_win = cfg.get('context_win', default_context_win)
self.history = []; self.lock = threading.Lock(); self.system = ""
self.name = cfg.get('name', self.model)
proxy = cfg.get('proxy');
self.proxies = {"http": proxy, "https": proxy} if proxy else None
self.max_retries = max(0, int(cfg.get('max_retries', 4)))
self.verify = cfg.get('verify', True)
self.stream = cfg.get('stream', True)
default_ct, default_rt = (5, 40) if self.stream else (10, 240)
self.connect_timeout = max(1, int(cfg.get('timeout', default_ct)))
self.read_timeout = max(5, int(cfg.get('read_timeout', default_rt)))
def _enum(key, valid):
v = cfg.get(key); v = None if v is None else str(v).strip().lower()
return v if not v or v in valid else print(f"[WARN] Invalid {key} {v!r}, ignored.")
self.reasoning_effort = _enum('reasoning_effort', {'none', 'minimal', 'low', 'medium', 'high', 'xhigh'})
self.service_tier = _enum('service_tier', {'auto', 'default', 'priority', 'flex'})
self.thinking_type = _enum('thinking_type', {'adaptive', 'enabled', 'disabled'})
self.thinking_budget_tokens = cfg.get('thinking_budget_tokens')
mode = str(cfg.get('api_mode', 'chat_completions')).strip().lower().replace('-', '_')
self.api_mode = 'responses' if mode in ('responses', 'response') else 'chat_completions'
self.temperature = cfg.get('temperature', 1)
self.max_tokens = cfg.get('max_tokens')
self.default_ua = "claude-cli/2.1.152 (external, cli)"
self.user_agent = cfg.get("user_agent", self.default_ua)
def _apply_claude_thinking(self, payload):
if self.thinking_type:
thinking = {"type": self.thinking_type}
if self.thinking_type == 'enabled':
if self.thinking_budget_tokens is None: print("[WARN] thinking_type='enabled' requires thinking_budget_tokens, ignored.")
else:
thinking["budget_tokens"] = self.thinking_budget_tokens; payload["thinking"] = thinking
else: payload["thinking"] = thinking
if self.reasoning_effort:
effort = {'low': 'low', 'medium': 'medium', 'high': 'high', 'xhigh': 'max'}.get(self.reasoning_effort)
if effort: payload["output_config"] = {"effort": effort}
else: print(f"[WARN] reasoning_effort {self.reasoning_effort!r} is unsupported for Claude output_config.effort, ignored.")
def ask(self, prompt):
def _ask_gen():
with self.lock:
self.history.append({"role": "user", "content": [{"type": "text", "text": prompt}]})
trim_messages_history(self.history, self)
messages = self.make_messages(self.history)
content_blocks = None; content = ''
gen = self.raw_ask(messages)
try:
while True: chunk = next(gen); content += chunk; yield chunk
except StopIteration as e: content_blocks = e.value or []
if len(content_blocks) > 1: print(f"[DEBUG BaseSession.ask] content_blocks: {content_blocks}")
for block in (content_blocks or []):
if block.get('type', '') == 'tool_use':
tu = {'name': block.get('name', ''), 'arguments': block.get('input', {})}
yield f'<tool_use>{json.dumps(tu, ensure_ascii=False)}</tool_use>'
if content.strip() and not content.startswith("!!!Error:"): self.history.append({"role": "assistant", "content": [{"type": "text", "text": content}]})
return _ask_gen()
def _keep_claude_block(b): return not isinstance(b, dict) or b.get("type") != "thinking" or b.get("signature")
def _drop_unsigned_thinking(messages):
for m in messages:
c = m.get("content")
if isinstance(c, list): m["content"] = [b for b in c if _keep_claude_block(b)]
return messages
def _ensure_thinking_blocks(messages, model):
"""deepseek needs thinking in history!"""
if 'deepseek' not in model.lower(): return messages
for m in messages:
if m.get("role") != "assistant": continue
c = m.get("content")
if not isinstance(c, list): continue
has_thinking = any(isinstance(b, dict) and b.get("type") == "thinking" for b in c)
if not has_thinking: m["content"] = [{"type": "thinking", "thinking": "...", "signature": "placeholder"}, *c]
return messages
class ClaudeSession(BaseSession):
def raw_ask(self, messages):
messages = _fix_messages(messages)
if self.max_tokens is None: self.max_tokens = 8192
headers = {"x-api-key": self.api_key, "Content-Type": "application/json", "anthropic-version": "2023-06-01", "anthropic-beta": "prompt-caching-2024-07-31"}
payload = {"model": self.model, "messages": messages, "max_tokens": self.max_tokens, "stream": self.stream}
if self.temperature != 1: payload["temperature"] = self.temperature
self._apply_claude_thinking(payload)
if self.system: payload["system"] = [{"type": "text", "text": self.system, "cache_control": {"type": "persistent"}}]
url = auto_make_url(self.api_base, "messages")
parse_fn = (lambda r: _parse_claude_sse(r.iter_lines())) if self.stream else (lambda r: _parse_claude_json(r.json()))
return (yield from _stream_with_retry(self, url, headers, payload, parse_fn))
def make_messages(self, raw_list):
msgs = _drop_unsigned_thinking([{"role": m['role'], "content": list(m['content'])} for m in raw_list])
user_idxs = [i for i, m in enumerate(msgs) if m['role'] == 'user']
for idx in user_idxs[-2:]:
msgs[idx]["content"][-1] = dict(msgs[idx]["content"][-1], cache_control={"type": "ephemeral"})
return msgs
class LLMSession(BaseSession):
def raw_ask(self, messages): return (yield from _openai_stream(self, messages))
def make_messages(self, raw_list): return _msgs_claude2oai(_fix_messages(raw_list))
def _fix_messages(messages):
if not messages: return messages
W = lambda c: c if isinstance(c, list) else [{"type": "text", "text": str(c)}]
merged = []
for m in messages:
if m.get('role') not in ('user', 'assistant'): continue
blocks = W(m.get('content', []))
if merged and m['role'] == merged[-1]['role']:
merged[-1]['content'] += [{"type": "text", "text": "\n"}] + blocks
else:
merged.append({"role": m['role'], "content": list(blocks)})
while merged and merged[0]['role'] != 'user': merged.pop(0)
if not merged: return []
prev_uses = []
for m in merged:
c = m['content']
if m['role'] == 'assistant':
seen, out = set(), []
for b in c:
uid = b.get('id') if isinstance(b, dict) and b.get('type') == 'tool_use' else None
if uid and uid in seen: continue
if uid: seen.add(uid)
out.append(b)
m['content'] = out
prev_uses = [b.get('id') for b in out if isinstance(b, dict) and b.get('type') == 'tool_use']
else:
got, rest = {}, []
for b in c:
tid = b.get('tool_use_id') if isinstance(b, dict) and b.get('type') == 'tool_result' else None
if tid and tid in prev_uses and tid not in got: got[tid] = b
elif isinstance(b, dict) and b.get('type') == 'tool_result': rest.append({"type": "text", "text": str(b.get('content', ''))})
else: rest.append(b)
m['content'] = [got.get(u) or {"type": "tool_result", "tool_use_id": u, "content": "(error)"} for u in prev_uses] + rest
prev_uses = []
return merged
class NativeClaudeSession(BaseSession):
native_ua = "claude-cli/2.1.152 (native, cli)"
def __init__(self, cfg):
super().__init__(cfg)
self.fake_cc_system_prompt = cfg.get("fake_cc_system_prompt", False)
self._session_id = str(uuid.uuid4())
self._account_uuid = str(uuid.uuid4())
self._device_id = uuid.uuid4().hex + uuid.uuid4().hex[:32]
self.tools = None
if self.user_agent == self.default_ua: self.user_agent = self.native_ua
def raw_ask(self, messages):
if self.max_tokens is None: self.max_tokens = 8192
model = self.model
messages = _fix_messages(messages)
if 'claude' in model.lower(): messages = _drop_unsigned_thinking(messages)
messages = _ensure_thinking_blocks(messages, self.model)
beta_parts = ["claude-code-20250219", "interleaved-thinking-2025-05-14", "redact-thinking-2026-02-12", "context-management-2025-06-27", "prompt-caching-scope-2026-01-05", "effort-2025-11-24"]
if "[1m]" in model.lower():
beta_parts.insert(1, "context-1m-2025-08-07"); model = model.replace("[1m]", "").replace("[1M]", "")
headers = {"Content-Type": "application/json", "anthropic-version": "2023-06-01",
"anthropic-beta": ",".join(beta_parts), "anthropic-dangerous-direct-browser-access": "true",
"user-agent": self.user_agent, "x-app": "cli"}
headers.update({"Accept": "application/json", "X-Claude-Code-Session-Id": self._session_id, "X-Stainless-Arch": "x64", "X-Stainless-Lang": "js", "X-Stainless-OS": "Windows", "X-Stainless-Package-Version": "0.94.0", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v24.3.0", "X-Stainless-Timeout": "600"})
if self.api_key.startswith("sk-ant-"): headers["x-api-key"] = self.api_key
else: headers["authorization"] = f"Bearer {self.api_key}"
payload = {"model": model, "messages": messages, "max_tokens": self.max_tokens, "stream": self.stream}
#if self.fake_cc_system_prompt: payload["max_tokens"] = 64000
if self.temperature != 1: payload["temperature"] = self.temperature
self._apply_claude_thinking(payload)
payload["context_management"] = {"edits": [{"type": "clear_thinking_20251015", "keep": "all"}]};
if self.fake_cc_system_prompt:
if 'thinking' not in payload: payload["thinking"] = {"type": "adaptive"}
if 'output_config' not in payload: payload["output_config"] = {"effort": "medium"}
payload["metadata"] = {"user_id": json.dumps({"device_id": self._device_id, "account_uuid": "", "session_id": self._session_id}, separators=(',', ':'))}
if self.tools:
claude_tools = openai_tools_to_claude(self.tools)
tools = [dict(t) for t in claude_tools]; tools[-1]["cache_control"] = {"type": "ephemeral"}
payload["tools"] = tools
else: print("[ERROR] No tools provided for this session.")
payload['system'] = [{"type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude.", "cache_control": {"type": "ephemeral"}}]
if self.system:
if self.fake_cc_system_prompt: payload["system"].append({"type": "text", "text": self.system})
else: payload["system"] = [{"type": "text", "text": self.system}]
user_idxs = [i for i, m in enumerate(messages) if m['role'] == 'user']
for idx in user_idxs[-2:]:
messages[idx] = {**messages[idx], "content": list(messages[idx]["content"])}
messages[idx]["content"][-1] = dict(messages[idx]["content"][-1], cache_control={"type": "ephemeral"})
url = auto_make_url(self.api_base, "messages") + '?beta=true'
parse_fn = (lambda r: _parse_claude_sse(r.iter_lines())) if self.stream else (lambda r: _parse_claude_json(r.json()))
return (yield from _stream_with_retry(self, url, headers, payload, parse_fn))
def ask(self, msg):
assert type(msg) is dict
with self.lock:
self.history.append(msg)
trim_messages_history(self.history, self)
messages = [{"role": m["role"], "content": list(m["content"])} for m in self.history]
content_blocks = None
gen = self.raw_ask(messages)
try:
while True: yield next(gen)
except StopIteration as e: content_blocks = e.value or []
if content_blocks and (_injected := _ensure_text_block(content_blocks)): yield _injected
if content_blocks and not (len(content_blocks) == 1 and content_blocks[0].get("text", "").startswith("!!!Error:")):
self.history.append({"role": "assistant", "content": content_blocks})
text_parts = [b["text"] for b in content_blocks if b.get("type") == "text"]
content = "\n".join(text_parts).strip()
tool_calls = [MockToolCall(b["name"], b.get("input", {}), id=b.get("id", "")) for b in content_blocks if b.get("type") == "tool_use"]
if not tool_calls: tool_calls, content = _parse_text_tool_calls(content)
thinking_parts = [b["thinking"] for b in content_blocks if b.get("type") == "thinking"]
thinking = "\n".join(thinking_parts).strip()
if not thinking:
think_pattern = r"<think(?:ing)?>(.*?)</think(?:ing)?>"
think_match = re.search(think_pattern, content, re.DOTALL)
if think_match:
thinking = think_match.group(1).strip()
content = re.sub(think_pattern, "", content, flags=re.DOTALL)
return MockResponse(thinking, content, tool_calls, str(content_blocks))
class NativeOAISession(NativeClaudeSession):
native_ua = "codex_exec/0.139.0 (Windows 10.0.26200; x86_64) unknown (codex_exec; 0.139.0)"
def raw_ask(self, messages):
messages = _fix_messages(messages)
messages = _ensure_thinking_blocks(messages, self.model)
return (yield from _openai_stream(self, _msgs_claude2oai(messages)))
def openai_tools_to_claude(tools):
"""[{type:'function', function:{name,description,parameters}}] → [{name,description,input_schema}]."""
result = []
for t in tools:
if 'input_schema' in t: result.append(t); continue # 已是claude格式
fn = t.get('function', t)
result.append({'name': fn['name'], 'description': fn.get('description', ''),
'input_schema': fn.get('parameters', {'type': 'object', 'properties': {}})})
return result
class MockFunction:
def __init__(self, name, arguments): self.name, self.arguments = name, arguments
class MockToolCall:
def __init__(self, name, args, id=''):
arg_str = json.dumps(args, ensure_ascii=False) if isinstance(args, (dict, list)) else (args or '{}')
self.function = MockFunction(name, arg_str); self.id = id
class MockResponse:
def __init__(self, thinking, content, tool_calls, raw, stop_reason='end_turn'):
self.thinking = thinking; self.content = content
self.tool_calls = tool_calls; self.raw = raw
self.stop_reason = 'tool_use' if tool_calls else stop_reason
def __repr__(self):
return f"<MockResponse thinking={bool(self.thinking)}, content='{self.content}', tools={bool(self.tool_calls)}>"
class ToolClient:
def __init__(self, backend, auto_save_tokens=True):
self.backend = backend
self.auto_save_tokens = auto_save_tokens
self.last_tools = ''
self.name = self.backend.name
self.total_cd_tokens = 0
self.log_path = None
def chat(self, messages, tools=None):
tools = json.loads(json.dumps(tools, ensure_ascii=False)) if tools else tools
for t in tools or []:
f = t.get('function', {})
if f.get('name') == 'file_write':
props = f.get('parameters', {}).get('properties', {})
props.pop('content', None)
extra = '. Content must be placed in <file_content> tags in reply body, not in args'
if extra not in f.get('description', ''): f['description'] = f.get('description', '') + extra
break
full_prompt = self._build_protocol_prompt(messages, tools)
print("Full prompt length:", len(full_prompt), 'chars')
gen = self.backend.ask(full_prompt)
_write_llm_log('Prompt', full_prompt, self.log_path)
raw_text = ''
for chunk in gen:
raw_text += chunk; yield chunk
_write_llm_log('Response', raw_text, self.log_path, model=self.backend.model)
return self._parse_mixed_response(raw_text)
def _prepare_tool_instruction(self, tools):
tool_instruction = ""
if not tools: return tool_instruction
tools_json = json.dumps(tools, ensure_ascii=False, separators=(',', ':'))
_en = os.environ.get('GA_LANG') == 'en'
if _en:
tool_instruction = f"""
### Interaction Protocol (must follow strictly, always in effect)
Follow these steps to think and act:
1. **Think**: Analyze the current situation and strategy inside `<thinking>` tags.
2. **Summarize**: Output a minimal one-line (<30 words) physical snapshot in `<summary>`: new info from last tool result + current tool call intent. This goes into long-term working memory. Must contain real information, no filler.
3. **Act**: If you need to call tools, output one or more **<tool_use> blocks** after your reply, then stop.
"""
else:
tool_instruction = f"""
### 交互协议 (必须严格遵守,持续有效)
请按照以下步骤思考并行动:
1. **思考**: 在 `<thinking>` 标签中先进行思考,分析现状和策略。
2. **总结**: 在 `<summary>` 中输出*极为简短*的高度概括的单行(<30字)物理快照,包括上次工具调用结果产生的新信息+本次工具调用意图。此内容将进入长期工作记忆,记录关键信息,严禁输出无实际信息增量的描述。
3. **行动**: 如需调用工具,请在回复正文之后输出一个(或多个)**<tool_use>块**,然后结束。
"""
tool_instruction += f'\nFormat: ```<tool_use>{{"name": "tool_name", "arguments": {{...}}}}</tool_use>```\n\n### Tools (mounted, always in effect):\n{tools_json}\n'
if self.auto_save_tokens and self.last_tools == tools_json:
tool_instruction = "\n### Tools: still active, **ready to call**. Protocol unchanged.\n" if _en else "\n### 工具库状态:持续有效(code_run/file_read等),**可正常调用**。调用协议沿用。\n"
else: self.total_cd_tokens = 0
self.last_tools = tools_json
return tool_instruction
def _build_protocol_prompt(self, messages, tools):
system_content = next((m['content'] for m in messages if m['role'].lower() == 'system'), "")
history_msgs = [m for m in messages if m['role'].lower() != 'system']
tool_instruction = self._prepare_tool_instruction(tools)
system = ""; user = ""
if system_content: system += f"{system_content}\n"
system += f"{tool_instruction}"
for m in history_msgs:
role = "USER" if m['role'] == 'user' else "ASSISTANT"
user += f"=== {role} ===\n"
for tr in m.get('tool_results', []): user += f'<tool_result>{tr["content"]}</tool_result>\n'
user += str(m['content']) + "\n"
self.total_cd_tokens += len(user) // 3
if self.total_cd_tokens > 9000: self.last_tools = ''
user += "=== ASSISTANT ===\n"
return system + user
def _parse_mixed_response(self, text):
remaining_text = text; thinking = ''
think_match = re.search(r"<think(?:ing)?>(.*?)</think(?:ing)?>", text, re.DOTALL)
if think_match:
thinking = think_match.group(1).strip()
remaining_text = re.sub(r"<think(?:ing)?>(.*?)</think(?:ing)?>", "", remaining_text, flags=re.DOTALL)
tool_calls, remaining_text = _parse_text_tool_calls(remaining_text)
if not tool_calls:
json_strs = []; errors = []
if '<tool_use>' in remaining_text:
weaktoolstr = remaining_text.split('<tool_use>')[-1].strip().strip('><')
json_str = weaktoolstr if weaktoolstr.endswith('}') else ''
if json_str == '' and '```' in weaktoolstr and weaktoolstr.split('```')[0].strip().endswith('}'):
json_str = weaktoolstr.split('```')[0].strip()
if json_str: json_strs.append(json_str)
remaining_text = remaining_text.replace('<tool_use>'+weaktoolstr, "")
elif '"name":' in remaining_text and '"arguments":' in remaining_text:
json_match = re.search(r'\{.*"name":.*\}', remaining_text, re.DOTALL)
if json_match:
json_strs.append(json_match.group(0).strip())
remaining_text = remaining_text.replace(json_match.group(0), "").strip()
for json_str in json_strs:
try:
data = tryparse(json_str)
func_name = data.get('name') or data.get('function') or data.get('tool')
args = data.get('arguments') or data.get('args') or data.get('params') or data.get('parameters')
if args is None: args = data
if func_name: tool_calls.append(MockToolCall(func_name, args))
except json.JSONDecodeError:
errors.append(f'Failed to parse tool_use JSON: {json_str[:200]}')
self.last_tools = ''
except: pass
if not tool_calls:
for e in errors:
print(f"[Warn] {e}"); tool_calls.append(MockToolCall('bad_json', {'msg': e}))
return MockResponse(thinking, remaining_text.strip(), tool_calls, text)
def _parse_text_tool_calls(content):
"""Fallback: extract tool calls from text when model doesn't use native tool_use blocks."""
tcs = []
# try JSON array: [{"type":"tool_use", "name":..., "input":...}]
_jp = next((p for p in ['[{"type":"tool_use"', '[{"type": "tool_use"'] if p in content), None)
if _jp and content.endswith('}]'):
try:
idx = content.index(_jp); raw = json.loads(content[idx:])
tcs = [MockToolCall(b["name"], b.get("input", {}), id=b.get("id", "")) for b in raw if b.get("type") == "tool_use"]
return tcs, content[:idx].strip()
except: pass
# try XML tags: <tool_call>{"name":..., "arguments":...}</tool_call>
_xp = r"<(?:tool_use|tool_call)>((?:(?!<(?:tool_use|tool_call)>).){15,}?)</(?:tool_use|tool_call)>"
for s in re.findall(_xp, content, re.DOTALL):
try:
d = tryparse(s.strip()); name = d.get('name')
args = d.get('arguments') or d.get('args') or d.get('input') or {}
if name: tcs.append(MockToolCall(name, args))
except: pass
if tcs: content = re.sub(_xp, "", content, flags=re.DOTALL).strip()
return tcs, content
def _ensure_text_block(blocks):
"""If response has thinking but no text block, inject a synthetic summary from thinking's first line."""
if any(b.get("type") == "text" for b in blocks): return None
th = next((b.get("thinking", "") for b in blocks if b.get("type") == "thinking"), "")
if not th: return None
line = th.strip().split('\n', 1)[0]
txt = "<summary>" + (line[:60] + '...' if len(line) > 60 else line) + "</summary>"
blocks.insert(1, {"type": "text", "text": txt})
return txt
def _write_llm_log(label, content, log_path=None, model=''):
if not log_path:
log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), f'temp/model_responses/model_responses_{os.getpid()}.txt')
os.makedirs(os.path.dirname(os.path.abspath(log_path)), exist_ok=True)
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if model: model = f' model={model}'
with open(log_path, 'a', encoding='utf-8', errors='replace') as f:
f.write(f"=== {label} === {ts}{model}\n{content}\n\n")
def tryparse(json_str):
try: return json.loads(json_str)
except: pass
json_str = json_str.strip().strip('`').replace('json\n', '', 1).strip()
try: return json.loads(json_str)
except: pass
try: return json.loads(json_str[:-1])
except: pass
if '}' in json_str: json_str = json_str[:json_str.rfind('}') + 1]
return json.loads(json_str)
class MixinSession:
"""Multi-session fallback with spring-back to primary."""
def __init__(self, all_sessions, cfg):
self._retries, self._base_delay = cfg.get('max_retries', 3), cfg.get('base_delay', 1.5)
self._spring_sec = cfg.get('spring_back', 300)
self._sessions = [all_sessions[i].backend if isinstance(i, int) else
next(s.backend for s in all_sessions if type(s) is not dict and s.backend.name == i) for i in cfg.get('llm_nos', [])]
is_native = lambda s: 'Native' in s.__class__.__name__
groups = {is_native(s) for s in self._sessions}
assert len(groups) == 1, f"MixinSession: sessions must be in same group (Native or non-Native), got {[type(s).__name__ for s in self._sessions]}"
self.name = '|'.join(s.name for s in self._sessions)
import copy; self._sessions = [copy.copy(s) for s in self._sessions]
for s in self._sessions: s.max_retries = 0
self._orig_raw_asks = [s.raw_ask for s in self._sessions]
self._sessions[0].raw_ask = self._raw_ask
self._cur_idx, self._switched_at = 0, 0.0
def __getattr__(self, name): return getattr(self._sessions[0], name)
_BROADCAST_ATTRS = frozenset({'system', 'tools', 'temperature', 'max_tokens', 'reasoning_effort', 'history', 'stream', 'read_timeout'})
def __setattr__(self, name, value):
if name in self._BROADCAST_ATTRS:
for s in self._sessions:
v = openai_tools_to_claude(value) if name == 'tools' and type(s) is NativeClaudeSession else value
setattr(s, name, v)
else: object.__setattr__(self, name, value)
@property
def primary(self): return self._sessions[0]
@property
def model(self): return getattr(self._sessions[self._cur_idx], 'model', None)
@property
def current_name(self): return getattr(self._sessions[self._cur_idx], 'name', None)
def _pick(self):
if self._cur_idx and time.time() - self._switched_at > self._spring_sec: self._cur_idx = 0
return self._cur_idx
def _raw_ask(self, *args, **kwargs):
base, n = self._pick(), len(self._sessions)
test_error = lambda x: isinstance(x, str) and x.lstrip().startswith(('!!!Error:', '[Error:'))
for attempt in range(self._retries + 1):
idx = (base + attempt) % n
gen = self._orig_raw_asks[idx](*args, **kwargs)
print(f'[MixinSession] Using session ({self._sessions[idx].name})')
last_chunk, return_val, yielded = None, [], False
try:
while True:
chunk = next(gen); last_chunk = chunk
if not yielded and test_error(chunk): continue
yield chunk; yielded = True
except StopIteration as e: return_val = e.value or []
is_err = test_error(last_chunk)
if not is_err:
if attempt > 0: self._cur_idx = idx; self._switched_at = time.time()
elif isinstance(last_chunk, str) and '[!!! 流异常中断' in last_chunk and n > 1:
self._cur_idx = (idx + 1) % n; self._switched_at = time.time()
print(f'[MixinSession] Partial failure, next call → s{self._cur_idx} ({self._sessions[self._cur_idx].name})')
return return_val
if attempt >= self._retries:
yield last_chunk; return return_val
nxt = (base + attempt + 1) % n
if nxt == base: # full round failed, delay before next
rnd = (attempt + 1) // n
delay = min(30, self._base_delay * (1.5 ** rnd))
print(f'[MixinSession] {last_chunk[:80]}, round {rnd} exhausted, retry in {delay:.1f}s')
time.sleep(delay)
else: print(f'[MixinSession] {last_chunk[:80]}, retry {attempt+1}/{self._retries} (s{idx}→s{nxt})')
THINKING_PROMPT_ZH = """
### 行动规范(持续有效)
每次回复(含工具调用轮)都先在回复文字中包含一个<summary></summary> 中输出极简单行(<30字)物理快照:上次结果新信息+本次意图。此内容进入长期工作记忆。
\n**若用户需求未完成,必须进行工具调用!**
""".strip()
THINKING_PROMPT_EN = """
### Action Protocol (always in effect)
The reply body should first include a minimal one-line (<30 words) physical snapshot in <summary></summary>: new info from last result + current intent. This goes into long-term working memory.
\n**If the user's request is not yet complete, tool calls are required!**
""".strip()
class NativeToolClient:
@staticmethod
def _thinking_prompt(): return THINKING_PROMPT_EN if os.environ.get('GA_LANG') == 'en' else THINKING_PROMPT_ZH
def __init__(self, backend):
self.backend = backend
self.backend.system = self._thinking_prompt()
self.name = self.backend.name
self._pending_tool_ids = []
self.log_path = None
def set_system(self, extra_system):
combined = f"{extra_system}\n\n{self._thinking_prompt()}" if extra_system else self._thinking_prompt()
if combined != self.backend.system: print(f"[Debug] Updated system prompt, length {len(combined)} chars.")
self.backend.system = combined
def chat(self, messages, tools=None):
if tools: self.backend.tools = tools
if not self.backend.history: self._pending_tool_ids = []
combined_content = []; resp = None; tool_results = []
for msg in messages:
c = msg.get('content', '')
if msg['role'] == 'system':
self.set_system(c); continue
if isinstance(c, str): combined_content.append({"type": "text", "text": c})
elif isinstance(c, list): combined_content.extend(c)
if msg['role'] == 'user' and msg.get('tool_results'): tool_results.extend(msg['tool_results'])
tr_id_set = set(); tool_result_blocks = []
for tr in tool_results:
tool_use_id, content = tr.get("tool_use_id", ""), tr.get("content", "")
tr_id_set.add(tool_use_id)
if tool_use_id: tool_result_blocks.append({"type": "tool_result", "tool_use_id": tool_use_id, "content": tr.get("content", "")})
else: combined_content = [{"type": "text", "text": f'<tool_result>{content}</tool_result>'}] + combined_content
for tid in self._pending_tool_ids:
if tid not in tr_id_set: tool_result_blocks.append({"type": "tool_result", "tool_use_id": tid, "content": ""})
self._pending_tool_ids = []
# Filter whitespace-only text blocks that cause 400 on strict API proxies
filtered_content = [c for c in combined_content if c.get("text", "").strip()]
final_content = tool_result_blocks + filtered_content
if not final_content: final_content = [{"type": "text", "text": "."}]
merged = {"role": "user", "content": final_content}
_write_llm_log('Prompt', json.dumps(merged, ensure_ascii=False, indent=2), self.log_path)
gen = self.backend.ask(merged)
try:
while True:
chunk = next(gen); yield chunk
except StopIteration as e: resp = e.value
if resp: _write_llm_log('Response', resp.raw, self.log_path, model=self.backend.model)
if resp and hasattr(resp, 'tool_calls') and resp.tool_calls: self._pending_tool_ids = [tc.id for tc in resp.tool_calls]
return resp
def resolve_session(cfg_name):
cfg = reload_mykeys()[0].get(cfg_name)
if not cfg: raise ValueError(f"Config '{cfg_name}' not in mykey")
if 'native' in cfg_name: return (NativeClaudeSession if 'claude' in cfg_name else NativeOAISession)(cfg=cfg)
if 'claude' in cfg_name: return ClaudeSession(cfg=cfg)
return LLMSession(cfg=cfg) if 'oai' in cfg_name else None
def resolve_client(cfg_name):
s = resolve_session(cfg_name)
return (NativeToolClient(s) if isinstance(s, (NativeClaudeSession, NativeOAISession)) else ToolClient(s)) if s else None
def fast_ask(prompt, cfg_name):
sess = resolve_session(cfg_name)
if not sess: raise ValueError(f"fast_ask: '{cfg_name}' unsupported")
return "".join(sess.raw_ask([{"role": "user", "content": prompt}]))