From 1d3d3c59ad0b43d5c77b06303141f3253a91c4a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=B3=96=E5=8F=AF=E4=B9=90?= <16593530+wuxiao297@user.noreply.gitee.com> Date: Fri, 6 Mar 2026 17:55:16 +0800 Subject: [PATCH] feat: token stats time range filter & subagent toggle --- ... { echo \357\200\242NO LOCK\357\200\242 }" | 163 ++++++ ...276&1 \357\201\274 Select-Object -Last 30" | 324 +++++++++++ pnpm-lock.yaml | 12 + src/apps/desktop/src/api/agentic_api.rs | 2 + src/apps/desktop/src/api/app_state.rs | 7 +- src/apps/desktop/src/api/mod.rs | 1 + src/apps/desktop/src/api/token_usage_api.rs | 200 +++++++ src/apps/desktop/src/lib.rs | 29 +- .../src/agentic/coordination/coordinator.rs | 5 + src/crates/core/src/agentic/core/session.rs | 4 + .../src/agentic/execution/execution_engine.rs | 6 +- .../src/agentic/execution/round_executor.rs | 33 +- src/crates/core/src/service/mod.rs | 5 + .../core/src/service/token_usage/mod.rs | 14 + .../core/src/service/token_usage/service.rs | 543 ++++++++++++++++++ .../src/service/token_usage/subscriber.rs | 65 +++ .../core/src/service/token_usage/types.rs | 106 ++++ src/crates/events/src/agentic.rs | 2 + src/crates/transport/src/adapters/tauri.rs | 4 +- src/mobile-web/package-lock.json | 6 + src/web-ui/src/infrastructure/api/index.ts | 4 +- .../src/infrastructure/api/tokenUsageApi.ts | 150 +++++ .../config/components/AIModelConfig.tsx | 28 +- .../config/components/TokenStatsModal.scss | 171 ++++++ .../config/components/TokenStatsModal.tsx | 188 ++++++ .../src/locales/en-US/settings/ai-model.json | 22 +- .../src/locales/zh-CN/settings/ai-model.json | 22 +- 27 files changed, 2084 insertions(+), 32 deletions(-) create mode 100644 "erswuxiaoBitFun.gitindex.lock 2\357\200\276$null; if ($\357\200\277) { echo \357\200\242LOCK EXISTS\357\200\242 } else { echo \357\200\242NO LOCK\357\200\242 }" create mode 100644 "hort 2\357\200\276&1 \357\201\274 Select-Object -Last 30" create mode 100644 src/apps/desktop/src/api/token_usage_api.rs create mode 100644 src/crates/core/src/service/token_usage/mod.rs create mode 100644 src/crates/core/src/service/token_usage/service.rs create mode 100644 src/crates/core/src/service/token_usage/subscriber.rs create mode 100644 src/crates/core/src/service/token_usage/types.rs create mode 100644 src/web-ui/src/infrastructure/api/tokenUsageApi.ts create mode 100644 src/web-ui/src/infrastructure/config/components/TokenStatsModal.scss create mode 100644 src/web-ui/src/infrastructure/config/components/TokenStatsModal.tsx diff --git "a/erswuxiaoBitFun.gitindex.lock 2\357\200\276$null; if ($\357\200\277) { echo \357\200\242LOCK EXISTS\357\200\242 } else { echo \357\200\242NO LOCK\357\200\242 }" "b/erswuxiaoBitFun.gitindex.lock 2\357\200\276$null; if ($\357\200\277) { echo \357\200\242LOCK EXISTS\357\200\242 } else { echo \357\200\242NO LOCK\357\200\242 }" new file mode 100644 index 00000000..309a59f6 --- /dev/null +++ "b/erswuxiaoBitFun.gitindex.lock 2\357\200\276$null; if ($\357\200\277) { echo \357\200\242LOCK EXISTS\357\200\242 } else { echo \357\200\242NO LOCK\357\200\242 }" @@ -0,0 +1,163 @@ +warning: in the working copy of 'pnpm-lock.yaml', LF will be replaced by CRLF the next time Git touches it +.github/workflows/ci.yml +Cargo.toml +package-lock.json +package.json +pnpm-lock.yaml +scripts/dev.cjs +src/apps/cli/src/agent/core_adapter.rs +src/apps/desktop/src/api/agentic_api.rs +src/apps/desktop/src/api/app_state.rs +src/apps/desktop/src/api/conversation_api.rs +src/apps/desktop/src/api/image_analysis_api.rs +src/apps/desktop/src/api/mod.rs +src/apps/desktop/src/api/remote_connect_api.rs +src/apps/desktop/src/lib.rs +src/apps/desktop/tauri.conf.json +src/apps/relay-server/Caddyfile +src/apps/relay-server/Cargo.toml +src/apps/relay-server/Dockerfile +src/apps/relay-server/README.md +src/apps/relay-server/deploy.sh +src/apps/relay-server/deploy/Cargo.toml +src/apps/relay-server/deploy/Dockerfile +src/apps/relay-server/deploy/docker-compose.yml +src/apps/relay-server/deploy/src/config.rs +src/apps/relay-server/deploy/src/main.rs +src/apps/relay-server/deploy/src/relay/mod.rs +src/apps/relay-server/deploy/src/relay/room.rs +src/apps/relay-server/deploy/src/routes/api.rs +src/apps/relay-server/deploy/src/routes/mod.rs +src/apps/relay-server/deploy/src/routes/websocket.rs +src/apps/relay-server/docker-compose.yml +src/apps/relay-server/src/config.rs +src/apps/relay-server/src/main.rs +src/apps/relay-server/src/relay/mod.rs +src/apps/relay-server/src/relay/room.rs +src/apps/relay-server/src/routes/api.rs +src/apps/relay-server/src/routes/mod.rs +src/apps/relay-server/src/routes/websocket.rs +src/apps/relay-server/static/assets/index-C-fgJuft.css +src/apps/relay-server/static/assets/index-RWXIAc4-.js +src/apps/relay-server/static/index.html +src/apps/relay-server/test_incremental_upload.py +src/apps/server/README.md +src/crates/core/Cargo.toml +src/crates/core/src/agentic/coordination/coordinator.rs +src/crates/core/src/agentic/core/session.rs +src/crates/core/src/agentic/execution/execution_engine.rs +src/crates/core/src/agentic/execution/round_executor.rs +src/crates/core/src/agentic/execution/types.rs +src/crates/core/src/agentic/session/history_manager.rs +src/crates/core/src/agentic/session/session_manager.rs +src/crates/core/src/infrastructure/filesystem/file_watcher.rs +src/crates/core/src/service/conversation/persistence_manager.rs +src/crates/core/src/service/conversation/types.rs +src/crates/core/src/service/mod.rs +src/crates/core/src/service/remote_connect/bot/command_router.rs +src/crates/core/src/service/remote_connect/bot/feishu.rs +src/crates/core/src/service/remote_connect/bot/mod.rs +src/crates/core/src/service/remote_connect/bot/telegram.rs +src/crates/core/src/service/remote_connect/device.rs +src/crates/core/src/service/remote_connect/embedded_relay.rs +src/crates/core/src/service/remote_connect/encryption.rs +src/crates/core/src/service/remote_connect/lan.rs +src/crates/core/src/service/remote_connect/mod.rs +src/crates/core/src/service/remote_connect/ngrok.rs +src/crates/core/src/service/remote_connect/pairing.rs +src/crates/core/src/service/remote_connect/qr_generator.rs +src/crates/core/src/service/remote_connect/relay_client.rs +src/crates/core/src/service/remote_connect/remote_server.rs +src/crates/core/src/service/workspace/mod.rs +src/crates/core/src/service/workspace/service.rs +src/crates/events/src/agentic.rs +src/crates/transport/src/adapters/tauri.rs +src/crates/transport/src/adapters/websocket.rs +src/mobile-web/index.html +src/mobile-web/package-lock.json +src/mobile-web/package.json +src/mobile-web/src/App.tsx +src/mobile-web/src/main.tsx +src/mobile-web/src/pages/ChatPage.tsx +src/mobile-web/src/pages/PairingPage.tsx +src/mobile-web/src/pages/SessionListPage.tsx +src/mobile-web/src/pages/WorkspacePage.tsx +src/mobile-web/src/services/E2EEncryption.ts +src/mobile-web/src/services/RelayConnection.ts +src/mobile-web/src/services/RemoteSessionManager.ts +src/mobile-web/src/services/store.ts +src/mobile-web/src/styles/mobile.scss +src/mobile-web/tsconfig.json +src/mobile-web/vite.config.ts +src/web-ui/src/app/components/NavPanel/NavPanel.scss +src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss +src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +src/web-ui/src/app/layout/AppLayout.tsx +src/web-ui/src/app/layout/WorkspaceBody.tsx +src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx +src/web-ui/src/component-library/components/InputDialog/InputDialog.scss +src/web-ui/src/component-library/components/InputDialog/InputDialog.tsx +src/web-ui/src/component-library/components/Modal/Modal.tsx +src/web-ui/src/flow_chat/components/ChatInput.scss +src/web-ui/src/flow_chat/reducers/inputReducer.ts +src/web-ui/src/flow_chat/services/AgenticEventListener.ts +src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +src/web-ui/src/flow_chat/store/FlowChatStore.ts +src/web-ui/src/flow_chat/types/flow-chat.ts +src/web-ui/src/infrastructure/api/index.ts +src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts +src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx +src/web-ui/src/locales/en-US/common.json +src/web-ui/src/locales/en-US/settings/ai-model.json +src/web-ui/src/locales/zh-CN/common.json +src/web-ui/src/locales/zh-CN/settings/ai-model.json +src/web-ui/src/shared/crypto/e2e-encryption.ts +src/web-ui/src/shared/crypto/index.ts +src/web-ui/src/tools/file-system/services/FileSystemService.ts +src/web-ui/src/tools/terminal/components/ConnectedTerminal.tsx +tests/e2e/.gitignore +tests/e2e/E2E-TESTING-GUIDE.md +tests/e2e/E2E-TESTING-GUIDE.zh-CN.md +tests/e2e/README.md +tests/e2e/README.zh-CN.md +tests/e2e/config/capabilities.ts +tests/e2e/config/wdio.conf_l0.ts +tests/e2e/config/wdio.conf_l1.ts +tests/e2e/helpers/screenshot-utils.ts +tests/e2e/helpers/tauri-utils.ts +tests/e2e/helpers/wait-utils.ts +tests/e2e/helpers/workspace-helper.ts +tests/e2e/helpers/workspace-utils.ts +tests/e2e/package-lock.json +tests/e2e/package.json +tests/e2e/page-objects/ChatPage.ts +tests/e2e/page-objects/StartupPage.ts +tests/e2e/page-objects/components/ChatInput.ts +tests/e2e/page-objects/components/Header.ts +tests/e2e/page-objects/components/MessageList.ts +tests/e2e/page-objects/index.ts +tests/e2e/specs/l0-i18n.spec.ts +tests/e2e/specs/l0-navigation.spec.ts +tests/e2e/specs/l0-notification.spec.ts +tests/e2e/specs/l0-observe.spec.ts +tests/e2e/specs/l0-open-settings.spec.ts +tests/e2e/specs/l0-open-workspace.spec.ts +tests/e2e/specs/l0-smoke.spec.ts +tests/e2e/specs/l0-tabs.spec.ts +tests/e2e/specs/l0-theme.spec.ts +tests/e2e/specs/l1-chat-input.spec.ts +tests/e2e/specs/l1-chat.spec.ts +tests/e2e/specs/l1-dialog.spec.ts +tests/e2e/specs/l1-editor.spec.ts +tests/e2e/specs/l1-file-tree.spec.ts +tests/e2e/specs/l1-git-panel.spec.ts +tests/e2e/specs/l1-navigation.spec.ts +tests/e2e/specs/l1-session.spec.ts +tests/e2e/specs/l1-settings.spec.ts +tests/e2e/specs/l1-terminal.spec.ts +tests/e2e/specs/l1-ui-navigation.spec.ts +tests/e2e/specs/l1-workspace.spec.ts diff --git "a/hort 2\357\200\276&1 \357\201\274 Select-Object -Last 30" "b/hort 2\357\200\276&1 \357\201\274 Select-Object -Last 30" new file mode 100644 index 00000000..74570f66 --- /dev/null +++ "b/hort 2\357\200\276&1 \357\201\274 Select-Object -Last 30" @@ -0,0 +1,324 @@ + + SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS + + Commands marked with * may be preceded by a number, _N. + Notes in parentheses indicate the behavior if _N is given. + A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K. + + h H Display this help. + q :q Q :Q ZZ Exit. + --------------------------------------------------------------------------- + + MMOOVVIINNGG + + e ^E j ^N CR * Forward one line (or _N lines). + y ^Y k ^K ^P * Backward one line (or _N lines). + ESC-j * Forward one file line (or _N file lines). + ESC-k * Backward one file line (or _N file lines). + f ^F ^V SPACE * Forward one window (or _N lines). + b ^B ESC-v * Backward one window (or _N lines). + z * Forward one window (and set window to _N). + w * Backward one window (and set window to _N). + ESC-SPACE * Forward one window, but don't stop at end-of-file. + ESC-b * Backward one window, but don't stop at beginning-of-file. + d ^D * Forward one half-window (and set half-window to _N). + u ^U * Backward one half-window (and set half-window to _N). + ESC-) RightArrow * Right one half screen width (or _N positions). + ESC-( LeftArrow * Left one half screen width (or _N positions). + ESC-} ^RightArrow Right to last column displayed. + ESC-{ ^LeftArrow Left to first column. + F Forward forever; like "tail -f". + ESC-F Like F but stop when search pattern is found. + r ^R ^L Repaint screen. + R Repaint screen, discarding buffered input. + --------------------------------------------------- + Default "window" is the screen height. + Default "half-window" is half of the screen height. + --------------------------------------------------------------------------- + + SSEEAARRCCHHIINNGG + + /_p_a_t_t_e_r_n * Search forward for (_N-th) matching line. + ?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line. + n * Repeat previous search (for _N-th occurrence). + N * Repeat previous search in reverse direction. + ESC-n * Repeat previous search, spanning files. + ESC-N * Repeat previous search, reverse dir. & spanning files. + ^O^N ^On * Search forward for (_N-th) OSC8 hyperlink. + ^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink. + ^O^L ^Ol Jump to the currently selected OSC8 hyperlink. + ESC-u Undo (toggle) search highlighting. + ESC-U Clear search highlighting. + &_p_a_t_t_e_r_n * Display only matching lines. + --------------------------------------------------- + Search is case-sensitive unless changed with -i or -I. + A search pattern may begin with one or more of: + ^N or ! Search for NON-matching lines. + ^E or * Search multiple files (pass thru END OF FILE). + ^F or @ Start search at FIRST file (for /) or last file (for ?). + ^K Highlight matches, but don't move (KEEP position). + ^R Don't use REGULAR EXPRESSIONS. + ^S _n Search for match in _n-th parenthesized subpattern. + ^W WRAP search if no match found. + ^L Enter next character literally into pattern. + --------------------------------------------------------------------------- + + JJUUMMPPIINNGG + + g < ESC-< * Go to first line in file (or line _N). + G > ESC-> * Go to last line in file (or line _N). + p % * Go to beginning of file (or _N percent into file). + t * Go to the (_N-th) next tag. + T * Go to the (_N-th) previous tag. + { ( [ * Find close bracket } ) ]. + } ) ] * Find open bracket { ( [. + ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>. + ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>. + --------------------------------------------------- + Each "find close bracket" command goes forward to the close bracket + matching the (_N-th) open bracket in the top line. + Each "find open bracket" command goes backward to the open bracket + matching the (_N-th) close bracket in the bottom line. + + m_<_l_e_t_t_e_r_> Mark the current top line with . + M_<_l_e_t_t_e_r_> Mark the current bottom line with . + '_<_l_e_t_t_e_r_> Go to a previously marked position. + '' Go to the previous position. + ^X^X Same as '. + ESC-m_<_l_e_t_t_e_r_> Clear a mark. + --------------------------------------------------- + A mark is any upper-case or lower-case letter. + Certain marks are predefined: + ^ means beginning of the file + $ means end of the file + --------------------------------------------------------------------------- + + CCHHAANNGGIINNGG FFIILLEESS + + :e [_f_i_l_e] Examine a new file. + ^X^V Same as :e. + :n * Examine the (_N-th) next file from the command line. + :p * Examine the (_N-th) previous file from the command line. + :x * Examine the first (or _N-th) file from the command line. + ^O^O Open the currently selected OSC8 hyperlink. + :d Delete the current file from the command line list. + = ^G :f Print current file name. + --------------------------------------------------------------------------- + + MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS + + -_<_f_l_a_g_> Toggle a command line option [see OPTIONS below]. + --_<_n_a_m_e_> Toggle a command line option, by name. + __<_f_l_a_g_> Display the setting of a command line option. + ___<_n_a_m_e_> Display the setting of an option, by name. + +_c_m_d Execute the less cmd each time a new file is examined. + + !_c_o_m_m_a_n_d Execute the shell command with $SHELL. + #_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt. + |XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command. + s _f_i_l_e Save input to a file. + v Edit the current file with $VISUAL or $EDITOR. + V Print version number of "less". + --------------------------------------------------------------------------- + + OOPPTTIIOONNSS + + Most options may be changed either on the command line, + or from within less by using the - or -- command. + Options may be given in one of two forms: either a single + character preceded by a -, or a name preceded by --. + + -? ........ --help + Display help (from command line). + -a ........ --search-skip-screen + Search skips current screen. + -A ........ --SEARCH-SKIP-SCREEN + Search starts just after target line. + -b [_N] .... --buffers=[_N] + Number of buffers. + -B ........ --auto-buffers + Don't automatically allocate buffers for pipes. + -c ........ --clear-screen + Repaint by clearing rather than scrolling. + -d ........ --dumb + Dumb terminal. + -D xx_c_o_l_o_r . --color=xx_c_o_l_o_r + Set screen colors. + -e -E .... --quit-at-eof --QUIT-AT-EOF + Quit at end of file. + -f ........ --force + Force open non-regular files. + -F ........ --quit-if-one-screen + Quit if entire file fits on first screen. + -g ........ --hilite-search + Highlight only last match for searches. + -G ........ --HILITE-SEARCH + Don't highlight any matches for searches. + -h [_N] .... --max-back-scroll=[_N] + Backward scroll limit. + -i ........ --ignore-case + Ignore case in searches that do not contain uppercase. + -I ........ --IGNORE-CASE + Ignore case in all searches. + -j [_N] .... --jump-target=[_N] + Screen position of target lines. + -J ........ --status-column + Display a status column at left edge of screen. + -k _f_i_l_e ... --lesskey-file=_f_i_l_e + Use a compiled lesskey file. + -K ........ --quit-on-intr + Exit less in response to ctrl-C. + -L ........ --no-lessopen + Ignore the LESSOPEN environment variable. + -m -M .... --long-prompt --LONG-PROMPT + Set prompt style. + -n ......... --line-numbers + Suppress line numbers in prompts and messages. + -N ......... --LINE-NUMBERS + Display line number at start of each line. + -o [_f_i_l_e] .. --log-file=[_f_i_l_e] + Copy to log file (standard input only). + -O [_f_i_l_e] .. --LOG-FILE=[_f_i_l_e] + Copy to log file (unconditionally overwrite). + -p _p_a_t_t_e_r_n . --pattern=[_p_a_t_t_e_r_n] + Start at pattern (from command line). + -P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t] + Define new prompt. + -q -Q .... --quiet --QUIET --silent --SILENT + Quiet the terminal bell. + -r -R .... --raw-control-chars --RAW-CONTROL-CHARS + Output "raw" control characters. + -s ........ --squeeze-blank-lines + Squeeze multiple blank lines. + -S ........ --chop-long-lines + Chop (truncate) long lines rather than wrapping. + -t _t_a_g .... --tag=[_t_a_g] + Find a tag. + -T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e] + Use an alternate tags file. + -u -U .... --underline-special --UNDERLINE-SPECIAL + Change handling of backspaces, tabs and carriage returns. + -V ........ --version + Display the version number of "less". + -w ........ --hilite-unread + Highlight first new line after forward-screen. + -W ........ --HILITE-UNREAD + Highlight first new line after any forward movement. + -x [_N[,...]] --tabs=[_N[,...]] + Set tab stops. + -X ........ --no-init + Don't use termcap init/deinit strings. + -y [_N] .... --max-forw-scroll=[_N] + Forward scroll limit. + -z [_N] .... --window=[_N] + Set size of window. + -" [_c[_c]] . --quotes=[_c[_c]] + Set shell quote characters. + -~ ........ --tilde + Don't display tildes after end of file. + -# [_N] .... --shift=[_N] + Set horizontal scroll amount (0 = one half screen width). + + --exit-follow-on-close + Exit F command on a pipe when writer closes pipe. + --file-size + Automatically determine the size of the input file. + --follow-name + The F command changes files if the input file is renamed. + --form-feed + Stop scrolling when a form feed character is reached. + --header=[_L[,_C[,_N]]] + Use _L lines (starting at line _N) and _C columns as headers. + --incsearch + Search file as each pattern character is typed in. + --intr=[_C] + Use _C instead of ^X to interrupt a read. + --lesskey-context=_t_e_x_t + Use lesskey source file contents. + --lesskey-src=_f_i_l_e + Use a lesskey source file. + --line-num-width=[_N] + Set the width of the -N line number field to _N characters. + --match-shift=[_N] + Show at least _N characters to the left of a search match. + --modelines=[_N] + Read _N lines from the input file and look for vim modelines. + --mouse + Enable mouse input. + --no-edit-warn + Don't warn when using v command on a file opened via LESSOPEN. + --no-keypad + Don't send termcap keypad init/deinit strings. + --no-histdups + Remove duplicates from command history. + --no-number-headers + Don't give line numbers to header lines. + --no-paste + Ignore pasted input. + --no-search-header-lines + Searches do not include header lines. + --no-search-header-columns + Searches do not include header columns. + --no-search-headers + Searches do not include header lines or columns. + --no-vbell + Disable the terminal's visual bell. + --redraw-on-quit + Redraw final screen when quitting. + --rscroll=[_C] + Set the character used to mark truncated lines. + --save-marks + Retain marks across invocations of less. + --search-options=[EFKNRW-] + Set default options for every search. + --show-preproc-errors + Display a message if preprocessor exits with an error status. + --proc-backspace + Process backspaces for bold/underline. + --PROC-BACKSPACE + Treat backspaces as control characters. + --proc-return + Delete carriage returns before newline. + --PROC-RETURN + Treat carriage returns as control characters. + --proc-tab + Expand tabs to spaces. + --PROC-TAB + Treat tabs as control characters. + --status-col-width=[_N] + Set the width of the -J status column to _N characters. + --status-line + Highlight or color the entire line containing a mark. + --use-backslash + Subsequent options use backslash as escape char. + --use-color + Enables colored text. + --wheel-lines=[_N] + Each click of the mouse wheel moves _N lines. + --wordwrap + Wrap lines at spaces. + + + --------------------------------------------------------------------------- + + LLIINNEE EEDDIITTIINNGG + + These keys can be used to edit text being entered + on the "command line" at the bottom of the screen. + + RightArrow ..................... ESC-l ... Move cursor right one character. + LeftArrow ...................... ESC-h ... Move cursor left one character. + ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word. + ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word. + HOME ........................... ESC-0 ... Move cursor to start of line. + END ............................ ESC-$ ... Move cursor to end of line. + BACKSPACE ................................ Delete char to left of cursor. + DELETE ......................... ESC-x ... Delete char under cursor. + ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor. + ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor. + ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line. + UpArrow ........................ ESC-k ... Retrieve previous command line. + DownArrow ...................... ESC-j ... Retrieve next command line. + TAB ...................................... Complete filename & cycle. + SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle. + ctrl-L ................................... Complete filename, list all. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dedf0865..019ed63d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: prismjs: specifier: ^1.30.0 version: 1.30.0 + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -2134,6 +2137,11 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -4669,6 +4677,10 @@ snapshots: property-information@7.1.0: {} + qrcode.react@4.2.0(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 10fa5c9d..cddf9590 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -28,6 +28,7 @@ pub struct SessionConfigDTO { pub max_turns: Option, pub enable_context_compression: Option, pub compression_threshold: Option, + pub model_name: Option, } #[derive(Debug, Serialize)] @@ -153,6 +154,7 @@ pub async fn create_session( enable_context_compression: c.enable_context_compression.unwrap_or(true), compression_threshold: c.compression_threshold.unwrap_or(0.8), workspace_path: None, + model_id: c.model_name, }) .unwrap_or_default(); diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 34b08ae7..d40a1bed 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -2,7 +2,7 @@ use bitfun_core::agentic::{agents, tools}; use bitfun_core::infrastructure::ai::{AIClient, AIClientFactory}; -use bitfun_core::service::{ai_rules, config, filesystem, mcp, workspace}; +use bitfun_core::service::{ai_rules, config, filesystem, mcp, token_usage, workspace}; use bitfun_core::util::errors::*; use serde::{Deserialize, Serialize}; @@ -37,12 +37,13 @@ pub struct AppState { pub ai_rules_service: Arc, pub agent_registry: Arc, pub mcp_service: Option>, + pub token_usage_service: Arc, pub statistics: Arc>, pub start_time: std::time::Instant, } impl AppState { - pub async fn new_async() -> BitFunResult { + pub async fn new_async(token_usage_service: Arc) -> BitFunResult { let start_time = std::time::Instant::now(); let config_service = config::get_global_config_service().await.map_err(|e| { @@ -85,6 +86,7 @@ impl AppState { None } }; + let statistics = Arc::new(RwLock::new(AppStatistics { sessions_created: 0, messages_processed: 0, @@ -103,6 +105,7 @@ impl AppState { ai_rules_service, agent_registry, mcp_service, + token_usage_service, statistics, start_time, }; diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 5bfbf6b1..604f1b7e 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -28,6 +28,7 @@ pub mod storage_commands; pub mod subagent_api; pub mod system_api; pub mod terminal_api; +pub mod token_usage_api; pub mod tool_api; pub mod remote_connect_api; diff --git a/src/apps/desktop/src/api/token_usage_api.rs b/src/apps/desktop/src/api/token_usage_api.rs new file mode 100644 index 00000000..4ac13e00 --- /dev/null +++ b/src/apps/desktop/src/api/token_usage_api.rs @@ -0,0 +1,200 @@ +//! Token usage tracking API + +use crate::api::app_state::AppState; +use bitfun_core::service::token_usage::{ + ModelTokenStats, SessionTokenStats, TimeRange, TokenUsageQuery, TokenUsageSummary, +}; +use log::{debug, error, info}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tauri::State; + +#[derive(Debug, Deserialize)] +pub struct RecordTokenUsageRequest { + pub model_id: String, + pub session_id: String, + pub turn_id: String, + pub input_tokens: u32, + pub output_tokens: u32, + pub cached_tokens: u32, + #[serde(default)] + pub is_subagent: bool, +} + +#[derive(Debug, Deserialize)] +pub struct GetModelStatsRequest { + pub model_id: String, + pub time_range: Option, + #[serde(default)] + pub include_subagent: bool, +} + +#[derive(Debug, Deserialize)] +pub struct GetSessionStatsRequest { + pub session_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct QueryTokenUsageRequest { + pub model_id: Option, + pub session_id: Option, + pub time_range: TimeRange, + pub limit: Option, + pub offset: Option, + #[serde(default)] + pub include_subagent: bool, +} + +#[derive(Debug, Deserialize)] +pub struct ClearModelStatsRequest { + pub model_id: String, +} + +#[derive(Debug, Serialize)] +pub struct GetAllModelStatsResponse { + pub stats: HashMap, +} + +/// Record token usage for a specific turn +#[tauri::command] +pub async fn record_token_usage( + state: State<'_, AppState>, + request: RecordTokenUsageRequest, +) -> Result<(), String> { + debug!( + "Recording token usage: model={}, session={}, input={}, output={}", + request.model_id, request.session_id, request.input_tokens, request.output_tokens + ); + + state + .token_usage_service + .record_usage( + request.model_id, + request.session_id, + request.turn_id, + request.input_tokens, + request.output_tokens, + request.cached_tokens, + request.is_subagent, + ) + .await + .map_err(|e| { + error!("Failed to record token usage: {}", e); + format!("Failed to record token usage: {}", e) + }) +} + +/// Get token statistics for a specific model +#[tauri::command] +pub async fn get_model_token_stats( + state: State<'_, AppState>, + request: GetModelStatsRequest, +) -> Result, String> { + debug!("Getting token stats for model: {}", request.model_id); + + match request.time_range { + Some(time_range) => { + state + .token_usage_service + .get_model_stats_filtered(&request.model_id, time_range, request.include_subagent) + .await + .map_err(|e| { + error!("Failed to get filtered model stats: {}", e); + format!("Failed to get filtered model stats: {}", e) + }) + } + None => { + Ok(state + .token_usage_service + .get_model_stats(&request.model_id) + .await) + } + } +} + +/// Get token statistics for all models +#[tauri::command] +pub async fn get_all_model_token_stats( + state: State<'_, AppState>, +) -> Result { + debug!("Getting token stats for all models"); + + let stats = state.token_usage_service.get_all_model_stats().await; + + Ok(GetAllModelStatsResponse { stats }) +} + +/// Get token statistics for a specific session +#[tauri::command] +pub async fn get_session_token_stats( + state: State<'_, AppState>, + request: GetSessionStatsRequest, +) -> Result, String> { + debug!("Getting token stats for session: {}", request.session_id); + + Ok(state + .token_usage_service + .get_session_stats(&request.session_id) + .await) +} + +/// Query token usage records with filters +#[tauri::command] +pub async fn query_token_usage( + state: State<'_, AppState>, + request: QueryTokenUsageRequest, +) -> Result { + debug!("Querying token usage with filters: {:?}", request); + + let query = TokenUsageQuery { + model_id: request.model_id, + session_id: request.session_id, + time_range: request.time_range, + limit: request.limit, + offset: request.offset, + include_subagent: request.include_subagent, + }; + + state + .token_usage_service + .get_summary(query) + .await + .map_err(|e| { + error!("Failed to query token usage: {}", e); + format!("Failed to query token usage: {}", e) + }) +} + +/// Clear token statistics for a specific model +#[tauri::command] +pub async fn clear_model_token_stats( + state: State<'_, AppState>, + request: ClearModelStatsRequest, +) -> Result<(), String> { + info!("Clearing token stats for model: {}", request.model_id); + + state + .token_usage_service + .clear_model_stats(&request.model_id) + .await + .map_err(|e| { + error!("Failed to clear model stats: {}", e); + format!("Failed to clear model stats: {}", e) + }) +} + +/// Clear all token statistics +#[tauri::command] +pub async fn clear_all_token_stats(state: State<'_, AppState>) -> Result<(), String> { + info!("Clearing all token statistics"); + + state + .token_usage_service + .clear_all_stats() + .await + .map_err(|e| { + error!("Failed to clear all stats: {}", e); + format!("Failed to clear all stats: {}", e) + }) +} + diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 437dd7dc..f0e483b0 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -42,6 +42,7 @@ use api::startchat_agent_api::*; use api::storage_commands::*; use api::subagent_api::*; use api::system_api::*; +use api::token_usage_api::*; use api::tool_api::*; /// Agentic Coordinator state @@ -72,7 +73,7 @@ pub async fn run() { return; } - let (coordinator, event_queue, event_router, ai_client_factory) = + let (coordinator, event_queue, event_router, ai_client_factory, token_usage_service) = match init_agentic_system().await { Ok(state) => state, Err(e) => { @@ -86,7 +87,7 @@ pub async fn run() { return; } - let app_state = match AppState::new_async().await { + let app_state = match AppState::new_async(token_usage_service).await { Ok(state) => state, Err(e) => { log::error!("Failed to initialize AppState: {}", e); @@ -555,6 +556,14 @@ pub async fn run() { i18n_get_supported_languages, i18n_get_config, i18n_set_config, + // Token Usage + record_token_usage, + get_model_token_stats, + get_all_model_token_stats, + get_session_token_stats, + query_token_usage, + clear_model_token_stats, + clear_all_token_stats, // Remote Connect api::remote_connect_api::remote_connect_get_device_info, api::remote_connect_api::remote_connect_get_lan_ip, @@ -578,6 +587,7 @@ async fn init_agentic_system() -> anyhow::Result<( Arc, Arc, Arc, + Arc, )> { use bitfun_core::agentic::*; @@ -645,8 +655,21 @@ async fn init_agentic_system() -> anyhow::Result<( coordination::ConversationCoordinator::set_global(coordinator.clone()); + // Initialize token usage service and register subscriber + let token_usage_service = Arc::new( + bitfun_core::service::token_usage::TokenUsageService::new(path_manager.clone()) + .await + .map_err(|e| anyhow::anyhow!("Failed to initialize token usage service: {}", e))?, + ); + let token_usage_subscriber = Arc::new( + bitfun_core::service::token_usage::TokenUsageSubscriber::new(token_usage_service.clone()) + ); + event_router.subscribe_internal("token_usage".to_string(), token_usage_subscriber); + + log::info!("Token usage service initialized and subscriber registered"); + log::info!("Agentic system initialized"); - Ok((coordinator, event_queue, event_router, ai_client_factory)) + Ok((coordinator, event_queue, event_router, ai_client_factory, token_usage_service)) } async fn init_function_agents(ai_client_factory: Arc) -> anyhow::Result<()> { diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index f84b8cf4..b02ff7c7 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -316,6 +316,11 @@ impl ConversationCoordinator { session.config.enable_tools.to_string(), ); + // Pass model_id for token usage tracking + if let Some(model_id) = &session.config.model_id { + context_vars.insert("model_name".to_string(), model_id.clone()); + } + // Pass snapshot session ID if let Some(snapshot_id) = &session.snapshot_session_id { context_vars.insert("snapshot_session_id".to_string(), snapshot_id.clone()); diff --git a/src/crates/core/src/agentic/core/session.rs b/src/crates/core/src/agentic/core/session.rs index 07ca3186..2b913b63 100644 --- a/src/crates/core/src/agentic/core/session.rs +++ b/src/crates/core/src/agentic/core/session.rs @@ -115,6 +115,9 @@ pub struct SessionConfig { /// without changing the desktop's foreground workspace. #[serde(skip_serializing_if = "Option::is_none")] pub workspace_path: Option, + /// Model config ID used by this session (for token usage tracking) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, } impl Default for SessionConfig { @@ -128,6 +131,7 @@ impl Default for SessionConfig { enable_context_compression: true, compression_threshold: 0.8, // 80% workspace_path: None, + model_id: None, } } } diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index 70512430..8af84a12 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -452,11 +452,7 @@ impl ExecutionEngine { round_number: round_index, messages: messages.clone(), available_tools: available_tools.clone(), - model_name: context - .context - .get("model_name") - .cloned() - .unwrap_or_else(|| "default".to_string()), + model_name: model_id.clone(), agent_type: agent_type.clone(), context_vars: round_context_vars, cancellation_token: CancellationToken::new(), diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index 9a0a8c84..71ef56e1 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -234,25 +234,24 @@ impl RoundExecutor { // If stream response contains usage info, update token statistics if let Some(ref usage) = stream_result.usage { debug!( - "Updating token stats from model response: input={}, output={}, total={}", - usage.prompt_token_count, usage.candidates_token_count, usage.total_token_count + "Updating token stats from model response: input={}, output={}, total={}, is_subagent={}", + usage.prompt_token_count, usage.candidates_token_count, usage.total_token_count, is_subagent ); - // Subagent does not send token events - if !is_subagent { - self.emit_event( - AgenticEvent::TokenUsageUpdated { - session_id: context.session_id.clone(), - turn_id: context.dialog_turn_id.clone(), - input_tokens: usage.prompt_token_count as usize, - output_tokens: Some(usage.candidates_token_count as usize), - total_tokens: usage.total_token_count as usize, - max_context_tokens: context_window, - }, - EventPriority::Normal, - ) - .await; - } + self.emit_event( + AgenticEvent::TokenUsageUpdated { + session_id: context.session_id.clone(), + turn_id: context.dialog_turn_id.clone(), + model_id: context.model_name.clone(), + input_tokens: usage.prompt_token_count as usize, + output_tokens: Some(usage.candidates_token_count as usize), + total_tokens: usage.total_token_count as usize, + max_context_tokens: context_window, + is_subagent, + }, + EventPriority::Normal, + ) + .await; } // Emit model round completed event diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index f7807208..94d35b8a 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -17,6 +17,7 @@ pub mod runtime; // Managed runtime and capability management pub mod snapshot; // Snapshot-based change tracking pub mod system; // System command detection and execution pub mod remote_connect; // Remote Connect (phone → desktop) +pub mod token_usage; // Token usage tracking pub mod workspace; // Workspace management // Diff calculation and merge service // Terminal is a standalone crate; re-export it here. @@ -41,4 +42,8 @@ pub use system::{ check_command, check_commands, run_command, run_command_simple, CheckCommandResult, CommandOutput, SystemError, }; +pub use token_usage::{ + ModelTokenStats, SessionTokenStats, TimeRange, TokenUsageQuery, TokenUsageRecord, + TokenUsageService, TokenUsageSummary, +}; pub use workspace::{WorkspaceManager, WorkspaceProvider, WorkspaceService}; diff --git a/src/crates/core/src/service/token_usage/mod.rs b/src/crates/core/src/service/token_usage/mod.rs new file mode 100644 index 00000000..17ed78ff --- /dev/null +++ b/src/crates/core/src/service/token_usage/mod.rs @@ -0,0 +1,14 @@ +//! Token usage tracking service +//! +//! Tracks and persists token consumption statistics per model, session, and turn. + +mod service; +mod subscriber; +mod types; + +pub use service::TokenUsageService; +pub use subscriber::TokenUsageSubscriber; +pub use types::{ + ModelTokenStats, SessionTokenStats, TimeRange, TokenUsageQuery, TokenUsageRecord, + TokenUsageSummary, +}; diff --git a/src/crates/core/src/service/token_usage/service.rs b/src/crates/core/src/service/token_usage/service.rs new file mode 100644 index 00000000..76b2ace0 --- /dev/null +++ b/src/crates/core/src/service/token_usage/service.rs @@ -0,0 +1,543 @@ +//! Token usage tracking service implementation + +use super::types::{ + ModelTokenStats, SessionTokenStats, TimeRange, TokenUsageQuery, TokenUsageRecord, + TokenUsageSummary, +}; +use crate::infrastructure::PathManager; +use anyhow::{Context, Result}; +use chrono::{DateTime, Datelike, Duration, Utc}; +use log::{debug, error, info, warn}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::fs; +use tokio::sync::RwLock; + +const TOKEN_USAGE_DIR: &str = "token_usage"; +const MODEL_STATS_FILE: &str = "model_stats.json"; +const RECORDS_DIR: &str = "records"; + +/// Token usage tracking service +pub struct TokenUsageService { + path_manager: Arc, + model_stats: Arc>>, + session_cache: Arc>>, +} + +#[derive(Debug, Serialize, Deserialize)] +struct RecordsBatch { + records: Vec, +} + +impl TokenUsageService { + /// Create a new token usage service + pub async fn new(path_manager: Arc) -> Result { + let service = Self { + path_manager, + model_stats: Arc::new(RwLock::new(HashMap::new())), + session_cache: Arc::new(RwLock::new(HashMap::new())), + }; + + // Initialize storage directories + service.init_storage().await?; + + // Load existing statistics + service.load_model_stats().await?; + + info!("Token usage service initialized"); + Ok(service) + } + + /// Initialize storage directories + async fn init_storage(&self) -> Result<()> { + let base_dir = self.get_base_dir(); + let records_dir = base_dir.join(RECORDS_DIR); + + fs::create_dir_all(&base_dir) + .await + .context("Failed to create token usage directory")?; + fs::create_dir_all(&records_dir) + .await + .context("Failed to create records directory")?; + + debug!("Token usage storage initialized at: {:?}", base_dir); + Ok(()) + } + + /// Get base directory for token usage data + fn get_base_dir(&self) -> PathBuf { + self.path_manager.user_data_dir().join(TOKEN_USAGE_DIR) + } + + /// Get model stats file path + fn get_model_stats_path(&self) -> PathBuf { + self.get_base_dir().join(MODEL_STATS_FILE) + } + + /// Get records file path for a specific date + fn get_records_path(&self, date: DateTime) -> PathBuf { + let filename = format!("{}.json", date.format("%Y-%m-%d")); + self.get_base_dir().join(RECORDS_DIR).join(filename) + } + + /// Load model statistics from disk + async fn load_model_stats(&self) -> Result<()> { + let path = self.get_model_stats_path(); + + if !path.exists() { + debug!("No existing model stats file found"); + return Ok(()); + } + + let content = match fs::read_to_string(&path).await { + Ok(c) => c, + Err(e) => { + warn!("Failed to read model stats file, starting fresh: {}", e); + return Ok(()); + } + }; + + let stats: HashMap = match serde_json::from_str(&content) { + Ok(s) => s, + Err(e) => { + warn!("Failed to parse model stats file, starting fresh: {}", e); + // Backup the corrupted file for debugging + let backup_path = path.with_extension("json.bak"); + if let Err(backup_err) = fs::rename(&path, &backup_path).await { + warn!("Failed to backup corrupted model stats file: {}", backup_err); + } + return Ok(()); + } + }; + + let mut model_stats = self.model_stats.write().await; + *model_stats = stats; + + info!("Loaded statistics for {} models", model_stats.len()); + Ok(()) + } + + /// Save model statistics to disk + async fn save_model_stats(&self) -> Result<()> { + let path = self.get_model_stats_path(); + let model_stats = self.model_stats.read().await; + + let content = serde_json::to_string_pretty(&*model_stats) + .context("Failed to serialize model stats")?; + + fs::write(&path, content) + .await + .context("Failed to write model stats file")?; + + debug!("Saved statistics for {} models", model_stats.len()); + Ok(()) + } + + /// Record a token usage event + pub async fn record_usage( + &self, + model_id: String, + session_id: String, + turn_id: String, + input_tokens: u32, + output_tokens: u32, + cached_tokens: u32, + is_subagent: bool, + ) -> Result<()> { + let now = Utc::now(); + let total_tokens = input_tokens + output_tokens; + + let record = TokenUsageRecord { + model_id: model_id.clone(), + session_id: session_id.clone(), + turn_id, + timestamp: now, + input_tokens, + output_tokens, + cached_tokens, + total_tokens, + is_subagent, + }; + + // Update model statistics (all-time aggregation, includes everything) + self.update_model_stats(&record).await?; + + // Update session cache + self.update_session_cache(&record).await?; + + // Persist record to disk + self.persist_record(&record).await?; + + debug!( + "Recorded token usage: model={}, session={}, input={}, output={}, total={}, is_subagent={}", + model_id, session_id, input_tokens, output_tokens, total_tokens, is_subagent + ); + + Ok(()) + } + + /// Update model statistics + async fn update_model_stats(&self, record: &TokenUsageRecord) -> Result<()> { + let mut model_stats = self.model_stats.write().await; + + let stats = model_stats + .entry(record.model_id.clone()) + .or_insert_with(|| ModelTokenStats { + model_id: record.model_id.clone(), + ..Default::default() + }); + + stats.total_input += record.input_tokens as u64; + stats.total_output += record.output_tokens as u64; + stats.total_cached += record.cached_tokens as u64; + stats.total_tokens += record.total_tokens as u64; + stats.request_count += 1; + + // Track unique sessions + if stats.session_ids.insert(record.session_id.clone()) { + stats.session_count += 1; + } + + if stats.first_used.is_none() { + stats.first_used = Some(record.timestamp); + } + stats.last_used = Some(record.timestamp); + + drop(model_stats); + + // Save to disk + self.save_model_stats().await?; + + Ok(()) + } + + /// Update session cache + async fn update_session_cache(&self, record: &TokenUsageRecord) -> Result<()> { + let mut session_cache = self.session_cache.write().await; + + let stats = session_cache + .entry(record.session_id.clone()) + .or_insert_with(|| SessionTokenStats { + session_id: record.session_id.clone(), + model_id: record.model_id.clone(), + total_input: 0, + total_output: 0, + total_cached: 0, + total_tokens: 0, + request_count: 0, + created_at: record.timestamp, + last_updated: record.timestamp, + }); + + stats.total_input += record.input_tokens; + stats.total_output += record.output_tokens; + stats.total_cached += record.cached_tokens; + stats.total_tokens += record.total_tokens; + stats.request_count += 1; + stats.last_updated = record.timestamp; + + Ok(()) + } + + /// Persist record to disk + async fn persist_record(&self, record: &TokenUsageRecord) -> Result<()> { + let path = self.get_records_path(record.timestamp); + + // Load existing records for the day + let mut batch = if path.exists() { + let content = fs::read_to_string(&path).await?; + serde_json::from_str::(&content).unwrap_or_else(|_| RecordsBatch { + records: Vec::new(), + }) + } else { + RecordsBatch { + records: Vec::new(), + } + }; + + // Add new record + batch.records.push(record.clone()); + + // Save back + let content = serde_json::to_string_pretty(&batch)?; + fs::write(&path, content).await?; + + Ok(()) + } + + /// Get statistics for a specific model + pub async fn get_model_stats(&self, model_id: &str) -> Option { + let model_stats = self.model_stats.read().await; + model_stats.get(model_id).cloned() + } + + /// Get statistics for a specific model with time range and subagent filter + pub async fn get_model_stats_filtered( + &self, + model_id: &str, + time_range: TimeRange, + include_subagent: bool, + ) -> Result> { + let query = TokenUsageQuery { + model_id: Some(model_id.to_string()), + session_id: None, + time_range, + limit: None, + offset: None, + include_subagent, + }; + + let records = self.query_records(query).await?; + if records.is_empty() { + return Ok(None); + } + + let mut stats = ModelTokenStats { + model_id: model_id.to_string(), + ..Default::default() + }; + + for record in &records { + stats.total_input += record.input_tokens as u64; + stats.total_output += record.output_tokens as u64; + stats.total_cached += record.cached_tokens as u64; + stats.total_tokens += record.total_tokens as u64; + stats.request_count += 1; + stats.session_ids.insert(record.session_id.clone()); + + if stats.first_used.is_none() || Some(record.timestamp) < stats.first_used { + stats.first_used = Some(record.timestamp); + } + if stats.last_used.is_none() || Some(record.timestamp) > stats.last_used { + stats.last_used = Some(record.timestamp); + } + } + + stats.session_count = stats.session_ids.len() as u32; + Ok(Some(stats)) + } + + /// Get all model statistics + pub async fn get_all_model_stats(&self) -> HashMap { + let model_stats = self.model_stats.read().await; + model_stats.clone() + } + + /// Get statistics for a specific session + pub async fn get_session_stats(&self, session_id: &str) -> Option { + let session_cache = self.session_cache.read().await; + session_cache.get(session_id).cloned() + } + + /// Query token usage records + pub async fn query_records(&self, query: TokenUsageQuery) -> Result> { + let (start_date, end_date) = self.get_date_range(&query.time_range); + + let mut all_records = Vec::new(); + let mut current_date = start_date; + + while current_date <= end_date { + let path = self.get_records_path(current_date); + + if path.exists() { + let content = fs::read_to_string(&path).await?; + if let Ok(batch) = serde_json::from_str::(&content) { + all_records.extend(batch.records); + } + } + + current_date = current_date + Duration::days(1); + } + + // Filter by model_id, session_id, and subagent flag + let include_subagent = query.include_subagent; + let filtered: Vec = all_records + .into_iter() + .filter(|r| { + // Filter out subagent records unless explicitly included + if !include_subagent && r.is_subagent { + return false; + } + if let Some(ref model_id) = query.model_id { + if &r.model_id != model_id { + return false; + } + } + if let Some(ref session_id) = query.session_id { + if &r.session_id != session_id { + return false; + } + } + true + }) + .collect(); + + // Apply pagination + let offset = query.offset.unwrap_or(0); + let limit = query.limit.unwrap_or(usize::MAX); + + Ok(filtered.into_iter().skip(offset).take(limit).collect()) + } + + /// Get date range from TimeRange enum + fn get_date_range(&self, time_range: &TimeRange) -> (DateTime, DateTime) { + let now = Utc::now(); + // Fallback: use Unix epoch as start if date calculation fails + let epoch = DateTime::UNIX_EPOCH; + + match time_range { + TimeRange::Today => { + let start = now + .date_naive() + .and_hms_opt(0, 0, 0) + .map(|t| t.and_utc()) + .unwrap_or(epoch); + (start, now) + } + TimeRange::ThisWeek => { + let days_from_monday = now.weekday().num_days_from_monday(); + let start = (now - Duration::days(days_from_monday as i64)) + .date_naive() + .and_hms_opt(0, 0, 0) + .map(|t| t.and_utc()) + .unwrap_or(epoch); + (start, now) + } + TimeRange::ThisMonth => { + let start = now + .date_naive() + .with_day(1) + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .map(|t| t.and_utc()) + .unwrap_or(epoch); + (start, now) + } + TimeRange::All => { + (epoch, now) + } + TimeRange::Custom { start, end } => (*start, *end), + } + } + + /// Get summary statistics + pub async fn get_summary(&self, query: TokenUsageQuery) -> Result { + let records = self.query_records(query).await?; + + let mut total_input = 0u64; + let mut total_output = 0u64; + let mut total_cached = 0u64; + let mut total_tokens = 0u64; + + let mut by_model: HashMap = HashMap::new(); + let mut by_session: HashMap = HashMap::new(); + + for record in &records { + total_input += record.input_tokens as u64; + total_output += record.output_tokens as u64; + total_cached += record.cached_tokens as u64; + total_tokens += record.total_tokens as u64; + + // Aggregate by model + let model_stats = by_model + .entry(record.model_id.clone()) + .or_insert_with(|| ModelTokenStats { + model_id: record.model_id.clone(), + ..Default::default() + }); + + model_stats.total_input += record.input_tokens as u64; + model_stats.total_output += record.output_tokens as u64; + model_stats.total_cached += record.cached_tokens as u64; + model_stats.total_tokens += record.total_tokens as u64; + model_stats.request_count += 1; + model_stats.session_ids.insert(record.session_id.clone()); + + if model_stats.first_used.is_none() || Some(record.timestamp) < model_stats.first_used { + model_stats.first_used = Some(record.timestamp); + } + if model_stats.last_used.is_none() || Some(record.timestamp) > model_stats.last_used { + model_stats.last_used = Some(record.timestamp); + } + + // Aggregate by session + let session_stats = by_session + .entry(record.session_id.clone()) + .or_insert_with(|| SessionTokenStats { + session_id: record.session_id.clone(), + model_id: record.model_id.clone(), + total_input: 0, + total_output: 0, + total_cached: 0, + total_tokens: 0, + request_count: 0, + created_at: record.timestamp, + last_updated: record.timestamp, + }); + + session_stats.total_input += record.input_tokens; + session_stats.total_output += record.output_tokens; + session_stats.total_cached += record.cached_tokens; + session_stats.total_tokens += record.total_tokens; + session_stats.request_count += 1; + + if record.timestamp < session_stats.created_at { + session_stats.created_at = record.timestamp; + } + if record.timestamp > session_stats.last_updated { + session_stats.last_updated = record.timestamp; + } + } + + // Update session counts from session_ids set + for stats in by_model.values_mut() { + stats.session_count = stats.session_ids.len() as u32; + } + + Ok(TokenUsageSummary { + total_input, + total_output, + total_cached, + total_tokens, + by_model, + by_session, + record_count: records.len(), + }) + } + + /// Clear statistics for a specific model + pub async fn clear_model_stats(&self, model_id: &str) -> Result<()> { + let mut model_stats = self.model_stats.write().await; + model_stats.remove(model_id); + drop(model_stats); + + self.save_model_stats().await?; + + info!("Cleared statistics for model: {}", model_id); + Ok(()) + } + + /// Clear all statistics + pub async fn clear_all_stats(&self) -> Result<()> { + let mut model_stats = self.model_stats.write().await; + model_stats.clear(); + drop(model_stats); + + let mut session_cache = self.session_cache.write().await; + session_cache.clear(); + drop(session_cache); + + self.save_model_stats().await?; + + // Optionally delete all record files + let records_dir = self.get_base_dir().join(RECORDS_DIR); + if records_dir.exists() { + fs::remove_dir_all(&records_dir).await?; + fs::create_dir_all(&records_dir).await?; + } + + info!("Cleared all token usage statistics"); + Ok(()) + } +} diff --git a/src/crates/core/src/service/token_usage/subscriber.rs b/src/crates/core/src/service/token_usage/subscriber.rs new file mode 100644 index 00000000..a787aa4c --- /dev/null +++ b/src/crates/core/src/service/token_usage/subscriber.rs @@ -0,0 +1,65 @@ +//! Token usage event subscriber + +use crate::agentic::events::{AgenticEvent, EventSubscriber}; +use crate::service::token_usage::TokenUsageService; +use crate::util::errors::BitFunResult; +use log::{debug, error, info}; +use std::sync::Arc; + +/// Token usage event subscriber +/// +/// Listens to TokenUsageUpdated events and records them +pub struct TokenUsageSubscriber { + token_usage_service: Arc, +} + +impl TokenUsageSubscriber { + pub fn new(token_usage_service: Arc) -> Self { + Self { + token_usage_service, + } + } +} + +#[async_trait::async_trait] +impl EventSubscriber for TokenUsageSubscriber { + async fn on_event(&self, event: &AgenticEvent) -> BitFunResult<()> { + if let AgenticEvent::TokenUsageUpdated { + session_id, + turn_id, + model_id, + input_tokens, + output_tokens, + total_tokens, + is_subagent, + .. + } = event + { + let output = output_tokens.unwrap_or(0); + let cached = 0; + + debug!( + "Recording token usage: model={}, session={}, turn={}, input={}, output={}, total={}, is_subagent={}", + model_id, session_id, turn_id, input_tokens, output, total_tokens, is_subagent + ); + + if let Err(e) = self + .token_usage_service + .record_usage( + model_id.clone(), + session_id.clone(), + turn_id.clone(), + *input_tokens as u32, + output as u32, + cached, + *is_subagent, + ) + .await + { + error!("Failed to record token usage: {}", e); + } + } + + Ok(()) + } +} diff --git a/src/crates/core/src/service/token_usage/types.rs b/src/crates/core/src/service/token_usage/types.rs new file mode 100644 index 00000000..438837ac --- /dev/null +++ b/src/crates/core/src/service/token_usage/types.rs @@ -0,0 +1,106 @@ +//! Token usage data types + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +/// Single token usage record for a specific API call +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenUsageRecord { + pub model_id: String, + pub session_id: String, + pub turn_id: String, + pub timestamp: DateTime, + pub input_tokens: u32, + pub output_tokens: u32, + pub cached_tokens: u32, + pub total_tokens: u32, + /// Whether this record is from a subagent call + #[serde(default)] + pub is_subagent: bool, +} + +/// Aggregated token statistics for a model +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelTokenStats { + pub model_id: String, + pub total_input: u64, + pub total_output: u64, + pub total_cached: u64, + pub total_tokens: u64, + /// Number of distinct sessions that used this model + pub session_count: u32, + /// Number of API requests made with this model + pub request_count: u32, + /// Set of session IDs that used this model (for dedup counting) + #[serde(default)] + pub session_ids: HashSet, + pub first_used: Option>, + pub last_used: Option>, +} + +impl Default for ModelTokenStats { + fn default() -> Self { + Self { + model_id: String::new(), + total_input: 0, + total_output: 0, + total_cached: 0, + total_tokens: 0, + session_count: 0, + request_count: 0, + session_ids: HashSet::new(), + first_used: None, + last_used: None, + } + } +} + +/// Token statistics for a specific session +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionTokenStats { + pub session_id: String, + pub model_id: String, + pub total_input: u32, + pub total_output: u32, + pub total_cached: u32, + pub total_tokens: u32, + pub request_count: u32, + pub created_at: DateTime, + pub last_updated: DateTime, +} + +/// Time range for querying statistics +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum TimeRange { + Today, + ThisWeek, + ThisMonth, + All, + Custom { start: DateTime, end: DateTime }, +} + +/// Query parameters for token usage +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenUsageQuery { + pub model_id: Option, + pub session_id: Option, + pub time_range: TimeRange, + pub limit: Option, + pub offset: Option, + /// Whether to include subagent token usage in results (default: false) + #[serde(default)] + pub include_subagent: bool, +} + +/// Summary of token usage with breakdown +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenUsageSummary { + pub total_input: u64, + pub total_output: u64, + pub total_cached: u64, + pub total_tokens: u64, + pub by_model: HashMap, + pub by_session: HashMap, + pub record_count: usize, +} diff --git a/src/crates/events/src/agentic.rs b/src/crates/events/src/agentic.rs index fda73528..c7825aac 100644 --- a/src/crates/events/src/agentic.rs +++ b/src/crates/events/src/agentic.rs @@ -79,10 +79,12 @@ pub enum AgenticEvent { TokenUsageUpdated { session_id: String, turn_id: String, + model_id: String, input_tokens: usize, output_tokens: Option, total_tokens: usize, max_context_tokens: Option, + is_subagent: bool, }, ContextCompressionStarted { diff --git a/src/crates/transport/src/adapters/tauri.rs b/src/crates/transport/src/adapters/tauri.rs index d297032f..2a69a698 100644 --- a/src/crates/transport/src/adapters/tauri.rs +++ b/src/crates/transport/src/adapters/tauri.rs @@ -127,14 +127,16 @@ impl TransportAdapter for TauriTransportAdapter { "subagentParentInfo": subagent_parent_info, }))?; } - AgenticEvent::TokenUsageUpdated { session_id, turn_id, input_tokens, output_tokens, total_tokens, max_context_tokens } => { + AgenticEvent::TokenUsageUpdated { session_id, turn_id, model_id, input_tokens, output_tokens, total_tokens, max_context_tokens, is_subagent } => { self.app_handle.emit("agentic://token-usage-updated", json!({ "sessionId": session_id, "turnId": turn_id, + "modelId": model_id, "inputTokens": input_tokens, "outputTokens": output_tokens, "totalTokens": total_tokens, "maxContextTokens": max_context_tokens, + "isSubagent": is_subagent, }))?; } AgenticEvent::ContextCompressionStarted { session_id, turn_id, subagent_parent_info, compression_id, trigger, tokens_before, context_window, threshold } => { diff --git a/src/mobile-web/package-lock.json b/src/mobile-web/package-lock.json index 18678285..a480fbc9 100644 --- a/src/mobile-web/package-lock.json +++ b/src/mobile-web/package-lock.json @@ -59,6 +59,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1570,6 +1571,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1671,6 +1673,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3282,6 +3285,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3603,6 +3607,7 @@ "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -3884,6 +3889,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/src/web-ui/src/infrastructure/api/index.ts b/src/web-ui/src/infrastructure/api/index.ts index fab276ff..4206d686 100644 --- a/src/web-ui/src/infrastructure/api/index.ts +++ b/src/web-ui/src/infrastructure/api/index.ts @@ -27,9 +27,10 @@ import { gitRepoHistoryAPI, type GitRepoHistory } from './service-api/GitRepoHis import { startchatAgentAPI } from './service-api/StartchatAgentAPI'; import { conversationAPI } from './service-api/ConversationAPI'; import { i18nAPI } from './service-api/I18nAPI'; +import { tokenUsageApi } from './tokenUsageApi'; // Export API modules -export { workspaceAPI, configAPI, aiApi, toolAPI, agentAPI, systemAPI, projectAPI, diffAPI, snapshotAPI, globalAPI, contextAPI, gitAPI, gitAgentAPI, gitRepoHistoryAPI, startchatAgentAPI, conversationAPI, i18nAPI }; +export { workspaceAPI, configAPI, aiApi, toolAPI, agentAPI, systemAPI, projectAPI, diffAPI, snapshotAPI, globalAPI, contextAPI, gitAPI, gitAgentAPI, gitRepoHistoryAPI, startchatAgentAPI, conversationAPI, i18nAPI, tokenUsageApi }; // Export types export type { GitRepoHistory }; @@ -53,6 +54,7 @@ export const bitfunAPI = { startchatAgent: startchatAgentAPI, conversation: conversationAPI, i18n: i18nAPI, + tokenUsage: tokenUsageApi, }; // Default export diff --git a/src/web-ui/src/infrastructure/api/tokenUsageApi.ts b/src/web-ui/src/infrastructure/api/tokenUsageApi.ts new file mode 100644 index 00000000..4a5db51d --- /dev/null +++ b/src/web-ui/src/infrastructure/api/tokenUsageApi.ts @@ -0,0 +1,150 @@ +// Token usage API client + +import { invoke } from '@tauri-apps/api/core'; + +export interface TokenUsageRecord { + model_id: string; + session_id: string; + turn_id: string; + timestamp: string; + input_tokens: number; + output_tokens: number; + cached_tokens: number; + total_tokens: number; +} + +export interface ModelTokenStats { + model_id: string; + total_input: number; + total_output: number; + total_cached: number; + total_tokens: number; + session_count: number; + request_count: number; + first_used: string | null; + last_used: string | null; +} + +export interface SessionTokenStats { + session_id: string; + model_id: string; + total_input: number; + total_output: number; + total_cached: number; + total_tokens: number; + request_count: number; + created_at: string; + last_updated: string; +} + +export type TimeRange = + | 'Today' + | 'ThisWeek' + | 'ThisMonth' + | 'All' + | { Custom: { start: string; end: string } }; + +export type StatsTimeRange = 'Last7Days' | 'Last30Days' | 'All'; + +export interface TokenUsageSummary { + total_input: number; + total_output: number; + total_cached: number; + total_tokens: number; + by_model: Record; + by_session: Record; + record_count: number; +} + +export const tokenUsageApi = { + /** + * Convert StatsTimeRange to a TimeRange with custom date calculation + */ + _toTimeRange(range: StatsTimeRange): TimeRange | undefined { + if (range === 'All') return undefined; + const now = new Date(); + const start = new Date(); + if (range === 'Last7Days') { + start.setDate(now.getDate() - 7); + } else if (range === 'Last30Days') { + start.setDate(now.getDate() - 30); + } + return { Custom: { start: start.toISOString(), end: now.toISOString() } }; + }, + + /** + * Get token statistics for a specific model + */ + async getModelStats( + modelId: string, + statsTimeRange?: StatsTimeRange, + includeSubagent?: boolean + ): Promise { + const timeRange = statsTimeRange ? this._toTimeRange(statsTimeRange) : undefined; + const needFiltered = timeRange !== undefined || includeSubagent; + return invoke('get_model_token_stats', { + request: { + model_id: modelId, + time_range: needFiltered ? (timeRange ?? 'All') : undefined, + include_subagent: includeSubagent ?? false, + } + }); + }, + + /** + * Get token statistics for all models + */ + async getAllModelStats(): Promise> { + const response = await invoke<{ stats: Record }>('get_all_model_token_stats', {}); + return response.stats; + }, + + /** + * Get token statistics for a specific session + */ + async getSessionStats(sessionId: string): Promise { + return invoke('get_session_token_stats', { + request: { session_id: sessionId } + }); + }, + + /** + * Query token usage with filters + */ + async queryTokenUsage( + modelId?: string, + sessionId?: string, + timeRange: TimeRange = 'All', + limit?: number, + offset?: number, + includeSubagent?: boolean + ): Promise { + return invoke('query_token_usage', { + request: { + model_id: modelId, + session_id: sessionId, + time_range: timeRange, + limit, + offset, + include_subagent: includeSubagent ?? false, + } + }); + }, + + /** + * Clear token statistics for a specific model + */ + async clearModelStats(modelId: string): Promise { + return invoke('clear_model_token_stats', { + request: { model_id: modelId } + }); + }, + + /** + * Clear all token statistics + */ + async clearAllStats(): Promise { + return invoke('clear_all_token_stats', {}); + } +}; + diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx index bb2fa81d..b33b76f9 100644 --- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Plus, Edit2, Trash2, Wifi, Loader, AlertTriangle, X, Settings, ArrowLeft, ExternalLink } from 'lucide-react'; +import { Plus, Edit2, Trash2, Wifi, Loader, AlertTriangle, X, Settings, ArrowLeft, ExternalLink, BarChart3 } from 'lucide-react'; import { Button, Switch, Select, IconButton, NumberInput, Card, Checkbox, Modal, Input, Textarea } from '@/component-library'; import { AIModelConfig as AIModelConfigType, @@ -14,6 +14,7 @@ import { aiApi, systemAPI } from '@/infrastructure/api'; import { useNotification } from '@/shared/notification-system'; import { ConfigPageHeader, ConfigPageLayout, ConfigPageContent, ConfigPageSection, ConfigPageRow, ConfigCollectionItem } from './common'; import DefaultModelConfig from './DefaultModelConfig'; +import TokenStatsModal from './TokenStatsModal'; import { createLogger } from '@/shared/utils/logger'; import './AIModelConfig.scss'; @@ -54,6 +55,8 @@ const AIModelConfig: React.FC = () => { const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); + const [showTokenStats, setShowTokenStats] = useState(false); + const [selectedModelForStats, setSelectedModelForStats] = useState<{ id: string; name: string } | null>(null); const [creationMode, setCreationMode] = useState<'selection' | 'form' | null>(null); @@ -975,6 +978,17 @@ const AIModelConfig: React.FC = () => { > {isTesting ? : } + + ))} + + + + + {loading ? ( +
+
+

{t('tokenStats.loading')}

+
+ ) : stats ? ( + <> +
+
+
+ +
+
+
{t('tokenStats.totalTokens')}
+
{formatNumber(stats.total_tokens)}
+
+
+ +
+
+ +
+
+
{t('tokenStats.inputTokens')}
+
{formatNumber(stats.total_input)}
+
+
+ +
+
+ +
+
+
{t('tokenStats.outputTokens')}
+
{formatNumber(stats.total_output)}
+
+
+ +
+
+ +
+
+
{t('tokenStats.sessionCount')}
+
{stats.session_count}
+
+
+
+ +
+
+ {t('tokenStats.requestCount')}: + {stats.request_count} +
+
+ {t('tokenStats.cachedTokens')}: + {formatNumber(stats.total_cached)} +
+
+ {t('tokenStats.firstUsed')}: + {formatDate(stats.first_used)} +
+
+ {t('tokenStats.lastUsed')}: + {formatDate(stats.last_used)} +
+
+ +
+ +
+ + ) : ( +
+

{t('tokenStats.noData')}

+
+ )} +
+ + ); +}; + +export default TokenStatsModal; diff --git a/src/web-ui/src/locales/en-US/settings/ai-model.json b/src/web-ui/src/locales/en-US/settings/ai-model.json index a636a41e..b3d4796a 100644 --- a/src/web-ui/src/locales/en-US/settings/ai-model.json +++ b/src/web-ui/src/locales/en-US/settings/ai-model.json @@ -157,7 +157,27 @@ "delete": "Delete", "test": "Test Connection", "newConfig": "New Configuration", - "createFirst": "Create First Configuration" + "createFirst": "Create First Configuration", + "viewStats": "View Statistics" + }, + "tokenStats": { + "title": "Token Usage Statistics", + "loading": "Loading...", + "noData": "No statistics data available", + "totalTokens": "Total Tokens", + "inputTokens": "Input Tokens", + "outputTokens": "Output Tokens", + "cachedTokens": "Cached Tokens", + "sessionCount": "Sessions", + "requestCount": "API Requests", + "firstUsed": "First Used", + "lastUsed": "Last Used", + "clearStats": "Clear Statistics", + "confirmClear": "Are you sure you want to clear all statistics for this model? This action cannot be undone.", + "rangeAll": "All", + "range30Days": "Last 30 Days", + "range7Days": "Last 7 Days", + "includeSubagent": "Include Subagent" }, "empty": { "noModels": "No model configurations", diff --git a/src/web-ui/src/locales/zh-CN/settings/ai-model.json b/src/web-ui/src/locales/zh-CN/settings/ai-model.json index bb7b8a55..e29a4d06 100644 --- a/src/web-ui/src/locales/zh-CN/settings/ai-model.json +++ b/src/web-ui/src/locales/zh-CN/settings/ai-model.json @@ -157,7 +157,27 @@ "delete": "删除", "test": "测试连接", "newConfig": "新建配置", - "createFirst": "创建第一个配置" + "createFirst": "创建第一个配置", + "viewStats": "查看统计" + }, + "tokenStats": { + "title": "Token消耗统计", + "loading": "加载中...", + "noData": "暂无统计数据", + "totalTokens": "总消耗", + "inputTokens": "输入Token", + "outputTokens": "输出Token", + "cachedTokens": "缓存Token", + "sessionCount": "会话数", + "requestCount": "请求次数", + "firstUsed": "首次使用", + "lastUsed": "最近使用", + "clearStats": "清除统计", + "confirmClear": "确定要清除该模型的所有统计数据吗?此操作不可恢复。", + "rangeAll": "全部", + "range30Days": "近30天", + "range7Days": "近7天", + "includeSubagent": "包含子代理" }, "empty": { "noModels": "暂无模型配置",