Merge branch 'feat/back-to-front' into feat/front-end

This commit is contained in:
Roe-xin
2026-01-13 11:07:53 +08:00
10 changed files with 312 additions and 103 deletions

View File

@ -132,7 +132,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}`);
}

View File

@ -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<string | undefined> {
try {
const session = await vscode.authentication.getSession('iccoder', [], { silent: true });
return session?.accessToken;
} catch {
return undefined;
}
}
/**
* 发送 HTTP 请求
*/
@ -25,6 +38,9 @@ async function request<T>(path: string, options: RequestOptions): Promise<T> {
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<T>(path: string, options: RequestOptions): Promise<T> {
method: options.method,
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...options.headers
},
timeout: options.timeout || timeout

View File

@ -29,7 +29,7 @@ import type {
} 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";
/**
@ -418,8 +418,9 @@ 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", [], {
@ -434,6 +435,22 @@ export class DialogSession {
"[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) {
@ -475,6 +492,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,
@ -861,6 +879,23 @@ 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);
},
@ -1068,7 +1103,15 @@ export class DialogSession {
selected?: string[],
customInput?: string
): Promise<void> {
await userInteractionManager.receiveAnswer(askId, selected, customInput);
// 直接调用 receiveAnswer传递 taskId 作为 fallbackTaskId
// 如果 pendingQuestions 中有问题,走正常流程
// 如果没有receiveAnswer 会使用 fallbackTaskId 直接发送到后端
await userInteractionManager.receiveAnswer(
askId,
selected,
customInput,
this.taskId
);
}
}

View File

@ -61,6 +61,20 @@ export class ICCoderAuthenticationProvider
scopes: readonly string[]
): Promise<vscode.AuthenticationSession> {
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 后立即调用用户信息接口

View File

@ -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;
}

View File

@ -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<void> {
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);
}
}
// 全局实例

View File

@ -40,6 +40,8 @@ export interface DialogRequest {
mode: RunMode;
/** 服务等级 */
serviceTier?: ServiceTier;
/** JWT Token用于认证和扣费 */
token?: string;
/** 压缩后的记忆数据(用于后端重启后恢复) */
compactedData?: CompactedMemory;
/** 压缩后产生的新消息 */

View File

@ -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;
}

View File

@ -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<boolean> {
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;

View File

@ -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 \`<button class="question-option\${isSelected ? ' selected' : ''}" data-option="\${opt}">\${opt}</button>\`;
}).join('');
// 渲染 Markdown 格式的摘要
const summaryHtml = renderPlanMarkdown(segment.planSummary || '');
// 已回答时显示用户的选择
const answeredHtml = isAnswered ? \`
<div class="plan-answered">
<span class="answered-label">已回复:</span>
<span class="answered-value">\${selectedAnswer}</span>
</div>
\` : '';
segmentDiv.innerHTML = \`
<div class="plan-card">
<div class="plan-header">
@ -618,59 +630,72 @@ export function getPlanCardScript(): string {
<div class="plan-summary">\${summaryHtml}</div>
\${hasPhases ? \`<div class="plan-phases">\${phasesHtml}</div>\` : \`<div class="plan-steps">\${stepsHtml}</div>\`}
</div>
<div class="plan-actions">
<div class="question-options" data-ask-id="\${segment.askId}">\${optionsHtml}</div>
<div class="custom-input-container" style="display: \${isAnswered ? 'none' : 'flex'};">
<input type="text" class="custom-input" placeholder="输入修改建议..." />
<button class="custom-submit">提交</button>
<div class="plan-actions" data-ask-id="\${segment.askId}" style="display: \${isAnswered ? 'none' : 'flex'};">
<div class="plan-input-row">
<input type="text" class="plan-input" placeholder="输入修改建议..." />
<button class="plan-btn plan-btn-submit">提交修改</button>
</div>
<div class="plan-btn-row">
<button class="plan-btn plan-btn-confirm">确认执行</button>
<button class="plan-btn plan-btn-cancel">取消</button>
</div>
</div>
\${answeredHtml}
</div>
\`;
// 只在未回答时添加事件监听
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 {
<div class="plan-summary">\${summaryHtml}</div>
\${hasPhases ? \`<div class="plan-phases">\${phasesHtml}</div>\` : \`<div class="plan-steps">\${stepsHtml}</div>\`}
</div>
<div class="plan-actions">
<button class="plan-btn plan-btn-confirm" data-action="confirm">确认执行</button>
<button class="plan-btn plan-btn-modify" data-action="modify">修改计划</button>
<button class="plan-btn plan-btn-cancel" data-action="cancel">取消</button>
<div class="plan-actions" data-ask-id="\${segment.askId}">
<div class="plan-input-row">
<input type="text" class="plan-input" placeholder="输入修改建议..." />
<button class="plan-btn plan-btn-submit">提交修改</button>
</div>
<div class="plan-btn-row">
<button class="plan-btn plan-btn-confirm">确认执行</button>
<button class="plan-btn plan-btn-cancel">取消</button>
</div>
</div>
</div>
\`;
// 绑定按钮事件
// 绑定按钮事件(静态渲染时也需要能响应)
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);
}
`;