diff --git a/backend/app/api/chat_sessions.py b/backend/app/api/chat_sessions.py index 08c9963c..4fe60665 100644 --- a/backend/app/api/chat_sessions.py +++ b/backend/app/api/chat_sessions.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone as tz from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession @@ -17,6 +17,7 @@ from app.models.user import User router = APIRouter(prefix="/api/agents", tags=["chat-sessions"]) +DEFAULT_CONTEXT_BUDGET_TOKENS = 128000 def _is_admin_or_creator(user: User, agent: Agent) -> bool: @@ -56,6 +57,14 @@ class PatchSessionIn(BaseModel): title: str +class ContextBudgetOut(BaseModel): + window_size_messages: int + session_messages_total: int + estimated_tokens_current_window: int + budget_tokens: int + usage_ratio: float + + @router.get("/{agent_id}/sessions") async def list_sessions( agent_id: uuid.UUID, @@ -279,6 +288,66 @@ async def delete_session( return None +@router.get("/{agent_id}/sessions/{session_id}/context-budget", response_model=ContextBudgetOut) +async def get_session_context_budget( + agent_id: uuid.UUID, + session_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Return a lightweight context-budget snapshot for one chat session.""" + # Allow looking up sessions where agent_id OR peer_agent_id matches + result = await db.execute( + select(ChatSession).where( + ChatSession.id == session_id, + (ChatSession.agent_id == agent_id) | (ChatSession.peer_agent_id == agent_id), + ) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Permission: owner, admin, or creator can view + agent_result = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent = agent_result.scalar_one_or_none() + if str(session.user_id) != str(current_user.id) and not _is_admin_or_creator(current_user, agent): + raise HTTPException(status_code=403, detail="Not authorized to view this session") + + window_size = max(getattr(agent, "context_window_size", 100) or 100, 1) + + total_result = await db.execute( + select(func.count(ChatMessage.id)).where(ChatMessage.conversation_id == str(session_id)) + ) + session_messages_total = int(total_result.scalar() or 0) + + window_result = await db.execute( + select(ChatMessage.content) + .where(ChatMessage.conversation_id == str(session_id)) + .order_by(ChatMessage.created_at.desc()) + .limit(window_size) + ) + window_contents = window_result.scalars().all() + + total_chars = sum(len(content or "") for content in window_contents) + # Reuse existing char-based estimator for consistency across code paths. + from app.services.token_tracker import estimate_tokens_from_chars + estimated_tokens_current_window = estimate_tokens_from_chars(total_chars) if total_chars > 0 else 0 + + budget_tokens = DEFAULT_CONTEXT_BUDGET_TOKENS + usage_ratio = round( + (estimated_tokens_current_window / budget_tokens) if budget_tokens > 0 else 0.0, + 4, + ) + + return ContextBudgetOut( + window_size_messages=window_size, + session_messages_total=session_messages_total, + estimated_tokens_current_window=estimated_tokens_current_window, + budget_tokens=budget_tokens, + usage_ratio=usage_ratio, + ) + + @router.get("/{agent_id}/sessions/{session_id}/messages") async def get_session_messages( agent_id: uuid.UUID, diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 45d00e48..bc29e725 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -379,7 +379,18 @@ "attachment": "Attachment", "uploadFailed": "Upload failed", "askAboutFile": "Ask about {{name}}...", - "thinking": "Thinking..." + "thinking": "Thinking...", + "contextBudget": { + "aria": "View context window info", + "title": "Context Window Info", + "windowSize": "Window messages", + "sessionMessages": "Session total messages", + "estimatedTokens": "Estimated window tokens", + "budgetTokens": "Budget tokens", + "usageRatio": "Usage ratio", + "loading": "Loading...", + "unavailable": "No session data yet" + } }, "activityLog": { "title": "Activity Log", @@ -1037,4 +1048,4 @@ "ws_note": "WebSocket mode requires no public IP, callback URL, or domain verification (ICP). The connection is managed automatically." } } -} \ No newline at end of file +} diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json index 4941577e..e4ac32a3 100644 --- a/frontend/src/i18n/zh.json +++ b/frontend/src/i18n/zh.json @@ -387,7 +387,18 @@ "attachment": "附件", "uploadFailed": "上传失败", "askAboutFile": "关于 {{name}} 的问题...", - "thinking": "思考中..." + "thinking": "思考中...", + "contextBudget": { + "aria": "查看上下文窗口信息", + "title": "上下文窗口信息", + "windowSize": "窗口消息条数", + "sessionMessages": "会话总消息条数", + "estimatedTokens": "窗口估算 Token", + "budgetTokens": "预算 Token", + "usageRatio": "使用率", + "loading": "加载中...", + "unavailable": "暂无会话数据" + } }, "activityLog": { "title": "工作日志", diff --git a/frontend/src/pages/AgentDetail.tsx b/frontend/src/pages/AgentDetail.tsx index 2ebdaa4f..51ca09e7 100644 --- a/frontend/src/pages/AgentDetail.tsx +++ b/frontend/src/pages/AgentDetail.tsx @@ -12,7 +12,7 @@ import PromptModal from '../components/PromptModal'; import OpenClawSettings from './OpenClawSettings'; import AgentBayLivePanel, { LivePreviewState } from '../components/AgentBayLivePanel'; import AgentCredentials from '../components/AgentCredentials'; -import { activityApi, agentApi, channelApi, enterpriseApi, fileApi, scheduleApi, skillApi, taskApi, triggerApi, uploadFileWithProgress } from '../services/api'; +import { activityApi, agentApi, channelApi, chatSessionApi, enterpriseApi, fileApi, scheduleApi, skillApi, taskApi, triggerApi, uploadFileWithProgress } from '../services/api'; import { useAppStore } from '../stores'; import { useAuthStore } from '../stores'; import { copyToClipboard } from '../utils/clipboard'; @@ -1119,6 +1119,7 @@ function AgentDetailInner() { const [historyMsgs, setHistoryMsgs] = useState([]); const [sessionsLoading, setSessionsLoading] = useState(false); const [allSessionsLoading, setAllSessionsLoading] = useState(false); + const [showContextBudgetPopover, setShowContextBudgetPopover] = useState(false); const [agentExpired, setAgentExpired] = useState(false); // Websocket chat state (for 'me' conversation) const token = useAuthStore((s) => s.token); @@ -1134,6 +1135,17 @@ function AgentDetailInner() { const sessionMsgAbortRef = useRef(null); const sessionLoadSeqRef = useRef(0); + const { + data: sessionContextBudget, + isFetching: sessionContextBudgetFetching, + refetch: refetchSessionContextBudget, + } = useQuery({ + queryKey: ['agent-detail-chat-context-budget', id, activeSession?.id], + queryFn: () => chatSessionApi.contextBudget(id!, String(activeSession?.id)), + enabled: !!id && activeTab === 'chat' && !!activeSession?.id, + refetchInterval: activeTab === 'chat' && activeSession?.id ? 15000 : false, + }); + const buildSessionRuntimeKey = (agentId: string, sessionId: string) => `${agentId}:${sessionId}`; const clearReconnectTimer = (key: SessionRuntimeKey) => { @@ -1360,6 +1372,21 @@ function AgentDetailInner() { const chatContainerRef = useRef(null); const chatInputRef = useRef(null); const fileInputRef = useRef(null); + const usageRatioRaw = sessionContextBudget?.usage_ratio ?? 0; + const usageRatio = Number.isFinite(usageRatioRaw) ? Math.max(usageRatioRaw, 0) : 0; + const usageRatioClamped = Math.min(usageRatio, 1); + const usagePercent = Math.round(usageRatio * 1000) / 10; + const contextRingColor = usageRatio >= 1 + ? 'var(--error)' + : usageRatio >= 0.8 + ? 'var(--warning)' + : usageRatio >= 0.6 + ? 'var(--info)' + : 'var(--text-secondary)'; + const contextRingRadius = 8; + const contextRingCircumference = 2 * Math.PI * contextRingRadius; + const contextRingOffset = contextRingCircumference * (1 - usageRatioClamped); + const formatBudgetCount = (value: number | undefined) => (typeof value === 'number' ? value.toLocaleString() : '—'); // Settings form local state const [settingsForm, setSettingsForm] = useState({ @@ -1616,6 +1643,7 @@ function AgentDetailInner() { return [...prev, parseChatMsg({ role: d.role, content: d.content, timestamp: new Date().toISOString() })]; }); fetchMySessions(true, agentId); + refetchSessionContextBudget(); } else if (d.type === 'error' || d.type === 'quota_exceeded') { const msg = d.content || d.detail || d.message || 'Request denied'; setChatMessages(prev => { @@ -3957,6 +3985,129 @@ function AgentDetailInner() { )} +
setShowContextBudgetPopover(true)} + onMouseLeave={() => setShowContextBudgetPopover(false)} + > + + {showContextBudgetPopover && ( +
+
+ {t('agent.chat.contextBudget.title')} + {usagePercent.toFixed(1)}% +
+
+
+
+ {!activeSession?.id ? ( +
+ {t('agent.chat.contextBudget.unavailable')} +
+ ) : sessionContextBudgetFetching && !sessionContextBudget ? ( +
+ {t('agent.chat.contextBudget.loading')} +
+ ) : ( +
+ {t('agent.chat.contextBudget.windowSize')} + {formatBudgetCount(sessionContextBudget?.window_size_messages)} + {t('agent.chat.contextBudget.sessionMessages')} + {formatBudgetCount(sessionContextBudget?.session_messages_total)} + {t('agent.chat.contextBudget.estimatedTokens')} + {formatBudgetCount(sessionContextBudget?.estimated_tokens_current_window)} + {t('agent.chat.contextBudget.budgetTokens')} + {formatBudgetCount(sessionContextBudget?.budget_tokens)} + {t('agent.chat.contextBudget.usageRatio')} + {usagePercent.toFixed(1)}% +
+ )} +
+ )} +
diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index c8bd069b..638d627c 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import MarkdownRenderer from '../components/MarkdownRenderer'; import AgentBayLivePanel, { LivePreviewState } from '../components/AgentBayLivePanel'; -import { agentApi, enterpriseApi, uploadFileWithProgress } from '../services/api'; +import { agentApi, chatSessionApi, enterpriseApi, uploadFileWithProgress } from '../services/api'; import { IconPaperclip, IconSend } from '@tabler/icons-react'; import { formatFileSize } from '../utils/formatFileSize'; import { useAuthStore } from '../stores'; @@ -73,6 +73,7 @@ export default function Chat() { const [liveState, setLiveState] = useState({}); const [livePanelVisible, setLivePanelVisible] = useState(false); const [wsSessionId, setWsSessionId] = useState(''); + const [showBudgetPopover, setShowBudgetPopover] = useState(false); const wsRef = useRef(null); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); @@ -94,6 +95,26 @@ export default function Chat() { enabled: !!agent?.primary_model_id, }); + const { data: mineSessions = [], refetch: refetchMineSessions } = useQuery({ + queryKey: ['chat-sessions-mine', id], + queryFn: () => chatSessionApi.listMine(id!), + enabled: !!id && !!token, + refetchInterval: 15000, + }); + + const activeSessionId = mineSessions[0]?.id; + + const { + data: contextBudget, + isFetching: contextBudgetFetching, + refetch: refetchContextBudget, + } = useQuery({ + queryKey: ['chat-context-budget', id, activeSessionId], + queryFn: () => chatSessionApi.contextBudget(id!, activeSessionId!), + enabled: !!id && !!token && !!activeSessionId, + refetchInterval: 15000, + }); + const supportsVision = !!agent?.primary_model_id && llmModels.some( (m: any) => m.id === agent.primary_model_id && m.supports_vision ); @@ -269,6 +290,8 @@ export default function Chat() { } return updated; }); + refetchMineSessions(); + refetchContextBudget(); } else { // Legacy format: {role, content} setMessages(prev => [...prev, { role: data.role, content: data.content }]); @@ -400,6 +423,21 @@ export default function Chat() { }; const hasLiveData = !!(liveState.desktop || liveState.browser || liveState.code); + const usageRatioRaw = contextBudget?.usage_ratio ?? 0; + const usageRatio = Number.isFinite(usageRatioRaw) ? Math.max(usageRatioRaw, 0) : 0; + const usageRatioClamped = Math.min(usageRatio, 1); + const usagePercent = Math.round(usageRatio * 1000) / 10; + const ringColor = usageRatio >= 1 + ? 'var(--error)' + : usageRatio >= 0.8 + ? 'var(--warning)' + : usageRatio >= 0.6 + ? 'var(--info)' + : 'var(--text-secondary)'; + const ringRadius = 8; + const ringCircumference = 2 * Math.PI * ringRadius; + const ringOffset = ringCircumference * (1 - usageRatioClamped); + const formatCount = (value: number | undefined) => (typeof value === 'number' ? value.toLocaleString() : '—'); return (
@@ -416,6 +454,91 @@ export default function Chat() {
+
setShowBudgetPopover(true)} + onMouseLeave={() => setShowBudgetPopover(false)} + > + + {showBudgetPopover && ( +
+
+ {t('agent.chat.contextBudget.title')} +
+ {!activeSessionId ? ( +
+ {t('agent.chat.contextBudget.unavailable')} +
+ ) : contextBudgetFetching && !contextBudget ? ( +
+ {t('agent.chat.contextBudget.loading')} +
+ ) : ( +
+ {t('agent.chat.contextBudget.windowSize')} + {formatCount(contextBudget?.window_size_messages)} + {t('agent.chat.contextBudget.sessionMessages')} + {formatCount(contextBudget?.session_messages_total)} + {t('agent.chat.contextBudget.estimatedTokens')} + {formatCount(contextBudget?.estimated_tokens_current_window)} + {t('agent.chat.contextBudget.budgetTokens')} + {formatCount(contextBudget?.budget_tokens)} + {t('agent.chat.contextBudget.usageRatio')} + {usagePercent.toFixed(1)}% +
+ )} +
+ )} +
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 1519752b..29434601 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -258,6 +258,28 @@ export const agentApi = { request(`/agents/${id}/gateway-messages`), }; +export interface ChatSessionSummary { + id: string; + title: string; + last_message_at?: string | null; +} + +export interface SessionContextBudget { + window_size_messages: number; + session_messages_total: number; + estimated_tokens_current_window: number; + budget_tokens: number; + usage_ratio: number; +} + +export const chatSessionApi = { + listMine: (agentId: string) => + request(`/agents/${agentId}/sessions?scope=mine`), + + contextBudget: (agentId: string, sessionId: string) => + request(`/agents/${agentId}/sessions/${sessionId}/context-budget`), +}; + // ─── Tasks ──────────────────────────────────────────── export const taskApi = { list: (agentId: string, status?: string, type?: string) => {