Bläddra i källkod

feat:AI助手窗口增加agent、model、usage等信息显示

Jerry Liang 2 månader sedan
förälder
incheckning
690966a938

+ 36 - 0
service/controllers/aiChatControllers.go

@@ -55,6 +55,12 @@ func AiChat(c *fiber.Ctx) error {
 	c.Set("X-Accel-Buffering", "no")
 
 	c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
+		// 发送 session_info 事件,包含实际生效的 agent 和 model
+		infoContent, _ := json.Marshal(fiber.Map{"agentId": sess.AgentId, "modelId": sess.ModelId})
+		infoEvent, _ := json.Marshal(services.AIChatEvent{Type: "session_info", Content: string(infoContent)})
+		fmt.Fprintf(w, "event: session_info\ndata: %s\n\n", string(infoEvent))
+		w.Flush()
+
 		// 如果有欢迎消息且是新会话,先发送 welcome 事件
 		if sess.WelcomeMessage != "" {
 			welcomeEvent, _ := json.Marshal(services.AIChatEvent{Type: "welcome", Content: sess.WelcomeMessage})
@@ -83,6 +89,32 @@ func GetAiSessionList(c *fiber.Ctx) error {
 	return Success(c, list)
 }
 
+// GetCurrentAiSession 获取当前活跃会话信息(含实际生效的 agent/model 与最近用量)
+// GET /ai/sessions/current
+func GetCurrentAiSession(c *fiber.Ctx) error {
+	userId := middleware.AuthGetUserName(c)
+	sess := acpManager.GetSessionInfo(userId)
+	if sess == nil {
+		// 无活跃会话,返回空对象
+		return Success(c, fiber.Map{})
+	}
+
+	var contextUsagePercent float64
+	var turnDurationMs int64
+	if sess.Client != nil {
+		contextUsagePercent, turnDurationMs = sess.Client.GetMeta()
+	}
+
+	return Success(c, fiber.Map{
+		"sessionId":           sess.SessionId,
+		"title":               sess.Title,
+		"agentId":             sess.AgentId,
+		"modelId":             sess.ModelId,
+		"contextUsagePercent": contextUsagePercent,
+		"turnDurationMs":      turnDurationMs,
+	})
+}
+
 // CreateAiSession 创建新的 AI 会话(保留旧会话到历史)
 // POST /ai/sessions/new
 func CreateAiSession(c *fiber.Ctx) error {
@@ -97,6 +129,8 @@ func CreateAiSession(c *fiber.Ctx) error {
 	return Success(c, fiber.Map{
 		"sessionId":      sess.SessionId,
 		"welcomeMessage": sess.WelcomeMessage,
+		"agentId":        sess.AgentId,
+		"modelId":        sess.ModelId,
 	})
 }
 
@@ -118,6 +152,8 @@ func SwitchAiSession(c *fiber.Ctx) error {
 		"sessionId":      sess.SessionId,
 		"title":          sess.Title,
 		"welcomeMessage": sess.WelcomeMessage,
+		"agentId":        sess.AgentId,
+		"modelId":        sess.ModelId,
 	})
 }
 

+ 3 - 0
service/routes/aiChatRoutes.go

@@ -13,6 +13,9 @@ func AiChatSetup(api fiber.Router) {
 	// 获取会话历史列表
 	api.Get("/ai/sessions/list", controllers.GetAiSessionList)
 
+	// 获取当前活跃会话信息(agent/model/用量)
+	api.Get("/ai/sessions/current", controllers.GetCurrentAiSession)
+
 	// 创建新会话
 	api.Post("/ai/sessions/new", controllers.CreateAiSession)
 

+ 14 - 4
service/services/aiChatServices.go

@@ -28,6 +28,8 @@ type ACPSession struct {
 	SessionId      string
 	Title          string
 	WelcomeMessage string
+	AgentId        string // 实际生效的 agent(currentModeId)
+	ModelId        string // 实际生效的 model(currentModelId)
 	ReplayMessages []acp.ReplayMessage // session/load 回放的历史消息
 	Client         *acp.Client
 	Mutex          sync.Mutex
@@ -152,7 +154,7 @@ func (m *ACPManager) LoadSession(userId string, sessionId string) (*ACPSession,
 		return nil, err
 	}
 
-	loadedId, welcomeMsg, replayMessages, err := client.LoadSession(sessionId, m.serviceDir)
+	loadedId, welcomeMsg, agentId, modelId, replayMessages, err := client.LoadSession(sessionId, m.serviceDir)
 	if err != nil {
 		client.Close()
 		return nil, fmt.Errorf("session/load 失败: %w", err)
@@ -169,6 +171,8 @@ func (m *ACPManager) LoadSession(userId string, sessionId string) (*ACPSession,
 		SessionId:      sessionId,
 		Title:          item.Title,
 		WelcomeMessage: welcomeMsg,
+		AgentId:        agentId,
+		ModelId:        modelId,
 		ReplayMessages: replayMessages,
 		Client:         client,
 		LastActive:     time.Now(),
@@ -355,6 +359,8 @@ func (m *ACPManager) Prompt(sess *ACPSession, message string, eventCh chan<- AIC
 			eventCh <- AIChatEvent{Type: "tool_update", ToolName: event.ToolName, Status: event.ToolStatus}
 		case "permission":
 			eventCh <- AIChatEvent{Type: "permission", Content: toJSON(event.Permission)}
+		case "metadata":
+			eventCh <- AIChatEvent{Type: "metadata", Content: event.Content}
 		case "done":
 			eventCh <- AIChatEvent{Type: "done", SessionId: event.SessionId, StopReason: event.StopReason}
 		case "error":
@@ -378,7 +384,7 @@ func (m *ACPManager) createSession(userId string) (*ACPSession, error) {
 		return nil, err
 	}
 
-	sessionId, welcomeMsg, err := client.NewSession(m.serviceDir)
+	sessionId, welcomeMsg, agentId, modelId, err := client.NewSession(m.serviceDir)
 	if err != nil {
 		client.Close()
 		return nil, err
@@ -390,6 +396,8 @@ func (m *ACPManager) createSession(userId string) (*ACPSession, error) {
 		SessionId:      sessionId,
 		Title:          "",
 		WelcomeMessage: welcomeMsg,
+		AgentId:        agentId,
+		ModelId:        modelId,
 		Client:         client,
 		LastActive:     now,
 		CreatedAt:      now,
@@ -400,7 +408,7 @@ func (m *ACPManager) createSession(userId string) (*ACPSession, error) {
 	// 持久化
 	m.saveNewSession(userId, sess)
 
-	zap.L().Info("[AIChat] 会话创建成功", zap.String("userId", userId), zap.String("sessionId", sessionId))
+	zap.L().Info("[AIChat] 会话创建成功", zap.String("userId", userId), zap.String("sessionId", sessionId), zap.String("agent", agentId), zap.String("model", modelId))
 	return sess, nil
 }
 
@@ -415,7 +423,7 @@ func (m *ACPManager) startAndLoadSession(userId string, active *repositories.AiA
 		return nil, err
 	}
 
-	_, welcomeMsg, _, err := client.LoadSession(active.SessionId, m.serviceDir)
+	_, welcomeMsg, agentId, modelId, _, err := client.LoadSession(active.SessionId, m.serviceDir)
 	if err != nil {
 		client.Close()
 		return nil, fmt.Errorf("session/load 失败: %w", err)
@@ -427,6 +435,8 @@ func (m *ACPManager) startAndLoadSession(userId string, active *repositories.AiA
 		SessionId:      active.SessionId,
 		Title:          active.Title,
 		WelcomeMessage: welcomeMsg,
+		AgentId:        agentId,
+		ModelId:        modelId,
 		Client:         client,
 		LastActive:     now,
 		CreatedAt:      time.UnixMilli(active.CreatedAt),

+ 132 - 30
service/utils/acp/acp.go

@@ -88,8 +88,9 @@ type NewSessionParams struct {
 
 // NewSessionResult 创建会话响应
 type NewSessionResult struct {
-	SessionId string        `json:"sessionId"`
-	Modes     *SessionModes `json:"modes,omitempty"`
+	SessionId string         `json:"sessionId"`
+	Modes     *SessionModes  `json:"modes,omitempty"`
+	Models    *SessionModels `json:"models,omitempty"`
 }
 
 // SessionModes 会话模式
@@ -111,6 +112,19 @@ type SessionMeta struct {
 	WelcomeMessage string `json:"welcomeMessage"`
 }
 
+// SessionModels 会话可用模型
+type SessionModels struct {
+	CurrentModelId  string         `json:"currentModelId"`
+	AvailableModels []SessionModel `json:"availableModels"`
+}
+
+// SessionModel 单个模型
+type SessionModel struct {
+	ModelId     string `json:"modelId"`
+	Name        string `json:"name"`
+	Description string `json:"description"`
+}
+
 // PromptParams 发送消息参数
 type PromptParams struct {
 	SessionId string         `json:"sessionId"`
@@ -219,6 +233,11 @@ type Client struct {
 
 	// 文本累积(用于合并日志输出)
 	textBuffer string
+
+	// 最近一次上下文用量与耗时(供首屏展示,读取需加 metaMu)
+	metaMu             sync.Mutex
+	lastContextUsage   float64
+	lastTurnDurationMs int64
 }
 
 // ClientConfig 客户端配置
@@ -289,6 +308,25 @@ func (c *Client) ResolvePermission(optionId string) {
 	}
 }
 
+// setMeta 更新最近的上下文用量与耗时(值 >0 时才覆盖)
+func (c *Client) setMeta(usage float64, turnMs int64) {
+	c.metaMu.Lock()
+	defer c.metaMu.Unlock()
+	if usage > 0 {
+		c.lastContextUsage = usage
+	}
+	if turnMs > 0 {
+		c.lastTurnDurationMs = turnMs
+	}
+}
+
+// GetMeta 返回最近的上下文用量(百分比)与上一轮耗时(毫秒)
+func (c *Client) GetMeta() (float64, int64) {
+	c.metaMu.Lock()
+	defer c.metaMu.Unlock()
+	return c.lastContextUsage, c.lastTurnDurationMs
+}
+
 // Initialize 执行初始化握手
 func (c *Client) Initialize() error {
 	params := InitializeParams{
@@ -310,8 +348,8 @@ func (c *Client) Initialize() error {
 	return err
 }
 
-// NewSession 创建新会话,返回 sessionId 和 welcomeMessage
-func (c *Client) NewSession(cwd string) (string, string, error) {
+// NewSession 创建新会话,返回 sessionId、welcomeMessage、实际生效的 agentId 和 modelId
+func (c *Client) NewSession(cwd string) (sessionId string, welcomeMsg string, agentId string, modelId string, err error) {
 	params := NewSessionParams{
 		CWD:        cwd,
 		MCPServers: []any{},
@@ -319,21 +357,21 @@ func (c *Client) NewSession(cwd string) (string, string, error) {
 
 	result, err := c.sendAndWait("session/new", params)
 	if err != nil {
-		return "", "", err
+		return "", "", "", "", err
 	}
 
 	var res NewSessionResult
 	if err := json.Unmarshal(result, &res); err != nil {
-		return "", "", fmt.Errorf("解析 session/new 响应失败: %w", err)
+		return "", "", "", "", fmt.Errorf("解析 session/new 响应失败: %w", err)
 	}
 
 	zap.L().Info("session/new", zap.String("result", string(result)))
 
 	c.sessionId = res.SessionId
 
-	// 提取当前 mode 的 welcomeMessage
-	var welcomeMsg string
+	// 提取当前 mode 的 welcomeMessage 和实际生效的 agentId
 	if res.Modes != nil {
+		agentId = res.Modes.CurrentModeId
 		for _, mode := range res.Modes.AvailableModes {
 			if mode.Id == res.Modes.CurrentModeId && mode.Meta != nil {
 				welcomeMsg = mode.Meta.WelcomeMessage
@@ -342,7 +380,55 @@ func (c *Client) NewSession(cwd string) (string, string, error) {
 		}
 	}
 
-	return res.SessionId, welcomeMsg, nil
+	// 提取实际生效的 modelId
+	if res.Models != nil {
+		modelId = res.Models.CurrentModelId
+	}
+
+	// 捕获 session/new 之后 Kiro 紧跟发送的初始用量 metadata 通知
+	c.drainInitialMeta()
+
+	return res.SessionId, welcomeMsg, agentId, modelId, nil
+}
+
+// drainInitialMeta 读取 session/new 之后紧跟的一批通知,捕获其中的初始上下文用量。
+// Kiro 在 session/new 响应后会发送 commands/available、subagent/list_update、metadata 等
+// 一批通知,且 metadata(含 contextUsagePercentage)位于这批通知的末尾。这里读取直到
+// 遇到 metadata 通知即停止,避免读空后阻塞。设置行数上限作为安全兜底。
+func (c *Client) drainInitialMeta() {
+	const maxLines = 30
+	for i := 0; i < maxLines; i++ {
+		if !c.scanner.Scan() {
+			return
+		}
+		line := c.scanner.Text()
+		if line == "" {
+			continue
+		}
+
+		var msg struct {
+			Method string          `json:"method,omitempty"`
+			Params json.RawMessage `json:"params,omitempty"`
+			ID     json.RawMessage `json:"id,omitempty"`
+		}
+		if err := json.Unmarshal([]byte(line), &msg); err != nil {
+			continue
+		}
+
+		if msg.Method == "_kiro.dev/metadata" {
+			var data struct {
+				ContextUsagePercentage float64 `json:"contextUsagePercentage"`
+				TurnDurationMs         int64   `json:"turnDurationMs,omitempty"`
+			}
+			if err := json.Unmarshal(msg.Params, &data); err == nil {
+				zap.L().Debug("[ACP] 初始 metadata", zap.Float64("contextUsage%", data.ContextUsagePercentage))
+				c.setMeta(data.ContextUsagePercentage, data.TurnDurationMs)
+			}
+			return
+		}
+		// 其他 pre-prompt 通知(commands/available、subagent 等)忽略
+	}
+	zap.L().Debug("[ACP] drainInitialMeta 未在上限内捕获到 metadata")
 }
 
 // ReplayMessage 回放的单条消息
@@ -353,7 +439,7 @@ type ReplayMessage struct {
 }
 
 // LoadSession 加载已有会话(恢复历史对话上下文),返回回放的消息列表
-func (c *Client) LoadSession(sessionId string, cwd string) (string, string, []ReplayMessage, error) {
+func (c *Client) LoadSession(sessionId string, cwd string) (string, string, string, string, []ReplayMessage, error) {
 	params := map[string]any{
 		"sessionId":  sessionId,
 		"cwd":        cwd,
@@ -394,7 +480,7 @@ func (c *Client) LoadSession(sessionId string, cwd string) (string, string, []Re
 			if numId == id {
 				// session/load 完成
 				if msg.Error != nil {
-					return "", "", nil, fmt.Errorf("session/load 错误: %s", msg.Error.FullMessage())
+					return "", "", "", "", nil, fmt.Errorf("session/load 错误: %s", msg.Error.FullMessage())
 				}
 				c.sessionId = sessionId
 				// 追加最后一条未关闭的消息
@@ -402,21 +488,32 @@ func (c *Client) LoadSession(sessionId string, cwd string) (string, string, []Re
 					replayMessages = append(replayMessages, *currentMsg)
 				}
 
-				// 解析响应中的 welcomeMessage
+				// 解析响应中的 welcomeMessage、实际生效的 agentId 和 modelId
 				var welcomeMsg string
+				var agentId string
+				var modelId string
 				if len(msg.Result) > 0 {
 					var res NewSessionResult
-					if err := json.Unmarshal(msg.Result, &res); err == nil && res.Modes != nil {
-						for _, mode := range res.Modes.AvailableModes {
-							if mode.Id == res.Modes.CurrentModeId && mode.Meta != nil {
-								welcomeMsg = mode.Meta.WelcomeMessage
-								break
+					if err := json.Unmarshal(msg.Result, &res); err == nil {
+						if res.Modes != nil {
+							agentId = res.Modes.CurrentModeId
+							for _, mode := range res.Modes.AvailableModes {
+								if mode.Id == res.Modes.CurrentModeId && mode.Meta != nil {
+									welcomeMsg = mode.Meta.WelcomeMessage
+									break
+								}
 							}
 						}
+						if res.Models != nil {
+							modelId = res.Models.CurrentModelId
+						}
 					}
 				}
 
-				return sessionId, welcomeMsg, replayMessages, nil
+				// 捕获 session/load 之后紧跟的初始用量 metadata 通知
+				c.drainInitialMeta()
+
+				return sessionId, welcomeMsg, agentId, modelId, replayMessages, nil
 			}
 		}
 
@@ -471,7 +568,7 @@ func (c *Client) LoadSession(sessionId string, cwd string) (string, string, []Re
 		// 忽略其他通知(kiro 扩展等)
 	}
 
-	return "", "", nil, fmt.Errorf("session/load 未收到响应(进程可能已退出)")
+	return "", "", "", "", nil, fmt.Errorf("session/load 未收到响应(进程可能已退出)")
 }
 
 // Prompt 发送消息并流式读取事件
@@ -792,18 +889,23 @@ func (c *Client) handleExtensionNotification(method string, params json.RawMessa
 			TurnDurationMs         int64   `json:"turnDurationMs,omitempty"`
 		}
 		if err := json.Unmarshal(params, &data); err == nil {
+			zap.L().Debug("[ACP] metadata",
+				zap.Float64("contextUsage%", data.ContextUsagePercentage),
+				zap.Int64("turnMs", data.TurnDurationMs),
+			)
+			// 缓存最近用量/耗时,供首屏读取
+			c.setMeta(data.ContextUsagePercentage, data.TurnDurationMs)
+			// 上下文用量每条通知都会带(turnDurationMs 仅 turn 结束那条才有),
+			// 因此总是转发用量,耗时有值时才附带
+			content := map[string]any{
+				"contextUsagePercent": data.ContextUsagePercentage,
+			}
 			if data.TurnDurationMs > 0 {
-				zap.L().Debug("[ACP] metadata",
-					zap.Float64("contextUsage%", data.ContextUsagePercentage),
-					zap.Int64("turnMs", data.TurnDurationMs),
-				)
-				eventCh <- Event{
-					Type: "metadata",
-					Content: toJSONStr(map[string]any{
-						"contextUsagePercent": data.ContextUsagePercentage,
-						"turnDurationMs":      data.TurnDurationMs,
-					}),
-				}
+				content["turnDurationMs"] = data.TurnDurationMs
+			}
+			eventCh <- Event{
+				Type:    "metadata",
+				Content: toJSONStr(content),
 			}
 		}
 

+ 56 - 0
src/layout/header/Actions/AiChat/ChatPanel.vue

@@ -17,6 +17,10 @@ const {
   pendingFiles,
   isUploading,
   sessionLoadDoneInfo,
+  currentAgent,
+  currentModel,
+  contextUsage,
+  turnDuration,
   sendMessage,
   cancelChat,
   clearMessages,
@@ -47,6 +51,28 @@ function copySessionId(id?: string) {
   }
 }
 
+// 上下文用量百分比(取整,0~100)
+const contextUsagePct = computed(() => {
+  const v = Math.round(contextUsage.value || 0)
+  return Math.min(100, Math.max(0, v))
+})
+
+// 进度条颜色:<60% 绿 / 60~80% 黄 / >80% 红
+const contextUsageColor = computed(() => {
+  const v = contextUsagePct.value
+  if (v > 80) return '#ef4444'
+  if (v >= 60) return '#eab308'
+  return '#22c55e'
+})
+
+// 上一轮耗时格式化(毫秒 → s / ms)
+const turnDurationText = computed(() => {
+  const ms = turnDuration.value || 0
+  if (ms <= 0) return ''
+  if (ms < 1000) return `${ms}ms`
+  return `${(ms / 1000).toFixed(1)}s`
+})
+
 onMounted(() => {
   fetchSession()
 })
@@ -233,6 +259,36 @@ function formatExpiry(expiresAt: number): string {
       </div>
     </div>
 
+    <!-- 第2行状态条:实际生效的 Agent / Model 与上下文用量 -->
+    <div class="flex items-center justify-between gap-2 border-b px-3 py-1 text-[10px] text-neutral-400 dark:border-neutral-700">
+      <div class="flex items-center gap-3 truncate">
+        <span v-if="currentAgent" class="flex items-center gap-0.5 truncate" :title="`Agent: ${currentAgent}`">
+          <span>🤖</span>
+          <span class="truncate">{{ currentAgent }}</span>
+        </span>
+        <span v-if="currentModel" class="flex items-center gap-0.5 truncate" :title="`Model: ${currentModel}`">
+          <span>⚡</span>
+          <span class="truncate">{{ currentModel }}</span>
+        </span>
+      </div>
+      <div class="flex shrink-0 items-center gap-2">
+        <span v-if="turnDurationText" class="flex items-center gap-0.5" title="上一轮耗时">
+          <span>⏱</span>
+          <span>{{ turnDurationText }}</span>
+        </span>
+        <span class="flex items-center gap-1" title="上下文用量">
+          <span>上下文</span>
+          <span class="inline-block h-1.5 w-16 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-600">
+            <span
+              class="block h-full rounded-full transition-all"
+              :style="{ width: `${contextUsagePct}%`, backgroundColor: contextUsageColor }"
+            />
+          </span>
+          <span :style="{ color: contextUsageColor }">{{ contextUsagePct }}%</span>
+        </span>
+      </div>
+    </div>
+
     <!-- 会话列表侧边栏 -->
     <Transition name="slide">
       <div

+ 62 - 2
src/layout/header/Actions/AiChat/useAiChat.ts

@@ -4,6 +4,7 @@ import { useUserStore } from '@/stores/user'
 import { chatDB } from './chatDB'
 import {
   getAiSessionList,
+  getCurrentAiSession,
   createAiSession,
   switchAiSession,
   deleteAiSession,
@@ -31,6 +32,12 @@ export function useAiChat() {
   const isLoadingSession = ref(false)
   const sessionLoadDoneInfo = ref<SessionLoadDoneInfo | null>(null)
 
+  // 实际生效的 agent / model 与上下文用量(百分比 0~100)
+  const currentAgent = ref('')
+  const currentModel = ref('')
+  const contextUsage = ref(0)
+  const turnDuration = ref(0) // 上一轮耗时(毫秒)
+
   const userStore = useUserStore()
 
   function getBaseUrl(): string {
@@ -78,6 +85,15 @@ export function useAiChat() {
         currentSessionId.value = activeSession.sessionId
         // 从 IndexedDB 恢复本地消息
         await loadLocalMessages(activeSession.sessionId)
+        // 获取当前会话的 agent/model/用量(首屏展示)
+        try {
+          const info = await getCurrentAiSession()
+          currentAgent.value = info?.agentId || ''
+          currentModel.value = info?.modelId || ''
+          contextUsage.value = info?.contextUsagePercent || 0
+          turnDuration.value = info?.turnDurationMs || 0
+        }
+        catch { /* 忽略 */ }
         return
       }
 
@@ -87,7 +103,9 @@ export function useAiChat() {
         currentSessionId.value = latest.sessionId
         await loadLocalMessages(latest.sessionId)
         // 后台恢复 Kiro 上下文
-        await switchAiSession(latest.sessionId)
+        const data = await switchAiSession(latest.sessionId)
+        currentAgent.value = data?.agentId || ''
+        currentModel.value = data?.modelId || ''
         await fetchSessionList()
         return
       }
@@ -117,6 +135,10 @@ export function useAiChat() {
         currentSessionId.value = data.sessionId
         messages.value = []
         welcomeMessage.value = data.welcomeMessage || '欢迎使用AI助手'
+        currentAgent.value = data.agentId || ''
+        currentModel.value = data.modelId || ''
+        contextUsage.value = 0
+        turnDuration.value = 0
         // 刷新列表
         await fetchSessionList()
       }
@@ -141,6 +163,10 @@ export function useAiChat() {
         await saveLocalMessages()
       }
 
+      // 切换会话前重置上下文用量
+      contextUsage.value = 0
+      turnDuration.value = 0
+
       // 先尝试从 IndexedDB 缓存加载
       const cached = await chatDB.getSession(sessionId)
       if (cached && cached.messages.length > 0) {
@@ -149,7 +175,9 @@ export function useAiChat() {
         welcomeMessage.value = ''
 
         // 调用 switchAiSession 恢复 Kiro 上下文
-        await switchAiSession(sessionId)
+        const data = await switchAiSession(sessionId)
+        currentAgent.value = data?.agentId || ''
+        currentModel.value = data?.modelId || ''
         await fetchSessionList()
       }
       else {
@@ -158,6 +186,8 @@ export function useAiChat() {
         if (data?.sessionId) {
           currentSessionId.value = data.sessionId
           welcomeMessage.value = ''
+          currentAgent.value = data.agentId || ''
+          currentModel.value = data.modelId || ''
           messages.value = []
 
           // 通过 SSE 流式加载回放消息
@@ -578,6 +608,32 @@ export function useAiChat() {
         case 'welcome':
           welcomeMessage.value = payload.content || ''
           break
+        case 'session_info': {
+          // 实际生效的 agent / model
+          try {
+            const info =
+              typeof payload.content === 'string' ? JSON.parse(payload.content) : payload.content
+            if (info?.agentId) currentAgent.value = info.agentId
+            if (info?.modelId) currentModel.value = info.modelId
+          }
+          catch { /* ignore */ }
+          break
+        }
+        case 'metadata': {
+          // 上下文用量(content 是嵌套 JSON 字符串)
+          try {
+            const meta =
+              typeof payload.content === 'string' ? JSON.parse(payload.content) : payload.content
+            if (typeof meta?.contextUsagePercent === 'number') {
+              contextUsage.value = meta.contextUsagePercent
+            }
+            if (typeof meta?.turnDurationMs === 'number' && meta.turnDurationMs > 0) {
+              turnDuration.value = meta.turnDurationMs
+            }
+          }
+          catch { /* ignore */ }
+          break
+        }
         case 'error':
           aiMessage.content += `\n\n[错误: ${payload.content || '未知'}]`
           break
@@ -665,6 +721,10 @@ export function useAiChat() {
     pendingFiles,
     isUploading,
     sessionLoadDoneInfo,
+    currentAgent,
+    currentModel,
+    contextUsage,
+    turnDuration,
     sendMessage,
     cancelChat,
     clearMessages,

+ 15 - 2
src/service/aiChat/index.ts

@@ -19,6 +19,7 @@ import type { SessionListItem, AiFileItem } from '@/layout/header/Actions/AiChat
 const API = {
   chat: '/ai/chat',
   sessionList: '/ai/sessions/list',
+  sessionCurrent: '/ai/sessions/current',
   sessionNew: '/ai/sessions/new',
   sessionSwitch: (id: string) => `/ai/sessions/${id}`,
   sessionLoad: (id: string) => `/ai/sessions/${id}/load`,
@@ -36,13 +37,25 @@ export const getAiSessionList = async (): Promise<SessionListItem[]> => {
   return await http.get<SessionListItem[]>(API.sessionList)
 }
 
+// GET 获取当前活跃会话信息(agent/model/用量)
+export const getCurrentAiSession = async (): Promise<{
+  sessionId?: string
+  title?: string
+  agentId?: string
+  modelId?: string
+  contextUsagePercent?: number
+  turnDurationMs?: number
+}> => {
+  return await http.get(API.sessionCurrent)
+}
+
 // POST 创建新会话
-export const createAiSession = async (): Promise<{ sessionId: string, welcomeMessage?: string }> => {
+export const createAiSession = async (): Promise<{ sessionId: string, welcomeMessage?: string, agentId?: string, modelId?: string }> => {
   return await http.post(API.sessionNew)
 }
 
 // POST 切换会话(恢复 AI 上下文,轻量响应)
-export const switchAiSession = async (sessionId: string): Promise<{ sessionId: string, title?: string, welcomeMessage?: string }> => {
+export const switchAiSession = async (sessionId: string): Promise<{ sessionId: string, title?: string, welcomeMessage?: string, agentId?: string, modelId?: string }> => {
   return await http.post(API.sessionSwitch(sessionId))
 }