From 72a84ed9e257a8b89dfe106f5caa6ffb62855889 Mon Sep 17 00:00:00 2001 From: XiaoFeng <117837368+Fzhiyu1@users.noreply.github.com> Date: Tue, 13 Jan 2026 10:58:33 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20showPlan=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E4=BA=A4=E4=BA=92=E9=80=BB=E8=BE=91=E5=92=8C?= =?UTF-8?q?=20JWT=20Token=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 pendingQuestions 缺失时无法提交回答的问题 - 添加 fallbackTaskId 参数支持直接发送到后端 - apiClient 自动获取 JWT Token - 取消按钮改为中止对话而非发送消息 --- src/extension.ts | 10 +- src/services/apiClient.ts | 17 ++ src/services/dialogService.ts | 37 ++++- src/services/icCoderAuthProvider.ts | 14 ++ src/services/sseHandler.ts | 20 ++- src/services/userInteraction.ts | 24 ++- src/types/api.ts | 2 + src/utils/jwtUtils.ts | 28 ++++ src/views/ICViewProvider.ts | 13 +- src/views/planCard.ts | 238 +++++++++++++++++----------- 10 files changed, 300 insertions(+), 103 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index e1c67b4..935026c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -128,7 +128,15 @@ export function activate(context: vscode.ExtensionContext) { "ic-coder.login", async () => { try { - await vscode.authentication.getSession("iccoder", [], { createIfNone: true }); + // 检查是否有现有 session + const existingSession = await vscode.authentication.getSession("iccoder", [], { createIfNone: false }); + if (existingSession) { + // 有旧 session,使用 forceNewSession 强制创建新 session + await vscode.authentication.getSession("iccoder", [], { forceNewSession: true }); + } else { + // 没有旧 session,使用 createIfNone 创建新 session + await vscode.authentication.getSession("iccoder", [], { createIfNone: true }); + } } catch (error) { vscode.window.showErrorMessage(`登录失败: ${error}`); } diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 6980271..b918411 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -2,6 +2,7 @@ * API 客户端 * 封装与后端的 HTTP 通信 */ +import * as vscode from 'vscode'; import * as https from 'https'; import * as http from 'http'; import { URL } from 'url'; @@ -18,6 +19,18 @@ interface RequestOptions { timeout?: number; } +/** + * 获取当前登录的 Token + */ +async function getAuthToken(): Promise { + try { + const session = await vscode.authentication.getSession('iccoder', [], { silent: true }); + return session?.accessToken; + } catch { + return undefined; + } +} + /** * 发送 HTTP 请求 */ @@ -25,6 +38,9 @@ async function request(path: string, options: RequestOptions): Promise { const url = new URL(getApiUrl(path)); const { timeout } = getConfig(); + // 自动获取 Token + const token = await getAuthToken(); + const isHttps = url.protocol === 'https:'; const httpModule = isHttps ? https : http; @@ -35,6 +51,7 @@ async function request(path: string, options: RequestOptions): Promise { method: options.method, headers: { 'Content-Type': 'application/json', + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), ...options.headers }, timeout: options.timeout || timeout diff --git a/src/services/dialogService.ts b/src/services/dialogService.ts index fab077f..ef031a8 100644 --- a/src/services/dialogService.ts +++ b/src/services/dialogService.ts @@ -12,7 +12,7 @@ import { getConfig } from '../config/settings'; import type { DialogRequest, ToolCallRequest, AskUserEvent, RunMode, ServiceTier, ToolConfirmEvent, PlanConfirmEvent } from '../types/api'; import { submitToolConfirm, submitAnswer, stopDialog } from './apiClient'; import { ChatHistoryManager } from '../utils/chatHistoryManager'; -import { getUserIdFromToken } from '../utils/jwtUtils'; +import { getUserIdFromToken, isTokenExpired } from '../utils/jwtUtils'; import { updateCachedBalance } from './creditsService'; /** @@ -342,14 +342,29 @@ export class DialogSession { const config = getConfig(); - // 从登录 session 获取真实 userId + // 从登录 session 获取真实 userId 和 token let userId = config.userId; // 默认值 + let token: string | undefined; try { console.log('[DialogSession] 尝试获取登录 session...'); const session = await vscode.authentication.getSession('iccoder', [], { silent: true }); console.log('[DialogSession] session 结果:', session ? '已获取' : 'null/undefined'); if (session?.accessToken) { console.log('[DialogSession] accessToken 长度:', session.accessToken.length); + + // 检测 token 是否过期 + const expired = isTokenExpired(session.accessToken); + if (expired === true) { + console.error('[DialogSession] token 已过期,需要重新登录'); + vscode.window.showErrorMessage('登录已过期,请重新登录', '重新登录').then(selection => { + if (selection === '重新登录') { + vscode.commands.executeCommand('iccoder.login'); + } + }); + throw new Error('登录已过期,请重新登录'); + } + + token = session.accessToken; // 保存 token 用于扣费 const parsedUserId = getUserIdFromToken(session.accessToken); console.log('[DialogSession] 解析的 userId:', parsedUserId); if (parsedUserId) { @@ -380,6 +395,7 @@ export class DialogSession { userId, mode: mode || 'agent', serviceTier: serviceTier || config.serviceTier, // 优先使用传入的参数 + token, // JWT token 用于扣费 compactedData: compactedData || undefined, newMessages: newMessages.length > 0 ? newMessages : undefined, knowledgeData: knowledgeData || undefined @@ -711,6 +727,18 @@ export class DialogSession { onError: (data) => { this.isActive = false; + + // 检测登录状态过期(只弹一次窗,不再传递错误) + if (data.message.includes('LOGIN_EXPIRED') || data.message.includes('登录状态已过期')) { + vscode.window.showErrorMessage('登录状态已过期,请重新登录', '重新登录').then(selection => { + if (selection === '重新登录') { + vscode.commands.executeCommand('ic-coder.login'); + } + }); + // 登录过期错误已处理,不再传递给外部 + return; + } + callbacks.onError?.(data.message); }, @@ -895,7 +923,10 @@ export class DialogSession { selected?: string[], customInput?: string ): Promise { - await userInteractionManager.receiveAnswer(askId, selected, customInput); + // 直接调用 receiveAnswer,传递 taskId 作为 fallbackTaskId + // 如果 pendingQuestions 中有问题,走正常流程 + // 如果没有,receiveAnswer 会使用 fallbackTaskId 直接发送到后端 + await userInteractionManager.receiveAnswer(askId, selected, customInput, this.taskId); } } diff --git a/src/services/icCoderAuthProvider.ts b/src/services/icCoderAuthProvider.ts index ce8e2f4..2e40cee 100644 --- a/src/services/icCoderAuthProvider.ts +++ b/src/services/icCoderAuthProvider.ts @@ -61,6 +61,20 @@ export class ICCoderAuthenticationProvider scopes: readonly string[] ): Promise { try { + // 先删除旧的 session(静默删除,不弹窗、不重载窗口) + if (this._sessions.length > 0) { + const oldSession = this._sessions[0]; + this._sessions = []; + await this.saveSessions(); + await clearUserInfo(); + this._onDidChangeSessions.fire({ + added: [], + removed: [oldSession], + changed: [], + }); + console.log("🔄 已清除旧的 session"); + } + const token = await this.login(); // 获取到 token 后立即调用用户信息接口 diff --git a/src/services/sseHandler.ts b/src/services/sseHandler.ts index 266818f..1858965 100644 --- a/src/services/sseHandler.ts +++ b/src/services/sseHandler.ts @@ -173,7 +173,8 @@ export async function startStreamDialog( 'Content-Type': 'application/json', 'Accept': 'text/event-stream', 'Cache-Control': 'no-cache', - 'Content-Length': Buffer.byteLength(body) + 'Content-Length': Buffer.byteLength(body), + ...(request.token ? { 'Authorization': `Bearer ${request.token}` } : {}) } }; @@ -183,9 +184,20 @@ export async function startStreamDialog( let errorBody = ''; res.on('data', chunk => errorBody += chunk); res.on('end', () => { - const error = new Error(`SSE 连接失败: ${res.statusCode} ${errorBody}`); - callbacks.onError?.({ message: error.message }); - reject(error); + // 检测是否是登录状态过期 + const isLoginExpired = errorBody.includes('登录状态已过期') || + errorBody.includes('token') && errorBody.includes('过期') || + res.statusCode === 401; + + if (isLoginExpired) { + const error = new Error('LOGIN_EXPIRED:登录状态已过期,请重新登录'); + callbacks.onError?.({ message: error.message }); + reject(error); + } else { + const error = new Error(`SSE 连接失败: ${res.statusCode} ${errorBody}`); + callbacks.onError?.({ message: error.message }); + reject(error); + } }); return; } diff --git a/src/services/userInteraction.ts b/src/services/userInteraction.ts index a5fea5a..0885b35 100644 --- a/src/services/userInteraction.ts +++ b/src/services/userInteraction.ts @@ -82,21 +82,28 @@ export class UserInteractionManager { * @param askId 问题ID * @param selected 选中的选项 * @param customInput 自定义输入 + * @param fallbackTaskId 当问题不存在时使用的 taskId(用于直接发送到后端) */ async receiveAnswer( askId: string, selected?: string[], - customInput?: string + customInput?: string, + fallbackTaskId?: string ): Promise { const pending = this.pendingQuestions.get(askId); + const answer = customInput || selected?.join(', ') || ''; + if (!pending) { - console.warn(`[UserInteraction] 问题不存在或已超时: askId=${askId}`); + // 问题不存在(可能是页面刷新或会话切换后),尝试直接发送到后端 + if (fallbackTaskId) { + console.log(`[UserInteraction] 问题不在 pendingQuestions 中,直接发送到后端: askId=${askId}, taskId=${fallbackTaskId}`); + await this.submitUserAnswer(askId, fallbackTaskId, answer); + } else { + console.warn(`[UserInteraction] 问题不存在且无 fallbackTaskId: askId=${askId}`); + } return; } - // 构建答案 - const answer = customInput || selected?.join(', ') || ''; - console.log(`[UserInteraction] 收到用户回答: askId=${askId}, answer=${answer}`); // 移除待处理问题 @@ -173,6 +180,13 @@ export class UserInteractionManager { hasPendingQuestions(): boolean { return this.pendingQuestions.size > 0; } + + /** + * 检查特定问题是否存在 + */ + hasPendingQuestion(askId: string): boolean { + return this.pendingQuestions.has(askId); + } } // 全局实例 diff --git a/src/types/api.ts b/src/types/api.ts index 3262139..2dfe7de 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -40,6 +40,8 @@ export interface DialogRequest { mode: RunMode; /** 服务等级 */ serviceTier?: ServiceTier; + /** JWT Token(用于认证和扣费) */ + token?: string; /** 压缩后的记忆数据(用于后端重启后恢复) */ compactedData?: CompactedMemory; /** 压缩后产生的新消息 */ diff --git a/src/utils/jwtUtils.ts b/src/utils/jwtUtils.ts index bb4f9a8..c65cda0 100644 --- a/src/utils/jwtUtils.ts +++ b/src/utils/jwtUtils.ts @@ -71,3 +71,31 @@ export function getUserIdFromToken(token: string): string | null { console.warn('[JWT] payload 中没有 user_id, userId 或 sub 字段'); return null; } + +/** + * 检测 JWT token 是否已过期 + * @param token JWT token + * @param bufferSeconds 提前多少秒判定为过期(默认60秒) + * @returns true 表示已过期,false 表示未过期,null 表示无法判断 + */ +export function isTokenExpired(token: string, bufferSeconds: number = 60): boolean | null { + const payload = parseJwtPayload(token); + if (!payload) { + return null; + } + + if (payload.exp === undefined) { + console.warn('[JWT] payload 中没有 exp 字段,无法判断过期'); + return null; + } + + const now = Math.floor(Date.now() / 1000); + const expTime = payload.exp - bufferSeconds; + const isExpired = now >= expTime; + + if (isExpired) { + console.warn('[JWT] token 已过期,exp:', payload.exp, '当前:', now); + } + + return isExpired; +} diff --git a/src/views/ICViewProvider.ts b/src/views/ICViewProvider.ts index 44f397f..c028a2a 100644 --- a/src/views/ICViewProvider.ts +++ b/src/views/ICViewProvider.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode"; import { getWebviewContent } from "./webviewContent"; +import { isTokenExpired } from "../utils/jwtUtils"; import { handleUserMessage, insertCodeToEditor, @@ -138,7 +139,17 @@ export class ICViewProvider implements vscode.WebviewViewProvider { private async checkLoginStatus(): Promise { try { const session = await vscode.authentication.getSession("iccoder", [], { createIfNone: false }); - return !!session; + if (!session) { + return false; + } + // 检查 token 是否过期 + const expired = isTokenExpired(session.accessToken); + // 如果已过期或无法判断(null),都认为未登录 + if (expired === true || expired === null) { + console.log("Token 已过期或无法判断过期状态"); + return false; + } + return true; } catch (error) { console.log("检查登录状态失败:", error); return false; diff --git a/src/views/planCard.ts b/src/views/planCard.ts index c061e08..c83f6f4 100644 --- a/src/views/planCard.ts +++ b/src/views/planCard.ts @@ -150,24 +150,50 @@ export function getPlanCardStyles(): string { .plan-actions { display: flex; flex-direction: column; - gap: 10px; + gap: 12px; padding: 14px 16px; border-top: 1px solid var(--vscode-input-border); background: var(--vscode-sideBar-background); } - .plan-actions .question-options { + .plan-input-row { display: flex; - flex-wrap: wrap; gap: 8px; + width: 100%; + } + .plan-input { + flex: 1; + padding: 10px 12px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + font-size: 13px; + box-sizing: border-box; + } + .plan-input:focus { + outline: none; + border-color: var(--vscode-focusBorder); + } + .plan-btn-row { + display: flex; + gap: 10px; } .plan-btn { - padding: 8px 18px; + padding: 8px 20px; border-radius: 4px; border: none; cursor: pointer; - font-size: 12px; + font-size: 13px; font-weight: 500; } + .plan-btn-submit { + background: var(--vscode-input-background); + color: var(--vscode-foreground); + border: 1px solid var(--vscode-input-border); + } + .plan-btn-submit:hover { + background: var(--vscode-list-hoverBackground); + } .plan-btn-confirm { background: var(--vscode-button-background); color: var(--vscode-button-foreground); @@ -175,41 +201,26 @@ export function getPlanCardStyles(): string { .plan-btn-confirm:hover { background: var(--vscode-button-hoverBackground); } - .plan-btn-modify { - background: var(--vscode-input-background); - color: var(--vscode-foreground); - border: 1px solid var(--vscode-input-border); - } .plan-btn-cancel { background: transparent; color: var(--vscode-descriptionForeground); - } - .plan-actions .custom-input-container { - display: flex; - gap: 8px; - width: 100%; - } - .plan-actions .custom-input { - flex: 1; - padding: 8px 12px; - background: var(--vscode-input-background); - color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); - border-radius: 4px; + } + .plan-btn-cancel:hover { + background: var(--vscode-list-hoverBackground); + } + .plan-answered { + padding: 12px 16px; + border-top: 1px solid var(--vscode-input-border); + background: var(--vscode-sideBar-background); font-size: 13px; } - .plan-actions .custom-submit { - padding: 8px 18px; - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 12px; - font-weight: 500; + .answered-label { + color: var(--vscode-descriptionForeground); } - .plan-actions .custom-submit:hover { - background: var(--vscode-button-hoverBackground); + .answered-value { + color: var(--vscode-textLink-foreground); + font-weight: 500; } /* 阶段进度条样式 */ @@ -597,16 +608,17 @@ export function getPlanCardScript(): string { // 兼容旧格式:渲染步骤列表 const stepsHtml = !hasPhases ? renderPlanSteps(segment.planSteps || []) : ''; - // 选项按钮 - const options = ['确认执行', '修改计划', '取消']; - const optionsHtml = options.map(opt => { - const isSelected = isAnswered && opt === selectedAnswer; - return \`\`; - }).join(''); - // 渲染 Markdown 格式的摘要 const summaryHtml = renderPlanMarkdown(segment.planSummary || ''); + // 已回答时显示用户的选择 + const answeredHtml = isAnswered ? \` +
+ 已回复: + \${selectedAnswer} +
+ \` : ''; + segmentDiv.innerHTML = \`
@@ -618,59 +630,72 @@ export function getPlanCardScript(): string {
\${summaryHtml}
\${hasPhases ? \`
\${phasesHtml}
\` : \`
\${stepsHtml}
\`}
-
-
\${optionsHtml}
-
- - +
+
+ + +
+
+ +
+ \${answeredHtml}
\`; // 只在未回答时添加事件监听 if (!isAnswered) { setTimeout(() => { - const optionButtons = segmentDiv.querySelectorAll('.question-option'); - optionButtons.forEach(btn => { - btn.addEventListener('click', function() { - const option = this.getAttribute('data-option'); - // 发送答案到后端 - handleQuestionAnswerInSegment(segment.askId, option, segmentDiv); - // 同时发送 planAction 用于模式切换 - const actionMap = { - '确认执行': 'confirm', - '修改计划': 'modify', - '取消': 'cancel' - }; - vscode.postMessage({ - command: 'planAction', - action: actionMap[option] || option, - planTitle: segment.planTitle, - model: getCurrentModel() - }); - }); - }); + const submitBtn = segmentDiv.querySelector('.plan-btn-submit'); + const confirmBtn = segmentDiv.querySelector('.plan-btn-confirm'); + const cancelBtn = segmentDiv.querySelector('.plan-btn-cancel'); + const planInput = segmentDiv.querySelector('.plan-input'); - const submitBtn = segmentDiv.querySelector('.custom-submit'); - const customInput = segmentDiv.querySelector('.custom-input'); - if (submitBtn && customInput) { + // 提交修改按钮 + if (submitBtn && planInput) { submitBtn.addEventListener('click', function() { - const customValue = customInput.value.trim(); - if (customValue) { - handleQuestionAnswerInSegment(segment.askId, customValue, segmentDiv); + const inputValue = planInput.value.trim(); + if (inputValue) { + handleQuestionAnswerInSegment(segment.askId, inputValue, segmentDiv); } }); - customInput.addEventListener('keypress', function(e) { + // 回车键提交修改 + planInput.addEventListener('keypress', function(e) { if (e.key === 'Enter') { - const customValue = customInput.value.trim(); - if (customValue) { - handleQuestionAnswerInSegment(segment.askId, customValue, segmentDiv); + const inputValue = planInput.value.trim(); + if (inputValue) { + handleQuestionAnswerInSegment(segment.askId, inputValue, segmentDiv); } } }); } + + // 确认执行按钮 + if (confirmBtn) { + confirmBtn.addEventListener('click', function() { + handleQuestionAnswerInSegment(segment.askId, '确认执行', segmentDiv); + }); + } + + // 取消按钮 - 直接中止对话,不发送给智能体 + if (cancelBtn) { + cancelBtn.addEventListener('click', function() { + // 标记问题已回答 + answeredQuestions.set(segment.askId, '取消'); + segmentDiv.classList.add('answered'); + + // 隐藏操作按钮 + const actionsDiv = segmentDiv.querySelector('.plan-actions'); + if (actionsDiv) { + actionsDiv.style.display = 'none'; + } + + // 发送中止对话命令 + vscode.postMessage({ command: 'abortDialog' }); + }); + } }, 0); } } @@ -703,30 +728,65 @@ export function getPlanCardScript(): string {
\${summaryHtml}
\${hasPhases ? \`
\${phasesHtml}
\` : \`
\${stepsHtml}
\`}
-
- - - +
+
+ + +
+
+ + +
\`; - // 绑定按钮事件 + // 绑定按钮事件(静态渲染时也需要能响应) setTimeout(() => { - const planCard = segmentDiv.querySelector('.plan-card'); - if (planCard) { - planCard.querySelectorAll('.plan-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - const action = e.currentTarget?.dataset?.action; + const submitBtn = segmentDiv.querySelector('.plan-btn-submit'); + const confirmBtn = segmentDiv.querySelector('.plan-btn-confirm'); + const cancelBtn = segmentDiv.querySelector('.plan-btn-cancel'); + const planInput = segmentDiv.querySelector('.plan-input'); + + // 提交修改按钮 + if (submitBtn && planInput) { + submitBtn.addEventListener('click', function() { + const inputValue = planInput.value.trim(); + if (inputValue) { vscode.postMessage({ - command: 'planAction', - action: action, - planTitle: segment.planTitle, - model: getCurrentModel() + command: 'submitAnswer', + askId: segment.askId, + selected: [inputValue], + customInput: inputValue }); + } + }); + } + + // 确认执行按钮 + if (confirmBtn) { + confirmBtn.addEventListener('click', function() { + vscode.postMessage({ + command: 'submitAnswer', + askId: segment.askId, + selected: ['确认执行'], + customInput: '确认执行' }); }); } + + // 取消按钮 - 直接中止对话 + if (cancelBtn) { + cancelBtn.addEventListener('click', function() { + // 隐藏操作按钮 + const actionsDiv = segmentDiv.querySelector('.plan-actions'); + if (actionsDiv) { + actionsDiv.style.display = 'none'; + } + // 发送中止对话命令 + vscode.postMessage({ command: 'abortDialog' }); + }); + } }, 0); } `;