Compare commits

...

8 Commits

Author SHA1 Message Date
kurihada 1622872c55 feat(desktop): 集成 ServicesPanel 外部服务配置面板
- 添加 External Services 菜单入口
- 同步窗口权限配置到 capabilities schema
2025-12-30 15:42:17 +08:00
kurihada 44bed99bb4 feat(desktop): 添加悬浮球 Quick Ask 功能
- 实现浅灰色玻璃拟态悬浮球,带机器人头图标
- 支持点击展开对话框,淡入淡出动画
- 支持窗口拖拽,区分点击和拖拽操作
- macOS 透明窗口支持 (macOSPrivateApi)
- 悬浮球 hover 放大效果,不溢出窗口
- 添加系统托盘 Toggle Quick Ask 菜单
2025-12-30 15:35:21 +08:00
kurihada 4108b112f9 feat(ui): 添加外部服务配置面板 (ServicesPanel)
- 新增 ServicesPanel 组件,用于配置 Tavily 等第三方服务的 API Key
- 添加 Services API 客户端方法 (listServices, updateService, deleteService)
- 在工具栏菜单中添加 "External Services" 入口
- 支持 API Key 的配置、启用/禁用和删除
2025-12-30 14:34:32 +08:00
kurihada c3db79c00d feat: 支持 Bun standalone 单文件打包
- core: 工具描述从文件系统加载改为编译时内联生成
- core: 添加 WASM 加载器支持嵌入式 WASM 数据
- core: bash-parser 使用动态导入 web-tree-sitter
- server: 添加静态文件托管支持 (--static 参数)
- server: 新增 standalone 入口点 (嵌入 Web UI + WASM)
- scripts: 添加 build-standalone.ts 构建脚本
- 更新 .gitignore 忽略生成文件
2025-12-30 13:57:29 +08:00
kurihada 5f38753f6d refactor: 清理未使用的类型定义和接口字段
- 移除 Provider 相关的 apiKeyEnvVar 字段(未实现的功能)
- 清理 Server routes 中未使用的 Core 类型导入
- 清理 UI Message 接口中未使用的 metadata 字段
2025-12-30 10:41:38 +08:00
kurihada 243f8dc860 feat(desktop): 同步 web 模块界面和修复会话初始化
- App.tsx: 添加 IDE 面板、StatusBar、Resizer 等组件
- App.tsx: 添加连接错误处理和用户友好提示
- App.tsx: 修复新 project 目录下不自动创建会话的问题
- Chat.tsx: 同步 web 版本的所有 UI 组件和功能
- tailwind.config.js: 添加语义化颜色 (surface, fg, line, code)
- index.css: 精简为仅包含桌面端特有样式
- ThemeProvider 设置 defaultTheme='dark' 修复代码编辑器主题
- web/App.tsx: 同步修复会话初始化逻辑
2025-12-18 17:48:46 +08:00
kurihada 3ff489fbc0 feat(ui): 添加 Token 消耗统计显示
- 状态栏显示当前会话 Token 消耗总量,悬停显示详情
- AI 消息底部显示本次响应的输入/输出 Token
- 会话列表顶部显示项目 Token 消耗总量
- 会话列表每项显示该会话的 Token 消耗
- 新增 Token 统计 API 客户端函数
- Server done 事件携带 usage 信息
2025-12-18 17:01:09 +08:00
kurihada bac32fe8f6 feat(core): 实现 Token 消耗统计系统
- 扩展 SessionStats schema 添加 token 统计字段
- 添加 TokenUsageInfo 类型和 ChatResult.usage 字段
- AgentMessageHandler 从 AI SDK response 提取 usage
- AgentExecutor 返回 usage 到执行结果
- 新增 TokenStatsManager 管理统计:
  - updateSessionStats: 更新会话 token 统计
  - mergeChildSessionStats: 合并子会话统计到父会话
  - getSessionStats/getProjectStats: 查询统计
- Agent.chat() 完成后自动更新统计
- Task 工具完成后合并子会话统计
- 新增 REST API: /api/stats/sessions/:id, /api/stats/projects/:id
- 添加 TokenStatsManager 单元测试 (12 tests)
2025-12-18 16:11:00 +08:00
71 changed files with 3895 additions and 633 deletions
+5
View File
@@ -45,6 +45,11 @@ packages/desktop/src-tauri/Cargo.lock
*.temp *.temp
.cache/ .cache/
# Generated files
packages/core/src/tools/descriptions.generated.ts
packages/server/src/embedded-assets.generated.ts
packages/server/src/embedded-wasm.generated.ts
# AI Open reference code # AI Open reference code
ai-open/ ai-open/
+3 -1
View File
@@ -41,7 +41,9 @@
"website:build": "pnpm --filter @ai-assistant/website build", "website:build": "pnpm --filter @ai-assistant/website build",
"website:dev": "pnpm --filter @ai-assistant/website dev", "website:dev": "pnpm --filter @ai-assistant/website dev",
"website:preview": "pnpm --filter @ai-assistant/website preview" "website:preview": "pnpm --filter @ai-assistant/website preview",
"build:standalone": "bun run scripts/build-standalone.ts"
}, },
"keywords": [ "keywords": [
"ai", "ai",
+2 -1
View File
@@ -40,7 +40,8 @@
} }
}, },
"scripts": { "scripts": {
"build": "tsc && cp -r src/tools/descriptions dist/tools/", "prebuild": "bun run scripts/generate-descriptions.ts",
"build": "tsc",
"dev": "tsc --watch", "dev": "tsc --watch",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
@@ -0,0 +1,89 @@
#!/usr/bin/env bun
/**
* Generate Tool Descriptions
*
* 将 descriptions 目录下的 .txt 文件转换为 TypeScript 模块
* 用于 Bun 打包时内联工具描述
*/
import * as fs from 'fs';
import * as path from 'path';
const DESCRIPTIONS_DIR = path.join(__dirname, '../src/tools/descriptions');
const OUTPUT_FILE = path.join(__dirname, '../src/tools/descriptions.generated.ts');
interface DescriptionEntry {
category: string;
name: string;
content: string;
}
function collectDescriptions(): DescriptionEntry[] {
const entries: DescriptionEntry[] = [];
const categories = fs.readdirSync(DESCRIPTIONS_DIR);
for (const category of categories) {
const categoryPath = path.join(DESCRIPTIONS_DIR, category);
const stat = fs.statSync(categoryPath);
if (stat.isDirectory()) {
const files = fs.readdirSync(categoryPath);
for (const file of files) {
if (file.endsWith('.txt')) {
const name = file.replace('.txt', '');
const content = fs.readFileSync(path.join(categoryPath, file), 'utf-8').trim();
entries.push({ category, name, content });
}
}
} else if (category.endsWith('.txt')) {
const name = category.replace('.txt', '');
const content = fs.readFileSync(categoryPath, 'utf-8').trim();
entries.push({ category: '', name, content });
}
}
return entries;
}
function escapeString(str: string): string {
return str
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$');
}
function generateCode(entries: DescriptionEntry[]): string {
const lines: string[] = [
'// Auto-generated by scripts/generate-descriptions.ts',
'// Do not edit manually',
'',
'export const TOOL_DESCRIPTIONS: Record<string, string> = {',
];
for (const entry of entries) {
const escapedContent = escapeString(entry.content);
lines.push(` '${entry.name}': \`${escapedContent}\`,`);
lines.push('');
}
lines.push('};');
lines.push('');
return lines.join('\n');
}
function main() {
console.log('Collecting tool descriptions...');
const entries = collectDescriptions();
console.log(`Found ${entries.length} descriptions`);
console.log('Generating TypeScript code...');
const code = generateCode(entries);
console.log(`Writing to ${OUTPUT_FILE}...`);
fs.writeFileSync(OUTPUT_FILE, code, 'utf-8');
console.log('Done!');
}
main();
+31 -2
View File
@@ -6,7 +6,7 @@ import {
type Tool as AITool, type Tool as AITool,
type LanguageModel, type LanguageModel,
} from 'ai'; } from 'ai';
import type { Tool, ToolResult, AgentConfig, ContentBlock } from '../types/index.js'; import type { Tool, ToolResult, AgentConfig, ContentBlock, TokenUsageInfo } from '../types/index.js';
import { buildZodSchema } from '../types/index.js'; import { buildZodSchema } from '../types/index.js';
import { ToolRegistry } from '../tools/registry.js'; import { ToolRegistry } from '../tools/registry.js';
import type { import type {
@@ -95,6 +95,7 @@ export class AgentExecutor {
let fullResponse = ''; let fullResponse = '';
let steps = 0; let steps = 0;
let usage: TokenUsageInfo | undefined;
// 工具调用时间追踪(用于计算持续时间) // 工具调用时间追踪(用于计算持续时间)
const toolStartTimes = new Map<string, number>(); const toolStartTimes = new Map<string, number>();
@@ -200,7 +201,9 @@ export class AgentExecutor {
} }
} }
await result.response; const response = await result.response;
// 提取 usage 信息
usage = this.extractUsage(response);
} else { } else {
// 非流式模式 // 非流式模式
const result = await generateText({ const result = await generateText({
@@ -214,6 +217,8 @@ export class AgentExecutor {
fullResponse = result.text; fullResponse = result.text;
steps = result.steps.length; steps = result.steps.length;
// 提取 usage 信息
usage = this.extractUsage(result.response);
} }
return { return {
@@ -221,6 +226,7 @@ export class AgentExecutor {
text: fullResponse, text: fullResponse,
steps, steps,
sessionId: context.parentSessionId ?? 'standalone', sessionId: context.parentSessionId ?? 'standalone',
usage,
}; };
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
@@ -416,4 +422,27 @@ export class AgentExecutor {
return blocks; return blocks;
} }
/**
* 从 AI SDK 响应中提取 usage 信息
*/
private extractUsage(response: unknown): TokenUsageInfo | undefined {
// AI SDK 的 response 对象包含 usage 字段
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resp = response as any;
const usage = resp?.usage;
if (!usage) {
return undefined;
}
return {
promptTokens: usage.promptTokens ?? 0,
completionTokens: usage.completionTokens ?? 0,
totalTokens: usage.totalTokens ?? (usage.promptTokens ?? 0) + (usage.completionTokens ?? 0),
// Anthropic API 特有的缓存字段
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheCreationInputTokens: usage.cacheCreationInputTokens,
};
}
} }
+3 -1
View File
@@ -1,4 +1,4 @@
import type { ProviderType } from '../types/index.js'; import type { ProviderType, TokenUsageInfo } from '../types/index.js';
import type { PermissionAction, PermissionRule } from '../permission/types.js'; import type { PermissionAction, PermissionRule } from '../permission/types.js';
// 重新导出权限类型,方便外部使用 // 重新导出权限类型,方便外部使用
@@ -196,4 +196,6 @@ export interface AgentExecutionResult {
sessionId: string; sessionId: string;
/** 错误信息 */ /** 错误信息 */
error?: string; error?: string;
/** Token 使用统计 */
usage?: TokenUsageInfo;
} }
@@ -11,7 +11,7 @@ import {
type Tool as AITool, type Tool as AITool,
type LanguageModel, type LanguageModel,
} from 'ai'; } from 'ai';
import type { ToolResult, UserInput, ContentBlock, ChatResult } from '../types/index.js'; import type { ToolResult, UserInput, ContentBlock, ChatResult, TokenUsageInfo } from '../types/index.js';
import { import {
CompressionManager, CompressionManager,
CompressionStatus, CompressionStatus,
@@ -188,6 +188,18 @@ export class AgentMessageHandler {
const response = await result.response; const response = await result.response;
responseMessages = response.messages as ModelMessage[]; responseMessages = response.messages as ModelMessage[];
// 提取 usage 信息
const usage = this.extractUsage(response);
// 将完整的响应消息添加到历史
this.conversationHistory.push(...responseMessages);
return {
text: fullResponse,
messages: responseMessages,
usage,
};
} catch (error) { } catch (error) {
if (error instanceof Error && (error.name === 'AbortError' || abortSignal?.aborted)) { if (error instanceof Error && (error.name === 'AbortError' || abortSignal?.aborted)) {
onStream?.('\n[已取消]\n'); onStream?.('\n[已取消]\n');
@@ -201,14 +213,6 @@ export class AgentMessageHandler {
} }
throw error; throw error;
} }
// 将完整的响应消息添加到历史
this.conversationHistory.push(...responseMessages);
return {
text: fullResponse,
messages: responseMessages,
};
} }
/** /**
@@ -320,12 +324,39 @@ export class AgentMessageHandler {
const fullResponse = result.text; const fullResponse = result.text;
const responseMessages = result.response.messages as ModelMessage[]; const responseMessages = result.response.messages as ModelMessage[];
// 提取 usage 信息
const usage = this.extractUsage(result.response);
// 将完整的响应消息添加到历史 // 将完整的响应消息添加到历史
this.conversationHistory.push(...responseMessages); this.conversationHistory.push(...responseMessages);
return { return {
text: fullResponse, text: fullResponse,
messages: responseMessages, messages: responseMessages,
usage,
};
}
/**
* 从 AI SDK 响应中提取 usage 信息
*/
private extractUsage(response: unknown): TokenUsageInfo | undefined {
// AI SDK 的 response 对象包含 usage 字段
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resp = response as any;
const usage = resp?.usage;
if (!usage) {
return undefined;
}
return {
promptTokens: usage.promptTokens ?? 0,
completionTokens: usage.completionTokens ?? 0,
totalTokens: usage.totalTokens ?? (usage.promptTokens ?? 0) + (usage.completionTokens ?? 0),
// Anthropic API 特有的缓存字段
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheCreationInputTokens: usage.cacheCreationInputTokens,
}; };
} }
+14 -2
View File
@@ -4,9 +4,9 @@
*/ */
import type { LanguageModel } from 'ai'; import type { LanguageModel } from 'ai';
import type { Tool, AgentConfig, UserInput, Message, ChatResult } from '../types/index.js'; import type { Tool, AgentConfig, UserInput, Message, ChatResult, TokenUsageInfo } from '../types/index.js';
import { ToolRegistry } from '../tools/registry.js'; import { ToolRegistry } from '../tools/registry.js';
import { SessionManager } from '../session/index.js'; import { SessionManager, tokenStatsManager } from '../session/index.js';
import { import {
CompressionManager, CompressionManager,
type TokenUsage, type TokenUsage,
@@ -256,6 +256,18 @@ export class Agent {
); );
} }
// 更新 Token 统计
if (result.usage && this.sessionManager) {
const session = this.sessionManager.getSession();
if (session) {
await tokenStatsManager.updateSessionStats(
session.projectId,
session.id,
result.usage
);
}
}
// 自动压缩 // 自动压缩
await this.messageHandler.autoCompress(onStream); await this.messageHandler.autoCompress(onStream);
+5 -2
View File
@@ -29,8 +29,8 @@ export type {
DetailedCompressionResult, DetailedCompressionResult,
} from './context/index.js'; } from './context/index.js';
// Session - 新的三层存储结构 // Session - 新的三层存储结构
export { SessionManager } from './session/index.js'; export { SessionManager, tokenStatsManager, TokenStatsManager } from './session/index.js';
export type { SessionData, SessionSummary, ProjectMetadata } from './session/index.js'; export type { SessionData, SessionSummary, ProjectMetadata, SessionTokenStats, ProjectStats } from './session/index.js';
// Session Storage API // Session Storage API
export { export {
@@ -354,6 +354,9 @@ export type {
FileSearchOptions, FileSearchOptions,
} from './file-index/index.js'; } from './file-index/index.js';
// RepoMap WASM 加载器(用于 standalone 构建)
export { setEmbeddedWasm, hasEmbeddedWasm } from './repomap/index.js';
// Constants - 统一存储路径 // Constants - 统一存储路径
export { export {
APP_DIR_NAME, APP_DIR_NAME,
+59 -13
View File
@@ -1,6 +1,6 @@
import { Parser, Language, type Node } from 'web-tree-sitter';
import * as path from 'path'; import * as path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { hasEmbeddedWasm, getEmbeddedWasm } from '../repomap/tags/wasm-loader.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -20,10 +20,22 @@ export interface ParseResult {
error?: string; error?: string;
} }
// 单例解析器 // Tree-sitter 类型
let parserInstance: Parser | null = null; interface TreeSitterNode {
let bashLanguage: Language | null = null; type: string;
text: string;
childCount: number;
child(index: number): TreeSitterNode | null;
}
// 单例解析器 (使用 any 类型避免与 web-tree-sitter 的类型冲突)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let parserInstance: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let bashLanguage: any = null;
let initPromise: Promise<void> | null = null; let initPromise: Promise<void> | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let TreeSitter: any = null;
/** /**
* 获取 wasm 文件路径 * 获取 wasm 文件路径
@@ -55,19 +67,51 @@ async function initParser(): Promise<void> {
initPromise = (async () => { initPromise = (async () => {
try { try {
// 初始化 tree-sitter // 动态导入 web-tree-sitter
await Parser.init({ TreeSitter = await import('web-tree-sitter');
// 检查是否有嵌入的 WASM
if (hasEmbeddedWasm()) {
const wasmData = getEmbeddedWasm('tree-sitter.wasm');
if (wasmData) {
// 使用嵌入的 WASM 数据初始化
await TreeSitter.Parser.init({
wasmBinary: wasmData,
});
} else {
// 回退到文件系统
await TreeSitter.Parser.init({
locateFile: (scriptName: string) => { locateFile: (scriptName: string) => {
return getWasmPath(scriptName); return getWasmPath(scriptName);
}, },
}); });
}
} else {
// 使用文件系统加载
await TreeSitter.Parser.init({
locateFile: (scriptName: string) => {
return getWasmPath(scriptName);
},
});
}
// 创建解析器实例 // 创建解析器实例
parserInstance = new Parser(); parserInstance = new TreeSitter.Parser();
// 加载 bash 语言 // 加载 bash 语言
if (hasEmbeddedWasm()) {
const bashWasmData = getEmbeddedWasm('tree-sitter-bash.wasm');
if (bashWasmData) {
bashLanguage = await TreeSitter.Parser.Language.load(bashWasmData);
} else {
const bashWasmPath = getWasmPath('tree-sitter-bash.wasm'); const bashWasmPath = getWasmPath('tree-sitter-bash.wasm');
bashLanguage = await Language.load(bashWasmPath); bashLanguage = await TreeSitter.Parser.Language.load(bashWasmPath);
}
} else {
const bashWasmPath = getWasmPath('tree-sitter-bash.wasm');
bashLanguage = await TreeSitter.Parser.Language.load(bashWasmPath);
}
parserInstance.setLanguage(bashLanguage); parserInstance.setLanguage(bashLanguage);
} catch (error) { } catch (error) {
initPromise = null; initPromise = null;
@@ -81,7 +125,7 @@ async function initParser(): Promise<void> {
/** /**
* 从语法树节点中提取命令信息 * 从语法树节点中提取命令信息
*/ */
function extractCommandFromNode(node: Node): ParsedCommand { function extractCommandFromNode(node: TreeSitterNode): ParsedCommand {
const parts: string[] = []; const parts: string[] = [];
for (let i = 0; i < node.childCount; i++) { for (let i = 0; i < node.childCount; i++) {
@@ -101,8 +145,10 @@ function extractCommandFromNode(node: Node): ParsedCommand {
// 对于字符串类型,提取内部文本(去掉引号) // 对于字符串类型,提取内部文本(去掉引号)
if (child.type === 'string' || child.type === 'raw_string') { if (child.type === 'string' || child.type === 'raw_string') {
const text = child.text; const text = child.text;
if ((text.startsWith('"') && text.endsWith('"')) || if (
(text.startsWith("'") && text.endsWith("'"))) { (text.startsWith('"') && text.endsWith('"')) ||
(text.startsWith("'") && text.endsWith("'"))
) {
parts.push(text.slice(1, -1)); parts.push(text.slice(1, -1));
} else { } else {
parts.push(text); parts.push(text);
@@ -138,8 +184,8 @@ function extractCommandFromNode(node: Node): ParsedCommand {
/** /**
* 递归查找所有命令节点 * 递归查找所有命令节点
*/ */
function findCommandNodes(node: Node): Node[] { function findCommandNodes(node: TreeSitterNode): TreeSitterNode[] {
const commands: Node[] = []; const commands: TreeSitterNode[] = [];
if (node.type === 'command') { if (node.type === 'command') {
commands.push(node); commands.push(node);
@@ -10,7 +10,6 @@ export const anthropicProvider: ProviderInfo = {
name: 'Anthropic', name: 'Anthropic',
description: 'Claude AI models by Anthropic', description: 'Claude AI models by Anthropic',
builtin: true, builtin: true,
apiKeyEnvVar: 'ANTHROPIC_API_KEY',
models: [ models: [
{ {
id: 'claude-sonnet-4-20250514', id: 'claude-sonnet-4-20250514',
@@ -10,7 +10,6 @@ export const deepseekProvider: ProviderInfo = {
name: 'DeepSeek', name: 'DeepSeek',
description: 'DeepSeek AI models', description: 'DeepSeek AI models',
builtin: true, builtin: true,
apiKeyEnvVar: 'DEEPSEEK_API_KEY',
models: [ models: [
{ {
id: 'deepseek-chat', id: 'deepseek-chat',
@@ -11,7 +11,6 @@ export const openaiProvider: ProviderInfo = {
name: 'OpenAI', name: 'OpenAI',
description: 'GPT models by OpenAI (also supports OpenAI-compatible APIs)', description: 'GPT models by OpenAI (also supports OpenAI-compatible APIs)',
builtin: true, builtin: true,
apiKeyEnvVar: 'OPENAI_API_KEY',
models: [ models: [
{ {
id: 'gpt-4o', id: 'gpt-4o',
-2
View File
@@ -168,7 +168,6 @@ export class ProviderRegistry {
description: provider.info.description, description: provider.info.description,
builtin: provider.info.builtin, builtin: provider.info.builtin,
baseUrl: config?.baseUrl ?? provider.info.baseUrl, baseUrl: config?.baseUrl ?? provider.info.baseUrl,
apiKeyEnvVar: provider.info.apiKeyEnvVar,
models: provider.info.models, models: provider.info.models,
allowCustomModels: provider.info.allowCustomModels ?? false, allowCustomModels: provider.info.allowCustomModels ?? false,
config: { config: {
@@ -200,7 +199,6 @@ export class ProviderRegistry {
description: definition.description, description: definition.description,
builtin: false, builtin: false,
baseUrl: definition.baseUrl, baseUrl: definition.baseUrl,
apiKeyEnvVar: definition.apiKeyEnvVar,
models: definition.models ?? [], models: definition.models ?? [],
allowCustomModels: definition.allowCustomModels ?? true, allowCustomModels: definition.allowCustomModels ?? true,
}; };
+1 -8
View File
@@ -48,8 +48,6 @@ export interface ProviderInfo {
builtin: boolean; builtin: boolean;
/** API 基础 URL */ /** API 基础 URL */
baseUrl?: string; baseUrl?: string;
/** API Key 环境变量名 */
apiKeyEnvVar?: string;
/** 可用模型列表 */ /** 可用模型列表 */
models: ModelInfo[]; models: ModelInfo[];
/** 是否允许自定义模型 */ /** 是否允许自定义模型 */
@@ -60,10 +58,8 @@ export interface ProviderInfo {
export interface ProviderConfig { export interface ProviderConfig {
/** 提供商 ID */ /** 提供商 ID */
id: string; id: string;
/** API Key(直接存储,不推荐) */ /** API Key */
apiKey?: string; apiKey?: string;
/** API Key 环境变量名 */
apiKeyEnvVar?: string;
/** 自定义 Base URL */ /** 自定义 Base URL */
baseUrl?: string; baseUrl?: string;
/** 是否启用 */ /** 是否启用 */
@@ -82,8 +78,6 @@ export interface CustomProviderDefinition {
description?: string; description?: string;
/** API 基础 URL(必填) */ /** API 基础 URL(必填) */
baseUrl: string; baseUrl: string;
/** API Key 环境变量名 */
apiKeyEnvVar?: string;
/** 可用模型列表 */ /** 可用模型列表 */
models?: ModelInfo[]; models?: ModelInfo[];
/** 是否允许自定义模型 */ /** 是否允许自定义模型 */
@@ -157,7 +151,6 @@ export interface ProviderDetail {
description?: string; description?: string;
builtin: boolean; builtin: boolean;
baseUrl?: string; baseUrl?: string;
apiKeyEnvVar?: string;
models: ModelInfo[]; models: ModelInfo[];
allowCustomModels: boolean; allowCustomModels: boolean;
config?: { config?: {
+3
View File
@@ -11,6 +11,9 @@ export { RepoMap, createRepoMap } from './repomap.js';
// Tag 提取 // Tag 提取
export { TagExtractor } from './tags/index.js'; export { TagExtractor } from './tags/index.js';
// WASM 加载器(用于 standalone 构建)
export { setEmbeddedWasm, hasEmbeddedWasm } from './tags/wasm-loader.js';
// PageRank 排序 // PageRank 排序
export { export {
Graph, Graph,
+32 -2
View File
@@ -8,6 +8,7 @@ import * as path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import type { Tag } from '../types.js'; import type { Tag } from '../types.js';
import { getLanguageFromFilename } from '../types.js'; import { getLanguageFromFilename } from '../types.js';
import { hasEmbeddedWasm, getEmbeddedWasm } from './wasm-loader.js';
// Tree-sitter 类型(web-tree-sitter // Tree-sitter 类型(web-tree-sitter
interface TreeSitterParser { interface TreeSitterParser {
@@ -56,8 +57,25 @@ async function initTreeSitter(): Promise<void> {
try { try {
const TreeSitter = await import('web-tree-sitter'); const TreeSitter = await import('web-tree-sitter');
// Parser 是命名导出的类,init 是其静态方法
// 检查是否有嵌入的 WASM
if (hasEmbeddedWasm()) {
const wasmData = getEmbeddedWasm('tree-sitter.wasm');
if (wasmData) {
// 使用嵌入的 WASM 数据直接初始化
// web-tree-sitter 支持传入 WebAssembly.Module 或 ArrayBuffer
await TreeSitter.Parser.init({
// 传入 WASM 二进制数据
wasmBinary: wasmData,
});
} else {
await TreeSitter.Parser.init(); await TreeSitter.Parser.init();
}
} else {
// 使用默认行为(从 node_modules 加载)
await TreeSitter.Parser.init();
}
ParserClass = TreeSitter.Parser; ParserClass = TreeSitter.Parser;
treeSitterInitialized = true; treeSitterInitialized = true;
} catch (error) { } catch (error) {
@@ -215,7 +233,19 @@ export class TagExtractor {
} }
try { try {
// 尝试加载 WASM 语言文件 // 首先检查是否有嵌入的语言 WASM
if (hasEmbeddedWasm()) {
const wasmName = `tree-sitter-${lang}.wasm`;
const wasmData = getEmbeddedWasm(wasmName);
if (wasmData) {
// 使用嵌入的 WASM 数据加载语言
const language = await ParserClass.Language.load(wasmData);
this.languages.set(lang, language);
return language;
}
}
// 回退到从文件系统加载
const wasmPath = this.getWasmPath(lang); const wasmPath = this.getWasmPath(lang);
const language = await ParserClass.Language.load(wasmPath); const language = await ParserClass.Language.load(wasmPath);
this.languages.set(lang, language); this.languages.set(lang, language);
@@ -0,0 +1,52 @@
/**
* WASM Loader for Tree-sitter
*
* 支持从文件系统或嵌入的 base64 数据加载 WASM
*/
// 嵌入的 WASM 数据(构建时生成)
let embeddedWasm: Map<string, string> | null = null;
/**
* 设置嵌入的 WASM 数据
*/
export function setEmbeddedWasm(wasm: Map<string, string>): void {
embeddedWasm = wasm;
}
/**
* 检查是否有嵌入的 WASM
*/
export function hasEmbeddedWasm(): boolean {
return embeddedWasm !== null && embeddedWasm.size > 0;
}
/**
* 获取嵌入的 WASM 数据
*/
export function getEmbeddedWasm(name: string): Uint8Array | null {
if (!embeddedWasm) return null;
const base64 = embeddedWasm.get(name);
if (!base64) return null;
// 解码 base64
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
/**
* 获取 tree-sitter.wasm 的路径或数据
*/
export function getTreeSitterWasmPath(): string | undefined {
// 如果没有嵌入数据,返回 undefined 让默认行为生效
if (!hasEmbeddedWasm()) {
return undefined;
}
// 返回一个特殊标记,在 init 时会被拦截
return '__embedded__';
}
+7
View File
@@ -77,3 +77,10 @@ export type { SessionData, SessionSummary, ProjectMetadata } from './manager.js'
// 项目工具 // 项目工具
export { getProjectId, isGitRepository } from './project.js'; export { getProjectId, isGitRepository } from './project.js';
// Token 统计管理器
export { TokenStatsManager, tokenStatsManager } from './token-stats-manager.js';
export type { SessionTokenStats } from './token-stats-manager.js';
// 项目管理器类型
export type { ProjectStats } from './project-manager.js';
@@ -6,6 +6,17 @@
import * as storage from './storage/index.js'; import * as storage from './storage/index.js';
import { getProjectId, isGitRepository } from './project.js'; import { getProjectId, isGitRepository } from './project.js';
/**
* 项目 Token 统计
*/
export interface ProjectStats {
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
sessionCount: number;
updatedAt: number;
}
/** /**
* 项目元数据 * 项目元数据
*/ */
@@ -14,6 +25,7 @@ export interface ProjectMetadata {
workdir: string; workdir: string;
createdAt: string; createdAt: string;
isGitRepo: boolean; isGitRepo: boolean;
stats?: ProjectStats;
} }
/** /**
@@ -113,6 +113,8 @@ export class SessionStore {
messageCount: session.messages.length, messageCount: session.messages.length,
inputTokens: 0, inputTokens: 0,
outputTokens: 0, outputTokens: 0,
totalTokens: 0,
updatedAt: Date.now(),
}, },
}; };
+2 -2
View File
@@ -17,8 +17,8 @@ export {
// Session storage // Session storage
export * as SessionStorage from './session.js'; export * as SessionStorage from './session.js';
export type { SessionInfo } from './session.js'; export type { SessionInfo, SessionStats } from './session.js';
export { SessionInfoSchema } from './session.js'; export { SessionInfoSchema, SessionStatsSchema, ChildSessionsTokensSchema } from './session.js';
// Message storage // Message storage
export * as MessageStorage from './message.js'; export * as MessageStorage from './message.js';
+28 -7
View File
@@ -2,6 +2,33 @@ import { z } from 'zod';
import * as base from './base.js'; import * as base from './base.js';
import { generateSessionId } from '../id.js'; import { generateSessionId } from '../id.js';
/**
* 子会话 Token 统计 Schema
*/
export const ChildSessionsTokensSchema = z.object({
inputTokens: z.number(),
outputTokens: z.number(),
totalTokens: z.number(),
});
/**
* Session 统计信息 Schema
*/
export const SessionStatsSchema = z.object({
messageCount: z.number(),
inputTokens: z.number(),
outputTokens: z.number(),
totalTokens: z.number(),
// 缓存相关(Anthropic API 支持)
cacheReadTokens: z.number().optional(),
cacheWriteTokens: z.number().optional(),
// 子会话统计(合并所有子会话的 token 消耗)
childSessionsTokens: ChildSessionsTokensSchema.optional(),
// 统计最后更新时间
updatedAt: z.number(),
});
export type SessionStats = z.infer<typeof SessionStatsSchema>;
/** /**
* Session Info Schema * Session Info Schema
*/ */
@@ -16,13 +43,7 @@ export const SessionInfoSchema = z.object({
title: z.string().optional(), title: z.string().optional(),
discoveredTools: z.array(z.string()).default([]), discoveredTools: z.array(z.string()).default([]),
// 统计信息 // 统计信息
stats: z stats: SessionStatsSchema.optional(),
.object({
messageCount: z.number(),
inputTokens: z.number(),
outputTokens: z.number(),
})
.optional(),
}); });
export type SessionInfo = z.infer<typeof SessionInfoSchema>; export type SessionInfo = z.infer<typeof SessionInfoSchema>;
@@ -0,0 +1,287 @@
/**
* Token 统计管理器
* 负责 Session 和 Project 级别的 token 消耗统计
*/
import * as SessionStorage from './storage/session.js';
import * as storage from './storage/index.js';
import type { SessionStats } from './storage/session.js';
import type { ProjectMetadata, ProjectStats } from './project-manager.js';
import type { TokenUsageInfo } from '../types/index.js';
/**
* Session Token 统计详情
*/
export interface SessionTokenStats {
/** 本会话自身的统计 */
self: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
};
/** 子会话统计汇总 */
children: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
/** 总计(自身 + 子会话) */
total: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
};
/** 消息数量 */
messageCount: number;
/** 最后更新时间 */
updatedAt: number;
}
/**
* Token 统计管理器
*/
export class TokenStatsManager {
/**
* 更新 Session 级别统计
* 将新的 token 使用累加到现有统计中
*/
async updateSessionStats(
projectId: string,
sessionId: string,
usage: TokenUsageInfo
): Promise<void> {
const session = await SessionStorage.get(projectId, sessionId);
if (!session) {
return;
}
const currentStats: SessionStats = session.stats || {
messageCount: 0,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
updatedAt: Date.now(),
};
// 累加新的 token 使用
const newStats: SessionStats = {
messageCount: currentStats.messageCount + 1,
inputTokens: currentStats.inputTokens + usage.promptTokens,
outputTokens: currentStats.outputTokens + usage.completionTokens,
totalTokens: currentStats.totalTokens + usage.totalTokens,
cacheReadTokens: (currentStats.cacheReadTokens || 0) + (usage.cacheReadInputTokens || 0),
cacheWriteTokens: (currentStats.cacheWriteTokens || 0) + (usage.cacheCreationInputTokens || 0),
childSessionsTokens: currentStats.childSessionsTokens,
updatedAt: Date.now(),
};
await SessionStorage.updateStats(projectId, sessionId, newStats);
}
/**
* 合并子会话统计到父会话
* 在子会话(Task 工具)完成后调用
*/
async mergeChildSessionStats(
projectId: string,
parentSessionId: string,
childSessionId: string
): Promise<void> {
const parent = await SessionStorage.get(projectId, parentSessionId);
const child = await SessionStorage.get(projectId, childSessionId);
if (!parent || !child || !child.stats) {
return;
}
const parentStats: SessionStats = parent.stats || {
messageCount: 0,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
updatedAt: Date.now(),
};
// 获取当前子会话累计
const childTokens = parentStats.childSessionsTokens || {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
};
// 累加子会话的 token(包括子会话自身和其嵌套的子会话)
const childTotal = child.stats.inputTokens + (child.stats.childSessionsTokens?.inputTokens || 0);
const childOutputTotal = child.stats.outputTokens + (child.stats.childSessionsTokens?.outputTokens || 0);
const childTotalTokens = child.stats.totalTokens + (child.stats.childSessionsTokens?.totalTokens || 0);
parentStats.childSessionsTokens = {
inputTokens: childTokens.inputTokens + childTotal,
outputTokens: childTokens.outputTokens + childOutputTotal,
totalTokens: childTokens.totalTokens + childTotalTokens,
};
parentStats.updatedAt = Date.now();
await SessionStorage.updateStats(projectId, parentSessionId, parentStats);
}
/**
* 获取 Session 完整统计
* 包含自身统计、子会话统计和总计
*/
async getSessionStats(
projectId: string,
sessionId: string
): Promise<SessionTokenStats | null> {
const session = await SessionStorage.get(projectId, sessionId);
if (!session || !session.stats) {
return null;
}
const stats = session.stats;
const childStats = stats.childSessionsTokens || {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
};
return {
self: {
inputTokens: stats.inputTokens,
outputTokens: stats.outputTokens,
totalTokens: stats.totalTokens,
cacheReadTokens: stats.cacheReadTokens,
cacheWriteTokens: stats.cacheWriteTokens,
},
children: {
inputTokens: childStats.inputTokens,
outputTokens: childStats.outputTokens,
totalTokens: childStats.totalTokens,
},
total: {
inputTokens: stats.inputTokens + childStats.inputTokens,
outputTokens: stats.outputTokens + childStats.outputTokens,
totalTokens: stats.totalTokens + childStats.totalTokens,
},
messageCount: stats.messageCount,
updatedAt: stats.updatedAt,
};
}
/**
* 更新 Project 级别统计
* 遍历项目下所有会话并汇总 token 消耗
*/
async updateProjectStats(projectId: string): Promise<ProjectStats> {
const sessions = await SessionStorage.listByProject(projectId);
let totalInput = 0;
let totalOutput = 0;
let totalTokens = 0;
for (const session of sessions) {
// 跳过子会话(避免重复计算)
if (session.parentId) {
continue;
}
if (session.stats) {
totalInput += session.stats.inputTokens;
totalOutput += session.stats.outputTokens;
totalTokens += session.stats.totalTokens;
// 包含子会话统计
if (session.stats.childSessionsTokens) {
totalInput += session.stats.childSessionsTokens.inputTokens;
totalOutput += session.stats.childSessionsTokens.outputTokens;
totalTokens += session.stats.childSessionsTokens.totalTokens;
}
}
}
const projectStats: ProjectStats = {
totalInputTokens: totalInput,
totalOutputTokens: totalOutput,
totalTokens,
sessionCount: sessions.filter(s => !s.parentId).length,
updatedAt: Date.now(),
};
// 更新项目元数据
try {
await storage.update(['project', projectId], (project: ProjectMetadata) => {
project.stats = projectStats;
});
} catch {
// 项目可能不存在,忽略错误
}
return projectStats;
}
/**
* 获取 Project 统计
* 如果缓存的统计过期,会重新计算
*/
async getProjectStats(
projectId: string,
forceRefresh = false
): Promise<ProjectStats | null> {
try {
const project = await storage.read<ProjectMetadata>(['project', projectId]);
// 如果需要强制刷新,或者没有缓存的统计
if (forceRefresh || !project.stats) {
return await this.updateProjectStats(projectId);
}
return project.stats;
} catch (e) {
if (e instanceof storage.StorageNotFoundError) {
return null;
}
throw e;
}
}
/**
* 格式化 token 数量显示
*/
formatTokens(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(2)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return `${tokens}`;
}
/**
* 格式化 Session 统计为可读字符串
*/
formatSessionStats(stats: SessionTokenStats): string {
const lines = [
`Token 使用统计:`,
` 输入: ${this.formatTokens(stats.total.inputTokens)}`,
` 输出: ${this.formatTokens(stats.total.outputTokens)}`,
` 总计: ${this.formatTokens(stats.total.totalTokens)}`,
];
if (stats.children.totalTokens > 0) {
lines.push(` (其中子任务: ${this.formatTokens(stats.children.totalTokens)})`);
}
if (stats.self.cacheReadTokens) {
lines.push(` 缓存读取: ${this.formatTokens(stats.self.cacheReadTokens)}`);
}
lines.push(` 消息数: ${stats.messageCount}`);
return lines.join('\n');
}
}
// 导出单例
export const tokenStatsManager = new TokenStatsManager();
+11 -59
View File
@@ -1,64 +1,16 @@
import * as fs from 'fs'; /**
import * as path from 'path'; * Tool Description Loader
import { fileURLToPath } from 'url'; *
* 从内联的 descriptions.generated.ts 加载工具描述
* 支持 Bun 单文件打包
*/
const __filename = fileURLToPath(import.meta.url); import { TOOL_DESCRIPTIONS } from './descriptions.generated.js';
const __dirname = path.dirname(__filename);
// 工具名到子目录的映射
const TOOL_CATEGORY_MAP: Record<string, string> = {
// shell
bash: 'shell',
kill_shell: 'shell',
// filesystem
read_file: 'filesystem',
write_file: 'filesystem',
edit_file: 'filesystem',
multi_edit: 'filesystem',
glob: 'filesystem',
grep: 'filesystem',
// web
web_search: 'web',
web_extract: 'web',
// git
git_status: 'git',
git_diff: 'git',
git_log: 'git',
git_branch: 'git',
git_add: 'git',
git_commit: 'git',
git_push: 'git',
git_pull: 'git',
git_checkout: 'git',
git_stash: 'git',
// todo
todo_write: 'todo',
// plan
ask_user_question: 'plan',
enter_plan_mode: 'plan',
exit_plan_mode: 'plan',
// task
task: 'task',
task_output: 'task',
// checkpoint
checkpoint_create: 'checkpoint',
checkpoint_list: 'checkpoint',
checkpoint_diff: 'checkpoint',
checkpoint_restore: 'checkpoint',
undo: 'checkpoint',
// repomap
repo_map: 'repomap',
};
export function loadDescription(toolName: string): string { export function loadDescription(toolName: string): string {
const category = TOOL_CATEGORY_MAP[toolName]; const description = TOOL_DESCRIPTIONS[toolName];
const filePath = category if (!description) {
? path.join(__dirname, 'descriptions', category, `${toolName}.txt`) throw new Error(`工具描述未找到: ${toolName}`);
: path.join(__dirname, 'descriptions', `${toolName}.txt`);
try {
return fs.readFileSync(filePath, 'utf-8').trim();
} catch {
throw new Error(`无法加载工具描述文件: ${filePath}`);
} }
return description;
} }
+17 -1
View File
@@ -4,7 +4,7 @@ import type { AgentConfig, ToolResult } from '../../types/index.js';
import type { ImageData } from '../../agent/types.js'; import type { ImageData } from '../../agent/types.js';
import { agentRegistry, AgentExecutor, agentEventEmitter } from '../../agent/index.js'; import { agentRegistry, AgentExecutor, agentEventEmitter } from '../../agent/index.js';
import { toolRegistry } from '../registry.js'; import { toolRegistry } from '../registry.js';
import { SessionManager } from '../../session/index.js'; import { SessionManager, tokenStatsManager } from '../../session/index.js';
import { getAgentManager } from '../../agent/manager.js'; import { getAgentManager } from '../../agent/manager.js';
import { loadVisionConfig } from '../../utils/config.js'; import { loadVisionConfig } from '../../utils/config.js';
import { loadDescription } from '../load_description.js'; import { loadDescription } from '../load_description.js';
@@ -317,6 +317,22 @@ async function executeTask(params: TaskParams): Promise<ToolResult> {
]; ];
await sessionManager.saveChildSession(childSession); await sessionManager.saveChildSession(childSession);
// 更新子会话的 Token 统计
const session = sessionManager.getSession();
if (result.usage && session) {
await tokenStatsManager.updateSessionStats(
session.projectId,
childSession.id,
result.usage
);
// 合并子会话统计到父会话
await tokenStatsManager.mergeChildSessionStats(
session.projectId,
parentSessionId,
childSession.id
);
}
if (result.success) { if (result.success) {
return { return {
success: true, success: true,
+18
View File
@@ -96,12 +96,30 @@ export interface ConversationContext {
workingDirectory: string; workingDirectory: string;
} }
/**
* Token 使用统计信息
*/
export interface TokenUsageInfo {
/** 输入 tokensprompt */
promptTokens: number;
/** 输出 tokenscompletion */
completionTokens: number;
/** 总 tokens */
totalTokens: number;
/** 缓存读取的输入 tokensAnthropic API */
cacheReadInputTokens?: number;
/** 缓存创建的输入 tokensAnthropic API */
cacheCreationInputTokens?: number;
}
// Chat 返回结果(包含完整的消息链) // Chat 返回结果(包含完整的消息链)
export interface ChatResult { export interface ChatResult {
/** 最终文本响应 */ /** 最终文本响应 */
text: string; text: string;
/** 完整的响应消息链(包含 tool-call 和 tool-result */ /** 完整的响应消息链(包含 tool-call 和 tool-result */
messages: unknown[]; messages: unknown[];
/** Token 使用统计(从 AI SDK response.usage 提取) */
usage?: TokenUsageInfo;
} }
// 将自定义 Tool 转换为 Vercel AI SDK 的 zod schema // 将自定义 Tool 转换为 Vercel AI SDK 的 zod schema
@@ -0,0 +1,372 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock storage functions before imports
vi.mock('../../../src/session/storage/session.js', () => ({
get: vi.fn(),
update: vi.fn(),
updateStats: vi.fn(),
list: vi.fn(),
listByProject: vi.fn(),
}));
vi.mock('../../../src/session/storage/index.js', () => ({
read: vi.fn(),
update: vi.fn(),
StorageNotFoundError: class StorageNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'StorageNotFoundError';
}
},
}));
import * as SessionStorage from '../../../src/session/storage/session.js';
import { TokenStatsManager } from '../../../src/session/token-stats-manager.js';
import type { TokenUsageInfo } from '../../../src/types/index.js';
describe('TokenStatsManager', () => {
let manager: TokenStatsManager;
beforeEach(() => {
vi.clearAllMocks();
manager = new TokenStatsManager();
});
describe('updateSessionStats', () => {
it('会话不存在时不更新', async () => {
const usage: TokenUsageInfo = {
promptTokens: 1000,
completionTokens: 500,
totalTokens: 1500,
};
vi.mocked(SessionStorage.get).mockResolvedValue(null);
await manager.updateSessionStats('project-1', 'session-1', usage);
expect(SessionStorage.get).toHaveBeenCalledWith('project-1', 'session-1');
expect(SessionStorage.updateStats).not.toHaveBeenCalled();
});
it('更新会话统计 - 已存在的会话', async () => {
const usage: TokenUsageInfo = {
promptTokens: 1000,
completionTokens: 500,
totalTokens: 1500,
};
vi.mocked(SessionStorage.get).mockResolvedValue({
id: 'session-1',
projectId: 'project-1',
createdAt: Date.now(),
updatedAt: Date.now(),
workdir: '/test',
stats: {
messageCount: 5,
inputTokens: 2000,
outputTokens: 1000,
totalTokens: 3000,
updatedAt: Date.now() - 1000,
},
});
await manager.updateSessionStats('project-1', 'session-1', usage);
expect(SessionStorage.updateStats).toHaveBeenCalledWith(
'project-1',
'session-1',
expect.objectContaining({
messageCount: 6, // 5 + 1
inputTokens: 3000, // 2000 + 1000
outputTokens: 1500, // 1000 + 500
totalTokens: 4500, // 3000 + 1500
})
);
});
it('处理缓存 token', async () => {
const usage: TokenUsageInfo = {
promptTokens: 1000,
completionTokens: 500,
totalTokens: 1500,
cacheReadInputTokens: 200,
cacheCreationInputTokens: 100,
};
vi.mocked(SessionStorage.get).mockResolvedValue({
id: 'session-1',
projectId: 'project-1',
createdAt: Date.now(),
updatedAt: Date.now(),
workdir: '/test',
stats: {
messageCount: 0,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
updatedAt: Date.now(),
},
});
await manager.updateSessionStats('project-1', 'session-1', usage);
expect(SessionStorage.updateStats).toHaveBeenCalledWith(
'project-1',
'session-1',
expect.objectContaining({
cacheReadTokens: 200,
cacheWriteTokens: 100,
})
);
});
});
describe('mergeChildSessionStats', () => {
it('合并子会话统计到父会话', async () => {
vi.mocked(SessionStorage.get).mockImplementation(async (projectId: string, sessionId: string) => {
if (sessionId === 'child-session') {
return {
id: 'child-session',
projectId,
createdAt: Date.now(),
updatedAt: Date.now(),
workdir: '/test',
stats: {
messageCount: 3,
inputTokens: 500,
outputTokens: 300,
totalTokens: 800,
updatedAt: Date.now(),
},
};
}
if (sessionId === 'parent-session') {
return {
id: 'parent-session',
projectId,
createdAt: Date.now(),
updatedAt: Date.now(),
workdir: '/test',
stats: {
messageCount: 10,
inputTokens: 2000,
outputTokens: 1000,
totalTokens: 3000,
updatedAt: Date.now(),
},
};
}
return null;
});
await manager.mergeChildSessionStats('project-1', 'parent-session', 'child-session');
expect(SessionStorage.updateStats).toHaveBeenCalledWith(
'project-1',
'parent-session',
expect.objectContaining({
childSessionsTokens: expect.objectContaining({
inputTokens: 500,
outputTokens: 300,
totalTokens: 800,
}),
})
);
});
it('累加子会话统计', async () => {
vi.mocked(SessionStorage.get).mockImplementation(async (projectId: string, sessionId: string) => {
if (sessionId === 'child-session-2') {
return {
id: 'child-session-2',
projectId,
createdAt: Date.now(),
updatedAt: Date.now(),
workdir: '/test',
stats: {
messageCount: 2,
inputTokens: 300,
outputTokens: 200,
totalTokens: 500,
updatedAt: Date.now(),
},
};
}
if (sessionId === 'parent-session') {
return {
id: 'parent-session',
projectId,
createdAt: Date.now(),
updatedAt: Date.now(),
workdir: '/test',
stats: {
messageCount: 10,
inputTokens: 2000,
outputTokens: 1000,
totalTokens: 3000,
childSessionsTokens: {
inputTokens: 500,
outputTokens: 300,
totalTokens: 800,
},
updatedAt: Date.now(),
},
};
}
return null;
});
await manager.mergeChildSessionStats('project-1', 'parent-session', 'child-session-2');
expect(SessionStorage.updateStats).toHaveBeenCalledWith(
'project-1',
'parent-session',
expect.objectContaining({
childSessionsTokens: expect.objectContaining({
inputTokens: 800, // 500 + 300
outputTokens: 500, // 300 + 200
totalTokens: 1300, // 800 + 500
}),
})
);
});
});
describe('getSessionStats', () => {
it('返回完整的会话统计', async () => {
const now = Date.now();
vi.mocked(SessionStorage.get).mockResolvedValue({
id: 'session-1',
projectId: 'project-1',
createdAt: now,
updatedAt: now,
workdir: '/test',
stats: {
messageCount: 10,
inputTokens: 5000,
outputTokens: 2500,
totalTokens: 7500,
cacheReadTokens: 1000,
cacheWriteTokens: 500,
childSessionsTokens: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
},
updatedAt: now,
},
});
const stats = await manager.getSessionStats('project-1', 'session-1');
expect(stats).toMatchObject({
self: {
inputTokens: 5000,
outputTokens: 2500,
totalTokens: 7500,
cacheReadTokens: 1000,
cacheWriteTokens: 500,
},
children: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
},
total: {
inputTokens: 6000, // 5000 + 1000
outputTokens: 3000, // 2500 + 500
totalTokens: 9000, // 7500 + 1500
},
messageCount: 10,
});
});
it('会话不存在时返回 null', async () => {
vi.mocked(SessionStorage.get).mockResolvedValue(null);
const stats = await manager.getSessionStats('project-1', 'nonexistent');
expect(stats).toBeNull();
});
it('没有子会话统计时返回空的 children', async () => {
vi.mocked(SessionStorage.get).mockResolvedValue({
id: 'session-1',
projectId: 'project-1',
createdAt: Date.now(),
updatedAt: Date.now(),
workdir: '/test',
stats: {
messageCount: 5,
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
updatedAt: Date.now(),
},
});
const stats = await manager.getSessionStats('project-1', 'session-1');
expect(stats?.children).toEqual({
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
});
expect(stats?.total).toEqual({
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
});
});
});
describe('formatTokens', () => {
it('格式化小数字', () => {
expect(manager.formatTokens(100)).toBe('100');
expect(manager.formatTokens(999)).toBe('999');
});
it('格式化千级数字', () => {
expect(manager.formatTokens(1000)).toBe('1.0k');
expect(manager.formatTokens(1500)).toBe('1.5k');
expect(manager.formatTokens(9999)).toBe('10.0k');
});
it('格式化百万级数字', () => {
expect(manager.formatTokens(1000000)).toBe('1.00M');
expect(manager.formatTokens(1500000)).toBe('1.50M');
});
});
describe('formatSessionStats', () => {
it('格式化会话统计为人类可读字符串', () => {
const stats = {
self: {
inputTokens: 5000,
outputTokens: 2500,
totalTokens: 7500,
},
children: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
},
total: {
inputTokens: 6000,
outputTokens: 3000,
totalTokens: 9000,
},
messageCount: 10,
updatedAt: Date.now(),
};
const formatted = manager.formatSessionStats(stats);
expect(formatted).toContain('6.0k'); // total input
expect(formatted).toContain('3.0k'); // total output
expect(formatted).toContain('9.0k'); // total
expect(formatted).toContain('1.5k'); // children
expect(formatted).toContain('10'); // message count
});
});
});
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quick Ask</title>
<style>
/* 透明背景 */
html, body {
margin: 0;
padding: 0;
background: transparent;
overflow: hidden;
}
#root {
width: 100%;
height: 100vh;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/floating.tsx"></script>
</body>
</html>
+1 -1
View File
@@ -14,7 +14,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = ["tray-icon"] } tauri = { version = "2", features = ["macos-private-api", "tray-icon"] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-fs = "2" tauri-plugin-fs = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
@@ -5,6 +5,15 @@
"windows": ["*"], "windows": ["*"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-close",
"core:window:allow-set-focus",
"core:window:allow-set-position",
"core:window:allow-set-size",
"core:window:allow-start-dragging",
"core:window:allow-is-visible",
"core:webview:allow-create-webview-window",
"shell:default", "shell:default",
"fs:default", "fs:default",
"dialog:default", "dialog:default",
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for the application","local":true,"windows":["*"],"permissions":["core:default","shell:default","fs:default","dialog:default",{"identifier":"http:default","allow":[{"url":"http://localhost:*/*"},{"url":"http://127.0.0.1:*/*"}]}]}} {"default":{"identifier":"default","description":"Default capabilities for the application","local":true,"windows":["*"],"permissions":["core:default","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-set-focus","core:window:allow-set-position","core:window:allow-set-size","core:window:allow-start-dragging","core:window:allow-is-visible","core:webview:allow-create-webview-window","shell:default","fs:default","dialog:default",{"identifier":"http:default","allow":[{"url":"http://localhost:*/*"},{"url":"http://127.0.0.1:*/*"}]}]}}
@@ -2,6 +2,7 @@ use serde::Serialize;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::mpsc; use std::sync::mpsc;
use tauri::Manager;
use tauri_plugin_dialog::DialogExt; use tauri_plugin_dialog::DialogExt;
#[derive(Serialize)] #[derive(Serialize)]
@@ -116,3 +117,70 @@ pub async fn list_directory(path: String) -> Result<Vec<DirectoryEntry>, String>
Ok(entries) Ok(entries)
} }
// 悬浮窗口控制命令
#[tauri::command]
pub async fn toggle_floating_window(app: tauri::AppHandle) -> Result<bool, String> {
if let Some(window) = app.get_webview_window("floating") {
let is_visible = window.is_visible().map_err(|e| e.to_string())?;
if is_visible {
window.hide().map_err(|e| e.to_string())?;
Ok(false)
} else {
window.show().map_err(|e| e.to_string())?;
window.set_focus().map_err(|e| e.to_string())?;
Ok(true)
}
} else {
Err("Floating window not found".to_string())
}
}
#[tauri::command]
pub async fn show_floating_window(app: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("floating") {
window.show().map_err(|e| e.to_string())?;
window.set_focus().map_err(|e| e.to_string())?;
Ok(())
} else {
Err("Floating window not found".to_string())
}
}
#[tauri::command]
pub async fn hide_floating_window(app: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("floating") {
window.hide().map_err(|e| e.to_string())?;
Ok(())
} else {
Err("Floating window not found".to_string())
}
}
#[tauri::command]
pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("main") {
window.show().map_err(|e| e.to_string())?;
window.set_focus().map_err(|e| e.to_string())?;
Ok(())
} else {
Err("Main window not found".to_string())
}
}
#[tauri::command]
pub async fn set_floating_window_size(
app: tauri::AppHandle,
width: f64,
height: f64,
) -> Result<(), String> {
if let Some(window) = app.get_webview_window("floating") {
window
.set_size(tauri::Size::Logical(tauri::LogicalSize { width, height }))
.map_err(|e| e.to_string())?;
Ok(())
} else {
Err("Floating window not found".to_string())
}
}
+18 -2
View File
@@ -21,16 +21,22 @@ pub fn run() {
commands::open_directory_dialog, commands::open_directory_dialog,
commands::read_local_file, commands::read_local_file,
commands::list_directory, commands::list_directory,
commands::toggle_floating_window,
commands::show_floating_window,
commands::hide_floating_window,
commands::show_main_window,
commands::set_floating_window_size,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
fn setup_tray<R: Runtime>(app: &tauri::App<R>) -> Result<(), Box<dyn std::error::Error>> { fn setup_tray<R: Runtime>(app: &tauri::App<R>) -> Result<(), Box<dyn std::error::Error>> {
let show_item = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>)?; let show_item = MenuItem::with_id(app, "show", "Show Main Window", true, None::<&str>)?;
let floating_item = MenuItem::with_id(app, "floating", "Toggle Quick Ask", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_item, &quit_item])?; let menu = Menu::with_items(app, &[&show_item, &floating_item, &quit_item])?;
let _tray = TrayIconBuilder::new() let _tray = TrayIconBuilder::new()
.menu(&menu) .menu(&menu)
@@ -42,6 +48,16 @@ fn setup_tray<R: Runtime>(app: &tauri::App<R>) -> Result<(), Box<dyn std::error:
let _ = window.set_focus(); let _ = window.set_focus();
} }
} }
"floating" => {
if let Some(window) = app.get_webview_window("floating") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
}
}
"quit" => { "quit" => {
app.exit(0); app.exit(0);
} }
+19 -1
View File
@@ -11,8 +11,10 @@
}, },
"app": { "app": {
"withGlobalTauri": true, "withGlobalTauri": true,
"macOSPrivateApi": true,
"windows": [ "windows": [
{ {
"label": "main",
"title": "AI Assistant", "title": "AI Assistant",
"width": 1200, "width": 1200,
"height": 800, "height": 800,
@@ -21,13 +23,29 @@
"resizable": true, "resizable": true,
"fullscreen": false, "fullscreen": false,
"center": true "center": true
},
{
"label": "floating",
"title": "",
"url": "/floating.html",
"width": 60,
"height": 60,
"minWidth": 60,
"minHeight": 60,
"resizable": false,
"decorations": false,
"transparent": true,
"shadow": false,
"alwaysOnTop": true,
"visible": true,
"skipTaskbar": true
} }
], ],
"security": { "security": {
"csp": "default-src 'self'; connect-src 'self' http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'" "csp": "default-src 'self'; connect-src 'self' http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'"
}, },
"trayIcon": { "trayIcon": {
"iconPath": "icons/icon.png", "iconPath": "icons/32x32.png",
"iconAsTemplate": true "iconAsTemplate": true
} }
}, },
+197 -52
View File
@@ -1,64 +1,89 @@
/** /**
* App Component * App Component
*
* 响应式布局:支持桌面端和移动端
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
Sidebar, IDE,
FileBrowser,
ConfigPanel,
CommandPanel, CommandPanel,
MCPPanel, MCPPanel,
HooksPanel, HooksPanel,
AgentsPanel, AgentsPanel,
CheckpointPanel, CheckpointPanel,
ProvidersPanel, ProvidersPanel,
ServicesPanel,
LSPPanel,
DiagnosticsPanel,
SessionPanel,
StatusBar,
Resizer,
Toaster, Toaster,
ThemeProvider,
listSessions, listSessions,
createSession, createSession,
type Session, type Session,
type ActiveFileInfo,
type FileDiffInfo,
} from '@ai-assistant/ui'; } from '@ai-assistant/ui';
import { ChatPage } from './pages/Chat'; import { ChatPage } from './pages/Chat';
export function App() { export function App() {
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null); const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [isInitializing, setIsInitializing] = useState(true); const [isInitializing, setIsInitializing] = useState(true);
const [showFileBrowser, setShowFileBrowser] = useState(false); const [connectionError, setConnectionError] = useState<string | null>(null);
const [showConfig, setShowConfig] = useState(false);
const [showCommands, setShowCommands] = useState(false); const [showCommands, setShowCommands] = useState(false);
const [showMCP, setShowMCP] = useState(false); const [showMCP, setShowMCP] = useState(false);
const [showHooks, setShowHooks] = useState(false); const [showHooks, setShowHooks] = useState(false);
const [showAgents, setShowAgents] = useState(false); const [showAgents, setShowAgents] = useState(false);
const [showCheckpoints, setShowCheckpoints] = useState(false); const [showCheckpoints, setShowCheckpoints] = useState(false);
const [showProviders, setShowProviders] = useState(false); const [showProviders, setShowProviders] = useState(false);
const [showServices, setShowServices] = useState(false);
const [showLSP, setShowLSP] = useState(false);
const [showDiagnostics, setShowDiagnostics] = useState(false);
const [showSessions, setShowSessions] = useState(false);
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null); const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
// IDE 面板宽度(百分比)
const [idePanelWidth, setIdePanelWidth] = useState(() => {
const saved = localStorage.getItem('ai-assistant-ide-width');
return saved ? parseFloat(saved) : 70;
});
// 初始化:加载会话(只在首次启动时自动创建) // 编辑器联动状态
const [activeFile, setActiveFile] = useState<ActiveFileInfo | null>(null);
const [autoAttachActiveFile, setAutoAttachActiveFile] = useState(() => {
const saved = localStorage.getItem('ai-assistant-auto-attach-file');
return saved !== 'false'; // 默认开启
});
// Diff 显示状态(当 AI 编辑/写入文件时触发)
const [pendingDiff, setPendingDiff] = useState<FileDiffInfo | null>(null);
// 持久化自动附加开关状态
useEffect(() => { useEffect(() => {
const HAS_SESSIONS_KEY = 'ai-assistant-has-sessions'; localStorage.setItem('ai-assistant-auto-attach-file', String(autoAttachActiveFile));
}, [autoAttachActiveFile]);
// 初始化:加载会话
useEffect(() => {
async function init() { async function init() {
try { try {
const { data: sessions } = await listSessions(); const sessionsResult = await listSessions();
const { data: sessions } = sessionsResult;
if (sessions.length > 0) { if (sessions.length > 0) {
// 有会话,选择最近的 // 有会话,选择最近的
setCurrentSessionId(sessions[0].id); setCurrentSessionId(sessions[0].id);
localStorage.setItem(HAS_SESSIONS_KEY, 'true');
} else { } else {
// 无会话:检查是否是首次启动 // 无会话,自动创建一个新会话
const hasHadSessions = localStorage.getItem(HAS_SESSIONS_KEY); // 这处理了新 project 目录的情况
if (!hasHadSessions) {
// 首次启动,自动创建会话
const { data: newSession } = await createSession(); const { data: newSession } = await createSession();
setCurrentSessionId(newSession.id); setCurrentSessionId(newSession.id);
localStorage.setItem(HAS_SESSIONS_KEY, 'true');
}
// 用户删除了所有会话:不自动创建,显示空状态
} }
} catch (error) { } catch (error) {
console.error('Failed to initialize:', error); console.error('Failed to initialize:', error);
setConnectionError('无法连接到服务器。请确保后端服务已启动 (pnpm server:dev)');
} finally { } finally {
setIsInitializing(false); setIsInitializing(false);
} }
@@ -75,91 +100,211 @@ export function App() {
setCurrentSessionId(session.id); setCurrentSessionId(session.id);
}; };
// 会话不存在时自动创建新会话
const handleSessionNotFound = useCallback(async () => {
try {
const { data: newSession } = await createSession();
setCurrentSessionId(newSession.id);
} catch (error) {
console.error('Failed to create new session:', error);
}
}, []);
// 会话标题更新回调 // 会话标题更新回调
const handleSessionUpdated = useCallback((sessionId: string, name: string) => { const handleSessionUpdated = useCallback((sessionId: string, name: string) => {
setSessionTitleUpdate({ sessionId, name }); setSessionTitleUpdate({ sessionId, name });
}, []); }, []);
// 会话切换回调(:new 命令创建新会话后切换)
const handleSessionSwitch = useCallback((newSessionId: string) => {
setCurrentSessionId(newSessionId);
}, []);
// 文件 diff 回调(当 AI 编辑/写入文件时触发)
const handleFileDiff = useCallback((diff: FileDiffInfo) => {
setPendingDiff(diff);
}, []);
// Diff 关闭回调
const handleDiffClose = useCallback(() => {
setPendingDiff(null);
}, []);
// 处理面板宽度调整
const handleResize = useCallback((delta: number) => {
setIdePanelWidth((prev) => {
// 计算百分比变化(基于窗口宽度)
const percentDelta = (delta / window.innerWidth) * 100;
// 限制范围:30% - 80%
return Math.min(80, Math.max(30, prev + percentDelta));
});
}, []);
// 保存面板宽度到 localStorage
const handleResizeEnd = useCallback(() => {
localStorage.setItem('ai-assistant-ide-width', String(idePanelWidth));
}, [idePanelWidth]);
if (isInitializing) { if (isInitializing) {
return ( return (
<div className="h-screen flex items-center justify-center bg-gray-900"> <ThemeProvider defaultTheme="dark">
<div className="h-screen flex items-center justify-center bg-surface-base">
<div className="text-center"> <div className="text-center">
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" /> <div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400">Initializing...</p> <p className="text-fg-muted">Initializing...</p>
</div> </div>
</div> </div>
</ThemeProvider>
); );
} }
return ( return (
<div className="h-screen flex bg-gray-900"> <ThemeProvider defaultTheme="dark">
<Sidebar <div className="h-screen flex flex-col bg-surface-base">
currentSessionId={currentSessionId} {/* 主内容区域:左侧文件浏览器 + 右侧对话框 */}
onSelectSession={handleSelectSession} <div className="flex-1 flex min-w-0 overflow-hidden">
onCreateSession={handleCreateSession} {/* 左侧:IDE(文件浏览器 + 代码编辑器) */}
sessionTitleUpdate={sessionTitleUpdate} <div
className="hidden md:flex flex-col"
style={{ width: `${idePanelWidth}%` }}
>
<IDE
onActiveFileChange={setActiveFile}
pendingDiff={pendingDiff}
onDiffClose={handleDiffClose}
/>
</div>
{/* 可拖拽分割线 */}
<Resizer
onResize={handleResize}
onResizeEnd={handleResizeEnd}
className="hidden md:block"
/> />
<div className="flex-1 flex"> {/* 右侧:聊天区域 */}
{/* 聊天区域 */} <div className="flex-1 min-w-0">
<div className={`flex-1 ${showFileBrowser ? 'w-1/2' : 'w-full'}`}> {connectionError ? (
{currentSessionId ? ( <div className="flex-1 flex flex-col items-center justify-center h-full gap-4 p-8">
<div className="w-16 h-16 rounded-full bg-red-500/10 flex items-center justify-center">
<svg className="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="text-center max-w-md">
<h3 className="text-lg font-semibold text-fg mb-2"></h3>
<p className="text-fg-muted text-sm mb-4">{connectionError}</p>
<button
onClick={() => {
setConnectionError(null);
setIsInitializing(true);
window.location.reload();
}}
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors"
>
</button>
</div>
</div>
) : currentSessionId ? (
<ChatPage <ChatPage
key={currentSessionId} key={currentSessionId}
sessionId={currentSessionId} sessionId={currentSessionId}
onSessionNotFound={handleSessionNotFound}
onSessionUpdated={handleSessionUpdated} onSessionUpdated={handleSessionUpdated}
showFileBrowser={showFileBrowser} onSessionSwitch={handleSessionSwitch}
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)} responsive
onOpenConfig={() => setShowConfig(true)}
onOpenCommands={() => setShowCommands(true)} onOpenCommands={() => setShowCommands(true)}
onOpenMCP={() => setShowMCP(true)} onOpenMCP={() => setShowMCP(true)}
onOpenHooks={() => setShowHooks(true)} onOpenHooks={() => setShowHooks(true)}
onOpenAgents={() => setShowAgents(true)} onOpenAgents={() => setShowAgents(true)}
onOpenCheckpoints={() => setShowCheckpoints(true)} onOpenCheckpoints={() => setShowCheckpoints(true)}
onOpenProviders={() => setShowProviders(true)} onOpenProviders={() => setShowProviders(true)}
onOpenServices={() => setShowServices(true)}
onOpenLSP={() => setShowLSP(true)}
onOpenDiagnostics={() => setShowDiagnostics(true)}
onOpenSessions={() => setShowSessions(true)}
activeFile={activeFile}
autoAttachActiveFile={autoAttachActiveFile}
onAutoAttachActiveFileToggle={setAutoAttachActiveFile}
onFileDiff={handleFileDiff}
onViewDiff={handleFileDiff}
/> />
) : ( ) : (
<div className="flex-1 flex items-center justify-center h-full"> <div className="flex-1 flex items-center justify-center h-full">
<p className="text-gray-400">Select or create a session</p> <p className="text-fg-muted">Select or create a session</p>
</div> </div>
)} )}
</div> </div>
{/* 文件浏览器 */} </div>
{showFileBrowser && (
<div className="w-1/2 border-l border-gray-700"> {/* 底部状态栏 */}
<FileBrowser <StatusBar
onFileSelect={(path, _content) => { sessionId={currentSessionId}
console.log('Selected file:', path); onDiagnosticsClick={() => setShowDiagnostics(true)}
}}
/> />
</div>
)}
</div>
{/* 配置面板 */}
{showConfig && <ConfigPanel onClose={() => setShowConfig(false)} />}
{/* 命令面板 */} {/* 命令面板 */}
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} />} {showCommands && <CommandPanel onClose={() => setShowCommands(false)} responsive />}
{/* MCP 面板 */} {/* MCP 面板 */}
{showMCP && <MCPPanel onClose={() => setShowMCP(false)} />} {showMCP && <MCPPanel onClose={() => setShowMCP(false)} responsive />}
{/* Hooks 面板 */} {/* Hooks 面板 */}
{showHooks && <HooksPanel onClose={() => setShowHooks(false)} />} {showHooks && <HooksPanel onClose={() => setShowHooks(false)} responsive />}
{/* Agents 面板 */} {/* Agents 面板 */}
{showAgents && <AgentsPanel onClose={() => setShowAgents(false)} />} {showAgents && <AgentsPanel onClose={() => setShowAgents(false)} responsive />}
{/* Checkpoints 面板 */} {/* Checkpoints 面板 */}
{showCheckpoints && <CheckpointPanel onClose={() => setShowCheckpoints(false)} />} {showCheckpoints && <CheckpointPanel onClose={() => setShowCheckpoints(false)} responsive />}
{/* Providers 面板 */} {/* Providers 面板 */}
{showProviders && <ProvidersPanel onClose={() => setShowProviders(false)} />} {showProviders && <ProvidersPanel onClose={() => setShowProviders(false)} responsive />}
{/* Services 面板 */}
{showServices && <ServicesPanel onClose={() => setShowServices(false)} responsive />}
{/* LSP 面板 */}
{showLSP && (
<LSPPanel
onClose={() => setShowLSP(false)}
onOpenDiagnostics={() => {
setShowLSP(false);
setShowDiagnostics(true);
}}
responsive
/>
)}
{/* Diagnostics 面板 */}
{showDiagnostics && (
<DiagnosticsPanel
onClose={() => setShowDiagnostics(false)}
onFileClick={(file, line) => {
console.log('Navigate to:', file, line);
// TODO: Integrate with file browser or editor
}}
responsive
/>
)}
{/* Sessions 面板 */}
{showSessions && (
<SessionPanel
onClose={() => setShowSessions(false)}
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}
onCreateSession={handleCreateSession}
sessionTitleUpdate={sessionTitleUpdate}
responsive
/>
)}
{/* Toast 通知 */} {/* Toast 通知 */}
<Toaster /> <Toaster />
</div> </div>
</ThemeProvider>
); );
} }
+25
View File
@@ -0,0 +1,25 @@
/**
* Floating Window Entry Point
* 悬浮窗口入口
*/
import React from 'react';
import ReactDOM from 'react-dom/client';
import { configureApiClient } from '@ai-assistant/ui';
import { FloatingChat } from './pages/FloatingChat';
import '@ai-assistant/ui/styles';
// 悬浮窗使用专用样式,确保背景透明(放在最后以覆盖其他样式)
import './styles/floating.css';
// 配置 API 客户端
configureApiClient({
baseUrl: 'http://localhost:3000/api',
wsBaseUrl: 'ws://localhost:3000/api',
healthUrl: 'http://localhost:3000/health',
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<FloatingChat />
</React.StrictMode>
);
+128 -153
View File
@@ -3,7 +3,7 @@
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap, Bot, History, Server } from 'lucide-react'; import { MessageSquare, Terminal, Plug, Zap, Bot, History, Server, MessagesSquare, Globe } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
@@ -11,38 +11,66 @@ import {
ChatMessage, ChatMessage,
TypingIndicator, TypingIndicator,
ChatInput, ChatInput,
ContextUsage,
SubagentProgress,
DiagnosticsIndicator,
ToolbarOverflowMenu,
type ActiveFileInfo,
type FileDiffInfo,
} from '@ai-assistant/ui'; } from '@ai-assistant/ui';
interface ChatPageProps { interface ChatPageProps {
sessionId: string; sessionId: string;
onSessionNotFound?: () => void;
onSessionUpdated?: (sessionId: string, name: string) => void; onSessionUpdated?: (sessionId: string, name: string) => void;
/** 切换会话回调(如 :new 命令创建新会话) */ /** 切换会话回调(如 :new 命令创建新会话) */
onSessionSwitch?: (newSessionId: string) => void; onSessionSwitch?: (newSessionId: string) => void;
responsive?: boolean;
// 工具栏按钮 // 工具栏按钮
showFileBrowser?: boolean;
onToggleFileBrowser?: () => void;
onOpenConfig?: () => void;
onOpenCommands?: () => void; onOpenCommands?: () => void;
onOpenMCP?: () => void; onOpenMCP?: () => void;
onOpenHooks?: () => void; onOpenHooks?: () => void;
onOpenAgents?: () => void; onOpenAgents?: () => void;
onOpenCheckpoints?: () => void; onOpenCheckpoints?: () => void;
onOpenProviders?: () => void; onOpenProviders?: () => void;
onOpenServices?: () => void;
onOpenLSP?: () => void;
onOpenDiagnostics?: () => void;
onOpenSessions?: () => void;
// 编辑器联动
/** 当前编辑器活动文件 */
activeFile?: ActiveFileInfo | null;
/** 是否自动附加当前编辑器文件 */
autoAttachActiveFile?: boolean;
/** 自动附加开关变更回调 */
onAutoAttachActiveFileToggle?: (enabled: boolean) => void;
/** 文件 diff 回调(当 AI 写入/编辑文件时触发) */
onFileDiff?: (diff: FileDiffInfo) => void;
/** 查看文件 diff 回调(点击 View Diff 按钮) */
onViewDiff?: (diff: FileDiffInfo) => void;
} }
export function ChatPage({ export function ChatPage({
sessionId, sessionId,
onSessionNotFound,
onSessionUpdated, onSessionUpdated,
onSessionSwitch, onSessionSwitch,
showFileBrowser, responsive = false,
onToggleFileBrowser,
onOpenConfig,
onOpenCommands, onOpenCommands,
onOpenMCP, onOpenMCP,
onOpenHooks, onOpenHooks,
onOpenAgents, onOpenAgents,
onOpenCheckpoints, onOpenCheckpoints,
onOpenProviders, onOpenProviders,
onOpenServices,
onOpenLSP,
onOpenDiagnostics,
onOpenSessions,
activeFile,
autoAttachActiveFile,
onAutoAttachActiveFileToggle,
onFileDiff,
onViewDiff,
}: ChatPageProps) { }: ChatPageProps) {
const { const {
messages, messages,
@@ -51,12 +79,21 @@ export function ChatPage({
streamingMessage, streamingMessage,
sendMessage, sendMessage,
cancelProcessing, cancelProcessing,
allowPermission,
denyPermission,
agentMode,
autoApprove,
setAgentMode,
setAutoApprove,
currentAgent,
currentSubagent,
answerQuestion, answerQuestion,
} = useChat({ } = useChat({
sessionId, sessionId,
onError: (error) => { onError: (error) => {
console.error('Chat error:', error); console.error('Chat error:', error);
}, },
onSessionNotFound,
onSessionUpdated, onSessionUpdated,
onSessionSwitch, onSessionSwitch,
onConfigError: (error) => { onConfigError: (error) => {
@@ -70,6 +107,7 @@ export function ChatPage({
: undefined, : undefined,
}); });
}, },
onFileDiff,
}); });
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -91,11 +129,11 @@ export function ChatPage({
<MessageSquare size={32} className="text-primary-400" /> <MessageSquare size={32} className="text-primary-400" />
</div> </div>
<h2 className="text-2xl font-semibold mb-2 bg-gradient-to-r from-white to-gray-400 bg-clip-text text-transparent"> <h2 className="text-2xl font-semibold mb-2 text-fg">
Start a conversation Start a conversation
</h2> </h2>
<p className="text-gray-400 mb-6 max-w-md mx-auto"> <p className="text-fg-muted mb-6 max-w-md mx-auto">
Ask me anything about coding, debugging, or software development. Ask me anything about coding, debugging, or software development.
</p> </p>
@@ -106,7 +144,7 @@ export function ChatPage({
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={() => sendMessage(suggestion)} onClick={() => sendMessage(suggestion)}
className="px-3 py-1.5 bg-gray-800 hover:bg-gray-700 rounded-full text-sm text-gray-300 transition-colors" className="px-3 py-1.5 bg-surface-subtle hover:bg-surface-muted rounded-full text-sm text-fg-secondary transition-colors"
> >
"{suggestion}" "{suggestion}"
</motion.button> </motion.button>
@@ -115,152 +153,61 @@ export function ChatPage({
</motion.div> </motion.div>
); );
// 连接状态指示器
const ConnectionStatus = () => (
<div className="flex items-center gap-1.5 text-sm">
{isConnected ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center gap-1.5"
>
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<span className="text-green-400">Connected</span>
</motion.div>
) : (
<div className="flex items-center gap-1.5 text-red-400">
<WifiOff size={16} />
<span>Disconnected</span>
</div>
)}
</div>
);
return ( return (
<div className="flex-1 flex flex-col h-screen"> <div className="flex-1 flex flex-col h-full">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-700 bg-gray-800"> <div className="flex items-center justify-between px-4 md:px-6 py-3 border-b border-line bg-surface-subtle">
<h1 className="text-lg font-medium">Chat</h1> {/* 左侧:上下文使用情况 */}
<div className="flex items-center gap-3"> <div className="flex items-center">
{/* 连接状态 */} {sessionId && (
<ConnectionStatus /> <ContextUsage
sessionId={sessionId}
{/* 工具栏按钮 */} compact
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints || onOpenProviders) && ( showCompressButton
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3"> refreshInterval={30000}
{/* Checkpoints 按钮 */} />
{onOpenCheckpoints && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenCheckpoints}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
title="Checkpoints"
>
<History size={20} />
</motion.button>
)}
{/* Providers 按钮 */}
{onOpenProviders && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenProviders}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
title="Model Providers"
>
<Server size={20} />
</motion.button>
)}
{/* Agents 按钮 */}
{onOpenAgents && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenAgents}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
title="Agent Presets"
>
<Bot size={20} />
</motion.button>
)}
{/* Hooks 按钮 */}
{onOpenHooks && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenHooks}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
title="Hooks"
>
<Zap size={20} />
</motion.button>
)}
{/* MCP 按钮 */}
{onOpenMCP && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenMCP}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
title="MCP Servers"
>
<Plug size={20} />
</motion.button>
)}
{/* 命令按钮 */}
{onOpenCommands && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenCommands}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
title="Commands"
>
<Terminal size={20} />
</motion.button>
)}
{/* 配置按钮 */}
{onOpenConfig && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenConfig}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
title="Settings"
>
<Settings size={20} />
</motion.button>
)}
{/* 文件浏览器按钮 */}
{onToggleFileBrowser && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onToggleFileBrowser}
className={`p-1.5 rounded-lg transition-colors ${
showFileBrowser
? 'text-blue-400 bg-blue-500/20'
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-700'
}`}
title={showFileBrowser ? 'Hide Files' : 'Show Files'}
>
<FolderOpen size={20} />
</motion.button>
)} )}
</div> </div>
{/* 右侧:工具栏按钮 */}
{(onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints || onOpenProviders || onOpenLSP || onOpenDiagnostics || onOpenSessions) && (
<div className="flex items-center gap-1.5 flex-shrink-0">
{/* LSP 诊断指示器 */}
{(onOpenLSP || onOpenDiagnostics) && (
<DiagnosticsIndicator
onClickDiagnostics={onOpenDiagnostics}
onClickLSP={onOpenLSP}
refreshInterval={30000}
/>
)} )}
{/* Sessions 按钮 */}
{onOpenSessions && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onOpenSessions}
className="p-1.5 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-muted transition-colors"
title="Sessions"
>
<MessagesSquare size={20} />
</motion.button>
)}
{/* 设置菜单 - 齿轮图标,放在最右侧 */}
<ToolbarOverflowMenu
items={[
{ icon: History, label: 'Checkpoints', onClick: onOpenCheckpoints },
{ icon: Server, label: 'Model Providers', onClick: onOpenProviders },
{ icon: Globe, label: 'External Services', onClick: onOpenServices },
{ icon: Bot, label: 'Agent Presets', onClick: onOpenAgents },
{ icon: Zap, label: 'Hooks', onClick: onOpenHooks },
{ icon: Plug, label: 'MCP Servers', onClick: onOpenMCP },
{ icon: Terminal, label: 'Commands', onClick: onOpenCommands },
]}
/>
</div> </div>
)}
</div> </div>
{/* Messages */} {/* Messages */}
@@ -270,16 +217,35 @@ export function ChatPage({
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
{messages.map((message) => ( {messages.map((message) => (
<ChatMessage key={message.id} message={message} onAnswerQuestion={answerQuestion} /> <ChatMessage
key={message.id}
message={message}
onAnswerQuestion={answerQuestion}
onViewDiff={onViewDiff ?? onFileDiff}
onAllowPermission={allowPermission}
onDenyPermission={denyPermission}
/>
))} ))}
</AnimatePresence> </AnimatePresence>
{/* 流式消息 - 复用 ChatMessage 组件 */} {/* 流式消息 - 复用 ChatMessage 组件 */}
{streamingMessage && ( {streamingMessage && (
<ChatMessage message={streamingMessage} isStreaming onAnswerQuestion={answerQuestion} /> <ChatMessage
message={streamingMessage}
isStreaming
onAnswerQuestion={answerQuestion}
onViewDiff={onViewDiff ?? onFileDiff}
onAllowPermission={allowPermission}
onDenyPermission={denyPermission}
/>
)} )}
{isLoading && !streamingMessage && <TypingIndicator />} {/* 子 Agent 进度显示 */}
{currentSubagent && (
<SubagentProgress subagent={currentSubagent} />
)}
{isLoading && !streamingMessage && !currentSubagent && <TypingIndicator agentName={currentAgent} />}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
@@ -291,7 +257,16 @@ export function ChatPage({
onCancel={cancelProcessing} onCancel={cancelProcessing}
isLoading={isLoading} isLoading={isLoading}
disabled={!isConnected} disabled={!isConnected}
responsive={responsive}
agentMode={agentMode}
onAgentModeChange={setAgentMode}
autoApprove={autoApprove}
onAutoApproveChange={setAutoApprove}
activeFile={activeFile}
autoAttachActiveFile={autoAttachActiveFile}
onAutoAttachActiveFileToggle={onAutoAttachActiveFileToggle}
/> />
</div> </div>
); );
} }
+375
View File
@@ -0,0 +1,375 @@
/**
* Floating Chat Window
* 圆形悬浮球,点击展开对话框
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { Send, X, Maximize2, Loader2, ChevronDown } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
useChat,
ChatMessage,
ThemeProvider,
Toaster,
} from '@ai-assistant/ui';
// 窗口尺寸常量
const BALL_SIZE = 60;
const EXPANDED_WIDTH = 400;
const EXPANDED_HEIGHT = 500;
export function FloatingChat() {
const [isExpanded, setIsExpanded] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [input, setInput] = useState('');
const inputRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const {
messages,
isConnected,
isLoading,
streamingMessage,
sendMessage,
cancelProcessing,
} = useChat({
sessionId: sessionId || '',
onError: (error) => {
console.error('Chat error:', error);
},
});
// 初始化:获取或创建会话
useEffect(() => {
async function init() {
try {
const response = await fetch('http://localhost:3000/api/sessions');
const result = await response.json();
if (result.data && result.data.length > 0) {
setSessionId(result.data[0].id);
} else {
const createResponse = await fetch('http://localhost:3000/api/sessions', {
method: 'POST',
});
const createResult = await createResponse.json();
setSessionId(createResult.data.id);
}
} catch (error) {
console.error('Failed to initialize session:', error);
}
}
init();
}, []);
// 自动滚动到底部
useEffect(() => {
if (isExpanded) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, streamingMessage, isExpanded]);
// 展开时聚焦输入框
useEffect(() => {
if (isExpanded && sessionId) {
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [isExpanded, sessionId]);
// 展开/收起窗口(带动画)
const toggleExpanded = useCallback(async () => {
if (isAnimating) return;
const newExpanded = !isExpanded;
setIsAnimating(true);
if (newExpanded) {
// 展开:先调整窗口大小,再显示内容动画
await invoke('set_floating_window_size', { width: EXPANDED_WIDTH, height: EXPANDED_HEIGHT });
setIsExpanded(true);
// 动画完成后
setTimeout(() => setIsAnimating(false), 300);
} else {
// 收起:先播放收起动画,再调整窗口大小
setIsExpanded(false);
setTimeout(async () => {
await invoke('set_floating_window_size', { width: BALL_SIZE, height: BALL_SIZE });
setIsAnimating(false);
}, 200);
}
}, [isExpanded, isAnimating]);
const handleSend = useCallback(() => {
if (!input.trim() || isLoading) return;
sendMessage(input.trim());
setInput('');
}, [input, isLoading, sendMessage]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
// Escape 收起窗口
if (e.key === 'Escape') {
if (isExpanded) {
toggleExpanded();
} else {
invoke('hide_floating_window');
}
}
}, [handleSend, isExpanded, toggleExpanded]);
const handleClose = useCallback(() => {
invoke('hide_floating_window');
}, []);
const handleExpandToMain = useCallback(() => {
invoke('show_main_window');
invoke('hide_floating_window');
}, []);
// 区分点击和拖拽
const isDraggingRef = useRef(false);
const mouseDownPosRef = useRef({ x: 0, y: 0 });
const handleMouseDown = useCallback((e: React.MouseEvent) => {
isDraggingRef.current = false;
mouseDownPosRef.current = { x: e.clientX, y: e.clientY };
}, []);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
const dx = Math.abs(e.clientX - mouseDownPosRef.current.x);
const dy = Math.abs(e.clientY - mouseDownPosRef.current.y);
// 移动超过 5px 认为是拖拽
if (dx > 5 || dy > 5) {
isDraggingRef.current = true;
}
}, []);
const handleClick = useCallback(() => {
// 如果是拖拽操作,不触发展开
if (isDraggingRef.current) {
return;
}
toggleExpanded();
}, [toggleExpanded]);
// 窗口拖拽
const handleDragStart = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const appWindow = getCurrentWindow();
await appWindow.startDragging();
}, []);
// 状态指示点颜色
const statusColor = !isConnected
? 'bg-yellow-400' // 连接中
: isLoading
? 'bg-blue-400' // 处理中
: 'bg-green-400'; // 就绪
// 圆球状态 - 不使用 ThemeProvider,保持背景完全透明
if (!isExpanded) {
return (
<div
className="relative w-[60px] h-[60px] cursor-pointer select-none flex items-center justify-center"
onMouseDown={(e) => {
handleMouseDown(e);
handleDragStart(e);
}}
onMouseMove={handleMouseMove}
onClick={handleClick}
>
{/* 主体球 - 玻璃拟态,使用固定尺寸避免放大溢出 */}
<motion.div
className="relative rounded-full flex items-center justify-center cursor-pointer overflow-hidden"
style={{
backgroundColor: 'rgba(209, 213, 219, 0.95)',
border: '1px solid rgba(255, 255, 255, 0.5)',
width: 52,
height: 52,
}}
whileHover={{ scale: 1.12 }}
whileTap={{ scale: 0.92 }}
>
{/* 内部渐变光泽 */}
<div
className="absolute inset-0 rounded-full"
style={{
background: 'linear-gradient(to bottom right, rgba(255,255,255,0.35), transparent, rgba(0,0,0,0.1))',
}}
/>
{/* 内部呼吸灯效果 */}
<motion.div
className="absolute inset-0 rounded-full"
style={{
background: 'radial-gradient(circle at center, rgba(255, 255, 255, 0.4), transparent 70%)',
}}
animate={{
opacity: [0.3, 0.6, 0.3],
}}
transition={{
duration: 3,
repeat: Infinity,
ease: 'easeInOut',
}}
/>
{/* 顶部高光 */}
<div
className="absolute top-1.5 left-1/2 -translate-x-1/2 w-7 h-2 rounded-full"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.35)',
filter: 'blur(2px)',
}}
/>
{/* 图标 - 简约机器人头 */}
{isLoading ? (
<Loader2 size={26} className="animate-spin relative z-10" style={{ color: 'rgba(255, 255, 255, 0.95)' }} />
) : (
<svg
width="30"
height="30"
viewBox="0 0 24 24"
fill="none"
className="relative z-10"
>
{/* 天线 */}
<circle cx="12" cy="3" r="1.5" fill="rgba(255, 255, 255, 0.9)" />
<line x1="12" y1="4.5" x2="12" y2="7" stroke="rgba(255, 255, 255, 0.9)" strokeWidth="1.5" strokeLinecap="round" />
{/* 头部 */}
<rect x="4" y="7" width="16" height="13" rx="4" fill="rgba(255, 255, 255, 0.15)" stroke="rgba(255, 255, 255, 0.9)" strokeWidth="1.5" />
{/* 眼睛 */}
<circle cx="8.5" cy="13" r="2" fill="rgba(255, 255, 255, 0.9)" />
<circle cx="15.5" cy="13" r="2" fill="rgba(255, 255, 255, 0.9)" />
{/* 嘴巴 */}
<line x1="9" y1="17" x2="15" y2="17" stroke="rgba(255, 255, 255, 0.9)" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)}
</motion.div>
</div>
);
}
// 展开状态
return (
<ThemeProvider defaultTheme="dark">
<motion.div
className="w-full h-full flex flex-col bg-surface-base/95 backdrop-blur-xl rounded-2xl border border-line/50 shadow-2xl overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.15 }}
>
{/* 顶部拖拽区域和工具栏 */}
<div
className="flex items-center justify-between px-3 py-2 border-b border-line/50 cursor-move select-none bg-surface-subtle/50"
onMouseDown={handleDragStart}
>
<div className="flex items-center gap-2">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={toggleExpanded}
className="p-1 rounded hover:bg-surface-muted text-fg-muted hover:text-fg transition-colors"
title="Collapse"
>
<ChevronDown size={16} />
</motion.button>
<span className="text-xs font-medium text-fg-muted">Quick Ask</span>
</div>
<div className="flex items-center gap-1">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={handleExpandToMain}
className="p-1 rounded hover:bg-surface-muted text-fg-muted hover:text-fg transition-colors"
title="Open in main window"
>
<Maximize2 size={14} />
</motion.button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={handleClose}
className="p-1 rounded hover:bg-red-500/20 text-fg-muted hover:text-red-400 transition-colors"
title="Hide"
>
<X size={14} />
</motion.button>
</div>
</div>
{/* 消息区域 */}
<div className="flex-1 overflow-y-auto p-3 space-y-3">
{messages.length === 0 && !streamingMessage && (
<div className="text-center text-fg-muted text-sm py-8">
Ask me anything...
</div>
)}
<AnimatePresence mode="popLayout">
{messages.map((message) => (
<ChatMessage
key={message.id}
message={message}
/>
))}
</AnimatePresence>
{streamingMessage && (
<ChatMessage
message={streamingMessage}
isStreaming
/>
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div className="p-3 border-t border-line/50 bg-surface-subtle/30">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask a question... (Enter to send)"
className="flex-1 resize-none bg-surface-subtle border border-line rounded-lg px-3 py-2 text-sm text-fg placeholder:text-fg-muted focus:outline-none focus:ring-2 focus:ring-primary-500/50 min-h-[40px] max-h-[120px]"
rows={1}
disabled={!isConnected}
/>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={isLoading ? cancelProcessing : handleSend}
disabled={!isConnected || (!input.trim() && !isLoading)}
className={`p-2 rounded-lg transition-colors ${
isLoading
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-primary-500 hover:bg-primary-600 text-white disabled:bg-surface-muted disabled:text-fg-muted'
}`}
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Send size={18} />
)}
</motion.button>
</div>
</div>
<Toaster />
</motion.div>
</ThemeProvider>
);
}
+18
View File
@@ -0,0 +1,18 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 悬浮窗专用样式 - 确保背景完全透明 (macOS 需要 rgba(0,0,0,0)) */
html, body, #root {
margin: 0;
padding: 0;
background-color: rgba(0, 0, 0, 0) !important;
background: rgba(0, 0, 0, 0) !important;
overflow: hidden;
}
/* 覆盖可能的 dark 主题背景 */
.dark, [data-theme="dark"] {
background-color: rgba(0, 0, 0, 0) !important;
background: rgba(0, 0, 0, 0) !important;
}
+8 -71
View File
@@ -2,77 +2,14 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* Custom scrollbar */ /* 导入 UI 包的共享样式会在 main.tsx 中通过 import '@ai-assistant/ui/styles' 完成 */
::-webkit-scrollbar { /* 此文件仅包含 desktop 特有的样式覆盖 */
width: 8px;
height: 8px; /* Desktop 特有的 Tauri 窗口拖拽区域 */
.titlebar-drag-region {
-webkit-app-region: drag;
} }
::-webkit-scrollbar-track { .no-drag {
background: transparent; -webkit-app-region: no-drag;
}
::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
/* Message content */
.message-content {
@apply max-w-none text-gray-100;
}
.message-content pre {
@apply bg-gray-800 rounded-lg p-4 overflow-x-auto;
}
.message-content code {
@apply bg-gray-800 px-1.5 py-0.5 rounded text-sm;
}
.message-content pre code {
@apply bg-transparent p-0;
}
/* Typing indicator */
.typing-indicator {
display: flex;
gap: 4px;
}
.typing-indicator span {
width: 8px;
height: 8px;
background: #6b7280;
border-radius: 50%;
animation: typing 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(1) {
animation-delay: 0s;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%,
80%,
100% {
transform: scale(1);
opacity: 0.5;
}
40% {
transform: scale(1.2);
opacity: 1;
}
} }
+20
View File
@@ -9,6 +9,26 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
// 语义化颜色 (引用 CSS 变量)
surface: {
base: 'rgb(var(--color-bg-base) / <alpha-value>)',
subtle: 'rgb(var(--color-bg-subtle) / <alpha-value>)',
muted: 'rgb(var(--color-bg-muted) / <alpha-value>)',
emphasis: 'rgb(var(--color-bg-emphasis) / <alpha-value>)',
},
fg: {
DEFAULT: 'rgb(var(--color-text-primary) / <alpha-value>)',
secondary: 'rgb(var(--color-text-secondary) / <alpha-value>)',
muted: 'rgb(var(--color-text-muted) / <alpha-value>)',
subtle: 'rgb(var(--color-text-subtle) / <alpha-value>)',
},
line: {
DEFAULT: 'rgb(var(--color-border-default) / <alpha-value>)',
muted: 'rgb(var(--color-border-muted) / <alpha-value>)',
},
// 代码块背景
code: 'rgb(var(--color-code-bg) / <alpha-value>)',
// 保留现有 primary 色板
primary: { primary: {
50: '#f0f9ff', 50: '#f0f9ff',
100: '#e0f2fe', 100: '#e0f2fe',
+7
View File
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
import { resolve } from 'path';
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
@@ -43,5 +44,11 @@ export default defineConfig({
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
sourcemap: !!process.env.TAURI_DEBUG, sourcemap: !!process.env.TAURI_DEBUG,
outDir: 'dist', outDir: 'dist',
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
floating: resolve(__dirname, 'floating.html'),
},
},
}, },
}); });
+2
View File
@@ -13,6 +13,8 @@
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"build:bundle": "bun build src/bin/server.ts --target bun --outfile dist/server.bundle.js",
"build:standalone": "bun build src/bin/server.ts --compile --outfile dist/ai-server",
"dev": "tsc --watch", "dev": "tsc --watch",
"start": "bun run src/bin/server.ts", "start": "bun run src/bin/server.ts",
"start:dev": "bun --watch run src/bin/server.ts", "start:dev": "bun --watch run src/bin/server.ts",
+8 -1
View File
@@ -421,7 +421,7 @@ export async function processMessage(
}); });
}); });
// 发送完成消息 // 发送完成消息(包含 token 使用信息)
broadcastToSession(sessionId, { broadcastToSession(sessionId, {
type: 'done', type: 'done',
sessionId, sessionId,
@@ -430,6 +430,13 @@ export async function processMessage(
hasToolCalls, hasToolCalls,
messageCount: result.messages.length, messageCount: result.messages.length,
agentName: options?.agentMode || 'build', agentName: options?.agentMode || 'build',
usage: result.usage ? {
inputTokens: result.usage.promptTokens,
outputTokens: result.usage.completionTokens,
totalTokens: result.usage.totalTokens,
cacheReadTokens: result.usage.cacheReadInputTokens,
cacheWriteTokens: result.usage.cacheCreationInputTokens,
} : undefined,
}, },
}); });
+17 -9
View File
@@ -14,12 +14,13 @@
import { app, websocket, startServer } from '../index.js'; import { app, websocket, startServer } from '../index.js';
// 解析命令行参数 // 解析命令行参数
function parseArgs(): { port: number; host: string; auth?: boolean; token?: string } { function parseArgs(): { port: number; host: string; auth?: boolean; token?: string; staticDir?: string } {
const args = process.argv.slice(2); const args = process.argv.slice(2);
let port = 3000; let port = 3000;
let host = '127.0.0.1'; let host = '127.0.0.1';
let auth: boolean | undefined; let auth: boolean | undefined;
let token: string | undefined; let token: string | undefined;
let staticDir: string | undefined;
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
if (args[i] === '--port' || args[i] === '-p') { if (args[i] === '--port' || args[i] === '-p') {
@@ -35,16 +36,20 @@ function parseArgs(): { port: number; host: string; auth?: boolean; token?: stri
} else if (args[i] === '--token' || args[i] === '-t') { } else if (args[i] === '--token' || args[i] === '-t') {
token = args[i + 1]; token = args[i + 1];
i++; i++;
} else if (args[i] === '--static' || args[i] === '-s') {
staticDir = args[i + 1];
i++;
} else if (args[i] === '--help' || args[i] === '-h') { } else if (args[i] === '--help' || args[i] === '-h') {
console.log(` console.log(`
AI Assistant Server AI Assistant Server
Usage: Usage:
bun run server.ts [options] ai-server [options]
Options: Options:
-p, --port <port> Port to listen on (default: 3000) -p, --port <port> Port to listen on (default: 3000)
-H, --host <host> Host to bind to (default: 127.0.0.1) -H, --host <host> Host to bind to (default: 127.0.0.1)
-s, --static <dir> Serve static files from directory (Web UI)
--auth Enable authentication --auth Enable authentication
--no-auth Disable authentication --no-auth Disable authentication
-t, --token <token> Set authentication token -t, --token <token> Set authentication token
@@ -52,27 +57,30 @@ Options:
Examples: Examples:
# Local development (no auth) # Local development (no auth)
bun run server.ts ai-server
# Serve with Web UI
ai-server --static ./web-dist
# Remote server with auth # Remote server with auth
bun run server.ts --host 0.0.0.0 --auth ai-server --host 0.0.0.0 --auth
# Custom token # Full production setup
bun run server.ts --host 0.0.0.0 --token mysecrettoken ai-server --host 0.0.0.0 --static ./web-dist --token mysecrettoken
`); `);
process.exit(0); process.exit(0);
} }
} }
return { port, host, auth, token }; return { port, host, auth, token, staticDir };
} }
// 主函数 // 主函数
async function main() { async function main() {
const { port, host, auth, token } = parseArgs(); const { port, host, auth, token, staticDir } = parseArgs();
// 初始化并打印启动信息 // 初始化并打印启动信息
await startServer({ port, host, auth, token }); await startServer({ port, host, auth, token, staticDir });
// 启动 Bun 服务器 // 启动 Bun 服务器
const server = Bun.serve({ const server = Bun.serve({
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env bun
/**
* AI Assistant Standalone Server
*
* 包含嵌入式 Web UI 的独立服务器
* 由 scripts/build-standalone.ts 构建
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { createBunWebSocket } from 'hono/bun';
import {
sessionsRouter,
toolsRouter,
configRouter,
filesRouter,
commandsRouter,
mcpRouter,
hooksRouter,
agentsRouter,
checkpointsRouter,
providersRouter,
servicesRouter,
contextRouter,
lspRouter,
systemCommandsRouter,
statsRouter,
} from '../routes/index.js';
import {
handleWebSocket,
handleWebSocketMessage,
handleWebSocketClose,
getConnectionStats,
} from '../ws.js';
import { handleSSE, getSSEStats } from '../sse.js';
import { getSessionManager } from '../session/manager.js';
import { initCore, isCoreAvailable } from '../agent/index.js';
import {
authMiddleware,
initAuth,
getAuthConfig,
generateToken,
addToken,
} from '../auth/index.js';
// 嵌入的 Web 资源(构建时生成)
import { EMBEDDED_ASSETS, decodeAsset } from '../embedded-assets.generated.js';
// 嵌入的 WASM 文件(构建时生成)
import { EMBEDDED_WASM } from '../embedded-wasm.generated.js';
// 创建 Hono 应用
const app = new Hono();
// WebSocket 升级
const { upgradeWebSocket, websocket } = createBunWebSocket();
// 中间件
app.use('*', logger());
app.use(
'*',
cors({
origin: '*',
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
})
);
// 认证中间件
app.use('*', authMiddleware);
// 健康检查
app.get('/health', (c) => {
const sessionManager = getSessionManager();
const wsStats = getConnectionStats();
const sseStats = getSSEStats();
const authConfig = getAuthConfig();
return c.json({
status: 'ok',
timestamp: new Date().toISOString(),
agent: {
coreAvailable: isCoreAvailable(),
},
auth: {
enabled: authConfig.enabled,
tokenCount: authConfig.tokens.length,
},
stats: {
sessions: sessionManager.count(),
websocket: wsStats,
sse: sseStats,
},
});
});
// API 路由
const api = new Hono();
api.route('/sessions', sessionsRouter);
api.route('/tools', toolsRouter);
api.route('/config', configRouter);
api.route('/files', filesRouter);
api.route('/commands', commandsRouter);
api.route('/mcp', mcpRouter);
api.route('/hooks', hooksRouter);
api.route('/agents', agentsRouter);
api.route('/checkpoints', checkpointsRouter);
api.route('/providers', providersRouter);
api.route('/services', servicesRouter);
api.route('/lsp', lspRouter);
api.route('/system-commands', systemCommandsRouter);
api.route('/stats', statsRouter);
api.route('/', contextRouter);
// SSE 事件流
api.get('/sessions/:id/events', handleSSE);
// WebSocket 端点
api.get(
'/ws/:sessionId',
upgradeWebSocket((c) => {
const sessionId = c.req.param('sessionId');
return {
onOpen(_event, ws) {
handleWebSocket(ws, sessionId);
},
onMessage(event, ws) {
handleWebSocketMessage(ws, sessionId, event.data);
},
onClose(_event, ws) {
handleWebSocketClose(ws, sessionId);
},
onError(event, ws) {
console.error('[WS] Error:', event);
handleWebSocketClose(ws, sessionId);
},
};
})
);
app.route('/api', api);
// 嵌入式 Web UI 静态文件服务
app.get('*', (c) => {
let reqPath = c.req.path;
// 尝试查找精确匹配
let asset = EMBEDDED_ASSETS.get(reqPath);
// 如果没找到且不是文件路径,尝试 index.html (SPA 路由)
if (!asset && !reqPath.includes('.')) {
asset = EMBEDDED_ASSETS.get('/index.html');
}
if (asset) {
const body = decodeAsset(asset);
return new Response(body, {
headers: {
'Content-Type': asset.mimeType,
'Cache-Control': reqPath.includes('/assets/') ? 'public, max-age=31536000' : 'no-cache',
},
});
}
return c.json({ success: false, error: 'Not found' }, 404);
});
// 解析命令行参数
function parseArgs(): { port: number; host: string; auth?: boolean; token?: string } {
const args = process.argv.slice(2);
let port = 3000;
let host = '127.0.0.1';
let auth: boolean | undefined;
let token: string | undefined;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--port' || args[i] === '-p') {
port = parseInt(args[i + 1], 10) || 3000;
i++;
} else if (args[i] === '--host' || args[i] === '-H') {
host = args[i + 1] || '127.0.0.1';
i++;
} else if (args[i] === '--auth') {
auth = true;
} else if (args[i] === '--no-auth') {
auth = false;
} else if (args[i] === '--token' || args[i] === '-t') {
token = args[i + 1];
i++;
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
AI Assistant (Standalone)
A terminal-based AI coding assistant with embedded Web UI.
Usage:
ai-assistant [options]
Options:
-p, --port <port> Port to listen on (default: 3000)
-H, --host <host> Host to bind to (default: 127.0.0.1)
--auth Enable authentication
--no-auth Disable authentication
-t, --token <token> Set authentication token
-h, --help Show this help message
Examples:
# Local development
ai-assistant
# Listen on all interfaces with auth
ai-assistant --host 0.0.0.0 --auth
# Custom port and token
ai-assistant --port 8080 --token mysecrettoken
After starting, open http://localhost:<port> in your browser.
`);
process.exit(0);
}
}
return { port, host, auth, token };
}
// 主函数
async function main() {
const { port, host, auth, token } = parseArgs();
// 初始化嵌入的 WASM(在 Core 初始化之前)
if (EMBEDDED_WASM.size > 0) {
try {
// 动态导入 core 模块来设置 WASM
const core = await import('@ai-assistant/core');
if (typeof core.setEmbeddedWasm === 'function') {
core.setEmbeddedWasm(EMBEDDED_WASM);
console.log(`[Server] Embedded WASM initialized (${EMBEDDED_WASM.size} files)`);
}
} catch (error) {
console.warn('[Server] Failed to initialize embedded WASM:', error);
}
}
// 初始化 SessionManager
const sessionManager = getSessionManager();
await sessionManager.init();
// 初始化 Core
const coreLoaded = await initCore();
if (coreLoaded) {
console.log('[Server] Core module initialized');
} else {
console.warn('[Server] Core module not available, running in limited mode');
}
// 初始化认证
const isRemote = host !== '127.0.0.1' && host !== 'localhost';
const authEnabled = auth !== undefined ? auth : isRemote;
initAuth({
enabled: authEnabled,
tokens: [],
skipPaths: ['/health', '/api/health'],
});
if (authEnabled) {
const serverToken = token || generateToken();
addToken(serverToken);
console.log(`[Auth] Authentication enabled`);
console.log(`[Auth] Token: ${serverToken}`);
}
const coreStatus = isCoreAvailable() ? '✅ Core loaded' : '⚠️ Core not available';
const authConfig = getAuthConfig();
const authStatus = authConfig.enabled ? '🔐 Enabled' : '🔓 Disabled';
console.log(`
╔════════════════════════════════════════════╗
║ AI Assistant (Standalone) ║
╠════════════════════════════════════════════╣
║ Web UI: http://${host}:${port}
║ REST API: http://${host}:${port}/api
║ WebSocket: ws://${host}:${port}/api/ws/:sessionId
║ Health: http://${host}:${port}/health
║ Agent: ${coreStatus}
║ Auth: ${authStatus}
║ Web UI: 📦 Embedded
╚════════════════════════════════════════════╝
`);
// 启动服务器
const server = Bun.serve({
port,
hostname: host,
fetch: app.fetch,
websocket,
});
console.log(`Server running at http://${host}:${port}`);
console.log(`Open http://${host === '0.0.0.0' ? 'localhost' : host}:${port} in your browser\n`);
// 优雅关闭
process.on('SIGINT', () => {
console.log('\nShutting down server...');
server.stop();
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('\nShutting down server...');
server.stop();
process.exit(0);
});
}
main().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});
+52 -2
View File
@@ -7,9 +7,10 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { cors } from 'hono/cors'; import { cors } from 'hono/cors';
import { logger } from 'hono/logger'; import { logger } from 'hono/logger';
import { serveStatic } from 'hono/bun';
import { createBunWebSocket } from 'hono/bun'; import { createBunWebSocket } from 'hono/bun';
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter, agentsRouter, checkpointsRouter, providersRouter, servicesRouter, contextRouter, lspRouter, systemCommandsRouter } from './routes/index.js'; import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter, agentsRouter, checkpointsRouter, providersRouter, servicesRouter, contextRouter, lspRouter, systemCommandsRouter, statsRouter } from './routes/index.js';
import { import {
handleWebSocket, handleWebSocket,
handleWebSocketMessage, handleWebSocketMessage,
@@ -91,6 +92,7 @@ api.route('/providers', providersRouter);
api.route('/services', servicesRouter); api.route('/services', servicesRouter);
api.route('/lsp', lspRouter); api.route('/lsp', lspRouter);
api.route('/system-commands', systemCommandsRouter); api.route('/system-commands', systemCommandsRouter);
api.route('/stats', statsRouter);
// 上下文压缩相关(挂载到根路径,内部路由包含 /sessions/:id/context // 上下文压缩相关(挂载到根路径,内部路由包含 /sessions/:id/context
api.route('/', contextRouter); api.route('/', contextRouter);
@@ -157,6 +159,8 @@ export interface ServerOptions {
auth?: boolean; auth?: boolean;
/** 预设的 token */ /** 预设的 token */
token?: string; token?: string;
/** 静态文件目录 (托管 Web UI) */
staticDir?: string;
} }
/** /**
@@ -209,18 +213,63 @@ export async function initServer(options: ServerOptions = {}): Promise<void> {
} }
} }
/**
* 配置静态文件托管
*/
export function configureStaticFiles(staticDir: string): void {
// 静态文件中间件
app.use(
'/*',
serveStatic({
root: staticDir,
rewriteRequestPath: (path) => {
// API 和健康检查路径不走静态文件
if (path.startsWith('/api') || path === '/health') {
return path;
}
return path;
},
})
);
// SPA 路由回退 - 所有非 API/静态文件请求返回 index.html
app.get('*', async (c) => {
const path = c.req.path;
// 跳过 API 和健康检查
if (path.startsWith('/api') || path === '/health') {
return c.notFound();
}
// 返回 index.html
const indexPath = `${staticDir}/index.html`;
const file = Bun.file(indexPath);
if (await file.exists()) {
return new Response(file, {
headers: { 'Content-Type': 'text/html' },
});
}
return c.notFound();
});
}
/** /**
* 启动服务器 (Bun 环境) * 启动服务器 (Bun 环境)
*/ */
export async function startServer(options: ServerOptions = {}): Promise<void> { export async function startServer(options: ServerOptions = {}): Promise<void> {
const { port = 3000, host = '127.0.0.1' } = options; const { port = 3000, host = '127.0.0.1', staticDir } = options;
// 初始化 // 初始化
await initServer(options); await initServer(options);
// 配置静态文件托管
if (staticDir) {
configureStaticFiles(staticDir);
}
const coreStatus = isCoreAvailable() ? '✅ Core loaded' : '⚠️ Core not available'; const coreStatus = isCoreAvailable() ? '✅ Core loaded' : '⚠️ Core not available';
const authConfig = getAuthConfig(); const authConfig = getAuthConfig();
const authStatus = authConfig.enabled ? '🔐 Enabled' : '🔓 Disabled'; const authStatus = authConfig.enabled ? '🔐 Enabled' : '🔓 Disabled';
const staticStatus = staticDir ? `📁 ${staticDir}` : '❌ Disabled';
console.log(` console.log(`
╔════════════════════════════════════════════╗ ╔════════════════════════════════════════════╗
@@ -232,6 +281,7 @@ export async function startServer(options: ServerOptions = {}): Promise<void> {
║ Health: http://${host}:${port}/health ║ Health: http://${host}:${port}/health
║ Agent: ${coreStatus} ║ Agent: ${coreStatus}
║ Auth: ${authStatus} ║ Auth: ${authStatus}
║ Static: ${staticStatus}
╚════════════════════════════════════════════╝ ╚════════════════════════════════════════════╝
`); `);
-1
View File
@@ -9,7 +9,6 @@ import { getConfig } from './config.js';
import type { import type {
AgentMode, AgentMode,
AgentInfo, AgentInfo,
AgentConfigFile,
AgentModelConfig, AgentModelConfig,
AgentPermission, AgentPermission,
} from '@ai-assistant/core'; } from '@ai-assistant/core';
+1 -14
View File
@@ -6,20 +6,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { getConfig } from './config.js'; import { getConfig } from './config.js';
import type { import type { CheckpointMetadata } from '@ai-assistant/core';
CheckpointMetadata,
CheckpointConfig,
CheckpointTrigger,
FileChange,
FileChangeType,
DiffInfo,
FileDiff,
RollbackOptions,
RollbackResult,
RollbackRecord,
SafetyCheckResult,
UnrevertResult,
} from '@ai-assistant/core';
import { import {
CheckpointManager, CheckpointManager,
getCheckpointManager, getCheckpointManager,
+1 -5
View File
@@ -7,11 +7,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { z } from 'zod'; import { z } from 'zod';
import { getConfig } from './config.js'; import { getConfig } from './config.js';
import type { // Command, CommandInput, CommandExecutionResult 类型由函数自动推断
Command,
CommandInput,
CommandExecutionResult,
} from '@ai-assistant/core';
import { import {
getCommandRegistry, getCommandRegistry,
createCommandExecutor, createCommandExecutor,
+1
View File
@@ -18,3 +18,4 @@ export { servicesRouter } from './services.js';
export { contextRouter } from './context.js'; export { contextRouter } from './context.js';
export { lspRouter } from './lsp.js'; export { lspRouter } from './lsp.js';
export { systemCommandsRouter } from './system-commands.js'; export { systemCommandsRouter } from './system-commands.js';
export { statsRouter } from './stats.js';
+1 -4
View File
@@ -6,10 +6,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { getConfig } from './config.js'; import { getConfig } from './config.js';
import type { import type { FileDiagnostic } from '@ai-assistant/core';
FileDiagnostic,
ServerStatus,
} from '@ai-assistant/core';
import { import {
initLSP, initLSP,
listServers, listServers,
+1 -6
View File
@@ -6,12 +6,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { getConfig } from './config.js'; import { getConfig } from './config.js';
import type { import type { MCPConfig } from '@ai-assistant/core';
MCPConfig,
MCPServerConfig,
MCPServerStatus,
MCPTool,
} from '@ai-assistant/core';
import { import {
getMCPManager, getMCPManager,
loadMCPConfig, loadMCPConfig,
+1 -1
View File
@@ -5,7 +5,7 @@
*/ */
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { ServiceConfig, ServiceType } from '@ai-assistant/core'; import type { ServiceType } from '@ai-assistant/core';
import { import {
loadProvidersConfig, loadProvidersConfig,
getServiceConfig, getServiceConfig,
+1 -1
View File
@@ -11,7 +11,7 @@ import {
type Message, type Message,
type MessagePart, type MessagePart,
} from '../types.js'; } from '../types.js';
import type { MessageInfo, Part, ApiPart } from '@ai-assistant/core'; // MessageInfo, Part, ApiPart 从 Core 导入但仅用于类型推导
import { MessageStorage, PartStorage, partsToApiFormat } from '@ai-assistant/core'; import { MessageStorage, PartStorage, partsToApiFormat } from '@ai-assistant/core';
export const sessionsRouter = new Hono(); export const sessionsRouter = new Hono();
+216
View File
@@ -0,0 +1,216 @@
/**
* Stats API Routes
*
* Token 消耗统计相关的 REST API
*/
import { Hono } from 'hono';
import { tokenStatsManager } from '@ai-assistant/core';
import { getSessionManager } from '../session/manager.js';
export const statsRouter = new Hono();
const sessionManager = getSessionManager();
/**
* GET /stats/sessions/:id - 获取会话 Token 统计
*/
statsRouter.get('/sessions/:id', async (c) => {
const id = c.req.param('id');
if (!sessionManager.exists(id)) {
return c.json(
{
success: false,
error: 'Session not found',
},
404
);
}
try {
// 从 session manager 获取 projectId
const projectId = sessionManager.getProjectId(id);
const stats = await tokenStatsManager.getSessionStats(projectId, id);
if (!stats) {
// 如果没有统计,返回空数据
return c.json({
success: true,
data: {
sessionId: id,
self: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
},
children: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
},
total: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
},
messageCount: 0,
},
});
}
return c.json({
success: true,
data: stats,
});
} catch (error) {
console.error('[Stats] Failed to get session stats:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get stats',
},
500
);
}
});
/**
* GET /stats/projects/:id - 获取项目 Token 统计
*/
statsRouter.get('/projects/:id', async (c) => {
const id = c.req.param('id');
try {
// forceRefresh=true 强制刷新统计
const refresh = c.req.query('refresh') === 'true';
const stats = await tokenStatsManager.getProjectStats(id, refresh);
if (!stats) {
return c.json({
success: true,
data: {
projectId: id,
totalInputTokens: 0,
totalOutputTokens: 0,
totalTokens: 0,
sessionCount: 0,
},
});
}
return c.json({
success: true,
data: {
projectId: id,
...stats,
},
});
} catch (error) {
console.error('[Stats] Failed to get project stats:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get stats',
},
500
);
}
});
/**
* GET /stats/summary - 获取当前会话/项目统计摘要
*
* 返回当前活动会话的统计信息(如果有)
*/
statsRouter.get('/summary', async (c) => {
try {
const sessions = sessionManager.list();
const currentSession = sessions.find((s) => s.status === 'busy') || sessions[0];
if (!currentSession) {
return c.json({
success: true,
data: {
hasActiveSession: false,
session: null,
project: null,
},
});
}
// 从 session manager 获取 projectId
const projectId = sessionManager.getProjectId(currentSession.id);
const [sessionStats, projectStats] = await Promise.all([
tokenStatsManager.getSessionStats(projectId, currentSession.id),
tokenStatsManager.getProjectStats(projectId),
]);
return c.json({
success: true,
data: {
hasActiveSession: true,
sessionId: currentSession.id,
projectId,
session: sessionStats || {
sessionId: currentSession.id,
self: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
children: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
total: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
messageCount: 0,
},
project: projectStats
? {
projectId,
...projectStats,
}
: {
projectId,
totalInputTokens: 0,
totalOutputTokens: 0,
totalTokens: 0,
sessionCount: 0,
},
},
});
} catch (error) {
console.error('[Stats] Failed to get summary:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get summary',
},
500
);
}
});
/**
* POST /stats/projects/:id/refresh - 强制刷新项目统计
*/
statsRouter.post('/projects/:id/refresh', async (c) => {
const id = c.req.param('id');
try {
const stats = await tokenStatsManager.updateProjectStats(id);
return c.json({
success: true,
data: {
projectId: id,
...stats,
},
});
} catch (error) {
console.error('[Stats] Failed to refresh project stats:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to refresh stats',
},
500
);
}
});
-5
View File
@@ -365,11 +365,6 @@ export interface Message {
parts: MessagePart[]; parts: MessagePart[];
/** 所有文本拼接(兼容字段) */ /** 所有文本拼接(兼容字段) */
content?: string; content?: string;
metadata?: {
model?: string;
stepCount?: number;
totalTokens?: number;
};
} }
/** @deprecated 使用 Message 代替 */ /** @deprecated 使用 Message 代替 */
+118
View File
@@ -56,6 +56,10 @@ import type {
// System Commands types // System Commands types
SystemCommandListResponse, SystemCommandListResponse,
SystemCommandInfo, SystemCommandInfo,
// Token Stats types
SessionTokenStats,
ProjectTokenStats,
TokenStatsSummary,
} from './types.js'; } from './types.js';
// Re-export types // Re-export types
@@ -155,6 +159,11 @@ export type {
ActiveFileInfo, ActiveFileInfo,
// File diff types // File diff types
FileDiffInfo, FileDiffInfo,
// Token Stats types
TokenCount,
SessionTokenStats,
ProjectTokenStats,
TokenStatsSummary,
} from './types.js'; } from './types.js';
// API Configuration // API Configuration
@@ -1156,3 +1165,112 @@ export async function getLSPDiagnostics(file?: string): Promise<{
const params = file ? `?file=${encodeURIComponent(file)}` : ''; const params = file ? `?file=${encodeURIComponent(file)}` : '';
return request('GET', `/lsp/diagnostics${params}`); return request('GET', `/lsp/diagnostics${params}`);
} }
// ============ Token Stats API ============
/**
* 获取会话 Token 统计
*/
export async function getSessionTokenStats(sessionId: string): Promise<{
success: boolean;
data?: SessionTokenStats;
error?: string;
}> {
return request('GET', `/stats/sessions/${encodeURIComponent(sessionId)}`);
}
/**
* 获取项目 Token 统计
*/
export async function getProjectTokenStats(
projectId: string,
refresh?: boolean
): Promise<{
success: boolean;
data?: ProjectTokenStats;
error?: string;
}> {
const params = refresh ? '?refresh=true' : '';
return request('GET', `/stats/projects/${encodeURIComponent(projectId)}${params}`);
}
/**
* 获取 Token 统计摘要(当前会话和项目)
*/
export async function getTokenStatsSummary(): Promise<{
success: boolean;
data?: TokenStatsSummary;
error?: string;
}> {
return request('GET', '/stats/summary');
}
/**
* 刷新项目 Token 统计
*/
export async function refreshProjectTokenStats(projectId: string): Promise<{
success: boolean;
data?: ProjectTokenStats;
error?: string;
}> {
return request('POST', `/stats/projects/${encodeURIComponent(projectId)}/refresh`);
}
// ============ Services API ============
/** 服务列表项 */
export interface ServiceListItem {
id: string;
name: string;
description: string;
website: string;
enabled: boolean;
hasApiKey: boolean;
}
/**
* 获取所有服务列表
*/
export async function listServices(): Promise<{
success: boolean;
data: ServiceListItem[];
error?: string;
}> {
return request('GET', '/services');
}
/**
* 获取单个服务详情
*/
export async function getService(id: string): Promise<{
success: boolean;
data?: ServiceListItem;
error?: string;
}> {
return request('GET', `/services/${encodeURIComponent(id)}`);
}
/**
* 更新服务配置
*/
export async function updateService(
id: string,
config: { apiKey?: string; enabled?: boolean }
): Promise<{
success: boolean;
message?: string;
error?: string;
}> {
return request('PUT', `/services/${encodeURIComponent(id)}`, config);
}
/**
* 删除服务配置(移除 API Key)
*/
export async function deleteService(id: string): Promise<{
success: boolean;
message?: string;
error?: string;
}> {
return request('DELETE', `/services/${encodeURIComponent(id)}`);
}
+63 -12
View File
@@ -175,14 +175,13 @@ export interface Message {
parts: MessagePart[]; parts: MessagePart[];
/** 所有文本拼接(兼容字段) */ /** 所有文本拼接(兼容字段) */
content?: string; content?: string;
/** 是否包含推理过程 */
hasReasoning?: boolean;
/** 推理内容 */
reasoning?: string;
/** 元数据 */ /** 元数据 */
metadata?: { metadata?: {
model?: string; /** 输入 Token 数 */
stepCount?: number; inputTokens?: number;
/** 输出 Token 数 */
outputTokens?: number;
/** 总 Token 数 */
totalTokens?: number; totalTokens?: number;
/** 生成此消息的 Agent 名称 */ /** 生成此消息的 Agent 名称 */
agentName?: string; agentName?: string;
@@ -838,8 +837,6 @@ export interface ProviderDetail {
builtin: boolean; builtin: boolean;
/** API 基础 URL */ /** API 基础 URL */
baseUrl?: string; baseUrl?: string;
/** API Key 环境变量名 */
apiKeyEnvVar?: string;
/** 可用模型列表 */ /** 可用模型列表 */
models: ModelInfo[]; models: ModelInfo[];
/** 是否允许自定义模型 */ /** 是否允许自定义模型 */
@@ -863,8 +860,6 @@ export interface CustomProviderDefinition {
description?: string; description?: string;
/** API 基础 URL(必填) */ /** API 基础 URL(必填) */
baseUrl: string; baseUrl: string;
/** API Key 环境变量名 */
apiKeyEnvVar?: string;
/** 预设模型列表 */ /** 预设模型列表 */
models?: ModelInfo[]; models?: ModelInfo[];
/** 是否允许自定义模型 */ /** 是否允许自定义模型 */
@@ -877,8 +872,6 @@ export interface ProviderConfig {
id?: string; id?: string;
/** API Key */ /** API Key */
apiKey?: string; apiKey?: string;
/** API Key 环境变量名 */
apiKeyEnvVar?: string;
/** 自定义 API 基础 URL */ /** 自定义 API 基础 URL */
baseUrl?: string; baseUrl?: string;
/** 是否启用 */ /** 是否启用 */
@@ -1223,3 +1216,61 @@ export interface FileDiffInfo {
toolCallId?: string; toolCallId?: string;
} }
// ============ Token 统计相关 ============
/** Token 统计数值 */
export interface TokenCount {
inputTokens: number;
outputTokens: number;
totalTokens: number;
}
/** 会话 Token 统计 */
export interface SessionTokenStats {
/** 会话 ID */
sessionId?: string;
/** 本会话自身统计 */
self: TokenCount & {
cacheReadTokens?: number;
cacheWriteTokens?: number;
};
/** 子会话统计汇总 */
children: TokenCount;
/** 总计(自身 + 子会话) */
total: TokenCount;
/** 消息数量 */
messageCount: number;
/** 最后更新时间 */
updatedAt?: number;
}
/** 项目 Token 统计 */
export interface ProjectTokenStats {
/** 项目 ID */
projectId: string;
/** 总输入 Token */
totalInputTokens: number;
/** 总输出 Token */
totalOutputTokens: number;
/** 总 Token */
totalTokens: number;
/** 会话数量 */
sessionCount: number;
/** 最后更新时间 */
updatedAt?: number;
}
/** Token 统计摘要 */
export interface TokenStatsSummary {
/** 是否有活跃会话 */
hasActiveSession: boolean;
/** 当前会话 ID */
sessionId?: string;
/** 当前项目 ID */
projectId?: string;
/** 会话统计 */
session: SessionTokenStats | null;
/** 项目统计 */
project: ProjectTokenStats | null;
}
@@ -15,6 +15,7 @@ import {
CheckCircle2, CheckCircle2,
Loader2, Loader2,
GitCompare, GitCompare,
Coins,
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useState, forwardRef } from 'react'; import { useState, forwardRef } from 'react';
@@ -41,11 +42,28 @@ interface ChatMessageProps {
onDenyPermission?: (requestId: string, remember: boolean) => void; onDenyPermission?: (requestId: string, remember: boolean) => void;
} }
// 格式化 Token 数量
const formatTokens = (tokens: number): string => {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(2)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return `${tokens}`;
};
export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>( export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
({ message, isStreaming = false, onAnswerQuestion, onViewDiff, onAllowPermission, onDenyPermission }, ref) => { ({ message, isStreaming = false, onAnswerQuestion, onViewDiff, onAllowPermission, onDenyPermission }, ref) => {
const isUser = message.role === 'user'; const isUser = message.role === 'user';
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// Token 信息
const hasTokenInfo = !isUser && message.metadata?.inputTokens !== undefined;
const inputTokens = message.metadata?.inputTokens ?? 0;
const outputTokens = message.metadata?.outputTokens ?? 0;
const totalTokens = message.metadata?.totalTokens ?? (inputTokens + outputTokens);
const handleCopy = async () => { const handleCopy = async () => {
await navigator.clipboard.writeText(message.content ?? ''); await navigator.clipboard.writeText(message.content ?? '');
setCopied(true); setCopied(true);
@@ -184,6 +202,22 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
</button> </button>
</div> </div>
{renderContent()} {renderContent()}
{/* Token 统计显示(仅 AI 消息,非流式输出时) */}
{hasTokenInfo && !isStreaming && totalTokens > 0 && (
<div className="mt-2 pt-2 border-t border-line/50 flex items-center gap-3 text-xs text-fg-subtle">
<span className="flex items-center gap-1" title="Token 消耗">
<Coins size={12} className="text-fg-muted" />
<span>{formatTokens(totalTokens)}</span>
</span>
<span className="text-fg-muted/40">|</span>
<span title="输入 Token">
{formatTokens(inputTokens)}
</span>
<span title="输出 Token">
{formatTokens(outputTokens)}
</span>
</div>
)}
</div> </div>
</motion.div> </motion.div>
); );
@@ -54,7 +54,6 @@ export function ProviderEditor({
// Form state // Form state
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [apiKeyEnvVar, setApiKeyEnvVar] = useState('');
const [baseUrl, setBaseUrl] = useState(''); const [baseUrl, setBaseUrl] = useState('');
const [enabled, setEnabled] = useState(true); const [enabled, setEnabled] = useState(true);
@@ -84,7 +83,6 @@ export function ProviderEditor({
const config = result.data.config; const config = result.data.config;
// API key is not returned for security, but we show if it's configured via hasApiKey // API key is not returned for security, but we show if it's configured via hasApiKey
setApiKey(''); setApiKey('');
setApiKeyEnvVar(result.data.apiKeyEnvVar || '');
setBaseUrl(config.baseUrl || result.data.baseUrl || ''); setBaseUrl(config.baseUrl || result.data.baseUrl || '');
setEnabled(config.enabled !== false); setEnabled(config.enabled !== false);
} else { } else {
@@ -114,9 +112,6 @@ export function ProviderEditor({
if (apiKey.trim()) { if (apiKey.trim()) {
config.apiKey = apiKey; config.apiKey = apiKey;
} }
if (apiKeyEnvVar.trim() && apiKeyEnvVar !== provider?.apiKeyEnvVar) {
config.apiKeyEnvVar = apiKeyEnvVar;
}
if (baseUrl.trim() && baseUrl !== provider?.baseUrl) { if (baseUrl.trim() && baseUrl !== provider?.baseUrl) {
config.baseUrl = baseUrl; config.baseUrl = baseUrl;
} }
@@ -336,20 +331,6 @@ export function ProviderEditor({
)} )}
</div> </div>
{/* API Key Env Var */}
<div>
<label className="block text-xs text-fg-muted mb-1">
Environment Variable (alternative)
</label>
<Input
value={apiKeyEnvVar}
onChange={(e) => setApiKeyEnvVar(e.target.value)}
placeholder={provider?.apiKeyEnvVar || 'PROVIDER_API_KEY'}
/>
<p className="text-xs text-fg-subtle mt-1">
If no API key is set, this env var will be used
</p>
</div>
</div> </div>
{/* Base URL Section */} {/* Base URL Section */}
@@ -406,14 +406,6 @@ export function ProvidersPanel({ onClose, responsive = false }: ProvidersPanelPr
</div> </div>
)} )}
{/* API Key Env Var */}
{detail.apiKeyEnvVar && (
<div className="text-xs">
<span className="text-fg-muted">API Key Env:</span>{' '}
<code className="text-fg-secondary bg-surface-subtle px-1 rounded">{detail.apiKeyEnvVar}</code>
</div>
)}
{/* Models */} {/* Models */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -688,14 +680,6 @@ export function ProvidersPanel({ onClose, responsive = false }: ProvidersPanelPr
placeholder="http://localhost:11434/v1" placeholder="http://localhost:11434/v1"
/> />
</div> </div>
<div>
<label className="text-xs text-fg-muted">API Key Env Var (optional)</label>
<Input
value={newProvider.apiKeyEnvVar || ''}
onChange={(e) => setNewProvider((p) => ({ ...p, apiKeyEnvVar: e.target.value }))}
placeholder="OLLAMA_API_KEY"
/>
</div>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setShowAddProvider(false)}> <Button variant="ghost" onClick={() => setShowAddProvider(false)}>
@@ -0,0 +1,452 @@
/**
* ServicesPanel Component
*
* Third-party services configuration panel (e.g., Tavily for web search)
*/
import { useState, useEffect, useCallback } from 'react';
import {
X,
RefreshCw,
Globe,
Key,
Check,
Loader2,
Eye,
EyeOff,
ExternalLink,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { cn } from '../utils/cn';
import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
import { Button } from '../primitives/Button';
import { Input } from '../primitives/Input';
import { Switch } from '../primitives/Switch';
import { Skeleton } from './Skeleton';
import {
listServices,
updateService,
deleteService,
type ServiceListItem,
} from '../api/client.js';
interface ServicesPanelProps {
onClose: () => void;
/** Enable responsive layout */
responsive?: boolean;
}
export function ServicesPanel({ onClose, responsive = false }: ServicesPanelProps) {
// Data state
const [services, setServices] = useState<ServiceListItem[]>([]);
// UI state
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [savingService, setSavingService] = useState<string | null>(null);
// Edit state
const [editingService, setEditingService] = useState<string | null>(null);
const [apiKeyInput, setApiKeyInput] = useState('');
const [showApiKey, setShowApiKey] = useState(false);
// Load services list
const loadServices = useCallback(async (showToast = false) => {
try {
const result = await listServices();
if (result.success) {
setServices(result.data);
if (showToast) {
toast.success('Services refreshed');
}
} else {
toast.error(result.error || 'Failed to load services');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load services');
}
}, []);
// Initial load
useEffect(() => {
setLoading(true);
loadServices().finally(() => setLoading(false));
}, [loadServices]);
// Refresh
const handleRefresh = async () => {
setRefreshing(true);
await loadServices(true);
setRefreshing(false);
};
// Start editing a service
const handleStartEdit = async (serviceId: string) => {
setEditingService(serviceId);
setApiKeyInput('');
setShowApiKey(false);
};
// Save service config
const handleSaveService = async (serviceId: string, enabled: boolean) => {
setSavingService(serviceId);
try {
const payload: { apiKey?: string; enabled?: boolean } = { enabled };
if (apiKeyInput) {
payload.apiKey = apiKeyInput;
}
const result = await updateService(serviceId, payload);
if (result.success) {
toast.success(`${serviceId} configuration saved`);
setEditingService(null);
setApiKeyInput('');
loadServices();
} else {
toast.error(result.error || 'Failed to save configuration');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to save configuration');
} finally {
setSavingService(null);
}
};
// Toggle service enabled status
const handleToggleEnabled = async (service: ServiceListItem) => {
setSavingService(service.id);
try {
const result = await updateService(service.id, { enabled: !service.enabled });
if (result.success) {
toast.success(`${service.name} ${!service.enabled ? 'enabled' : 'disabled'}`);
loadServices();
} else {
toast.error(result.error || 'Failed to update service');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update service');
} finally {
setSavingService(null);
}
};
// Delete service config (remove API key)
const handleDeleteConfig = async (serviceId: string) => {
if (!confirm(`Are you sure you want to remove the API key for "${serviceId}"?`)) return;
setSavingService(serviceId);
try {
const result = await deleteService(serviceId);
if (result.success) {
toast.success(`${serviceId} configuration removed`);
loadServices();
} else {
toast.error(result.error || 'Failed to remove configuration');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to remove configuration');
} finally {
setSavingService(null);
}
};
// Loading skeleton
const LoadingSkeleton = () => (
<div className="space-y-3 p-4">
{[1, 2].map((i) => (
<div key={i} className="flex items-center gap-3 p-4 bg-surface-base/50 rounded-lg">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-6 w-12 rounded-full" />
</div>
))}
</div>
);
// Service item component
const ServiceItem = ({ service }: { service: ServiceListItem }) => {
const isEditing = editingService === service.id;
const isSaving = savingService === service.id;
return (
<motion.div
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-surface-base/50 rounded-lg overflow-hidden"
>
{/* Service Header */}
<div className="flex items-center gap-4 p-4">
{/* Icon */}
<div className="w-10 h-10 rounded-lg bg-primary-500/10 flex items-center justify-center">
<Globe size={20} className="text-primary-400" />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-fg-secondary">{service.name}</span>
<a
href={service.website}
target="_blank"
rel="noopener noreferrer"
className="text-fg-subtle hover:text-primary-400 transition-colors"
title="Visit website"
>
<ExternalLink size={12} />
</a>
</div>
<p className="text-xs text-fg-subtle truncate">{service.description}</p>
<div className="text-xs text-fg-subtle flex items-center gap-2 mt-1">
{service.hasApiKey ? (
<span className="text-green-400 flex items-center gap-1">
<Key size={10} />
API Key configured
</span>
) : (
<span className="text-yellow-400 flex items-center gap-1">
<Key size={10} />
No API Key
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
{/* Enable Toggle */}
<div className="flex items-center gap-2">
<span className="text-xs text-fg-muted">
{service.enabled ? 'Enabled' : 'Disabled'}
</span>
<Switch
checked={service.enabled}
onCheckedChange={() => handleToggleEnabled(service)}
disabled={isSaving || !service.hasApiKey}
/>
</div>
{/* Configure Button */}
<Button
variant="outline"
size="sm"
onClick={() => handleStartEdit(service.id)}
disabled={isSaving}
>
{isSaving ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Key size={14} className="mr-1" />
)}
Configure
</Button>
</div>
</div>
{/* Edit Panel */}
<AnimatePresence>
{isEditing && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-4 pb-4 pt-2 border-t border-line/50 space-y-4">
{/* API Key Input */}
<div className="space-y-2">
<label className="text-sm text-fg-muted flex items-center gap-2">
<Key size={14} />
API Key
</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showApiKey ? 'text' : 'password'}
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
placeholder={service.hasApiKey ? '••••••••••••••••' : 'Enter API key'}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg-secondary"
>
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<p className="text-xs text-fg-subtle">
Get your API key from{' '}
<a
href={service.website}
target="_blank"
rel="noopener noreferrer"
className="text-primary-400 hover:underline"
>
{service.website}
</a>
</p>
</div>
{/* Actions */}
<div className="flex items-center justify-between">
<div>
{service.hasApiKey && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteConfig(service.id)}
className="text-red-400 hover:text-red-300"
disabled={isSaving}
>
Remove API Key
</Button>
)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingService(null);
setApiKeyInput('');
}}
disabled={isSaving}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => handleSaveService(service.id, service.enabled)}
disabled={isSaving || (!apiKeyInput && !service.hasApiKey)}
>
{isSaving ? (
<Loader2 size={14} className="animate-spin mr-1" />
) : (
<Check size={14} className="mr-1" />
)}
Save
</Button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
};
return (
<AnimatePresence>
<motion.div
variants={modalOverlay}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2 }}
className={cn(
'fixed inset-0 bg-black/50 flex z-50',
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
)}
onClick={onClose}
>
<motion.div
variants={modalContent}
initial="initial"
animate="animate"
exit="exit"
transition={smoothTransition}
onClick={(e) => e.stopPropagation()}
className={cn(
'bg-surface-subtle max-h-[90vh] overflow-hidden flex flex-col',
responsive
? 'w-full md:w-full md:max-w-lg md:mx-4 rounded-t-2xl md:rounded-lg'
: 'rounded-lg w-full max-w-lg mx-4'
)}
>
{/* Header */}
<div
className={cn(
'flex items-center justify-between border-b border-line',
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
)}
>
{responsive && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-surface-emphasis rounded-full md:hidden" />
)}
<div className={cn(responsive && 'mt-2 md:mt-0')}>
<h2 className="text-lg font-semibold flex items-center gap-2">
<Globe size={20} className="text-primary-400" />
External Services
</h2>
<p className="text-xs text-fg-subtle">
Configure API keys for third-party services
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={handleRefresh}
disabled={refreshing}
title="Refresh"
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<X size={20} />
</Button>
</div>
</div>
{/* Services List */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<LoadingSkeleton />
) : services.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-fg-subtle">
<Globe size={48} className="mb-4 opacity-50" />
<p className="text-center">No services available</p>
</div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={cn('space-y-3', responsive ? 'p-4' : 'p-4')}
>
{services.map((service) => (
<ServiceItem key={service.id} service={service} />
))}
</motion.div>
)}
</div>
{/* Footer */}
<div
className={cn(
'border-t border-line text-xs text-fg-subtle text-center',
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
)}
>
Config stored in{' '}
<code className="font-mono bg-surface-base px-1 rounded">~/.ai-assistant/providers.json</code>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}
+98 -9
View File
@@ -5,14 +5,23 @@
*/ */
import { useState, useEffect, useCallback, forwardRef } from 'react'; import { useState, useEffect, useCallback, forwardRef } from 'react';
import { X, Plus, MessageSquare, Trash2, MessageCircle } from 'lucide-react'; import { X, Plus, MessageSquare, Trash2, MessageCircle, Coins } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '../utils/cn'; import { cn } from '../utils/cn';
import { modalOverlay, modalContent, smoothTransition, fadeInUp } from '../utils/animations'; import { modalOverlay, modalContent, smoothTransition, fadeInUp } from '../utils/animations';
import { Button } from '../primitives/Button'; import { Button } from '../primitives/Button';
import { SessionSkeleton } from './Skeleton'; import { SessionSkeleton } from './Skeleton';
import { listSessions, createSession, deleteSession, type Session } from '../api/client.js'; import {
listSessions,
createSession,
deleteSession,
getTokenStatsSummary,
getSessionTokenStats,
type Session,
type ProjectTokenStats,
type SessionTokenStats,
} from '../api/client.js';
interface SessionPanelProps { interface SessionPanelProps {
onClose: () => void; onClose: () => void;
@@ -28,6 +37,17 @@ interface SessionPanelProps {
responsive?: boolean; responsive?: boolean;
} }
// 格式化 Token 数量
const formatTokens = (tokens: number): string => {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(2)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return `${tokens}`;
};
export function SessionPanel({ export function SessionPanel({
onClose, onClose,
currentSessionId, currentSessionId,
@@ -38,13 +58,45 @@ export function SessionPanel({
}: SessionPanelProps) { }: SessionPanelProps) {
const [sessions, setSessions] = useState<Session[]>([]); const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [projectStats, setProjectStats] = useState<ProjectTokenStats | null>(null);
const [sessionStats, setSessionStats] = useState<Record<string, SessionTokenStats>>({});
// 加载会话列表 // 加载会话列表和 Token 统计
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const { data } = await listSessions(); const [sessionsResult, summaryResult] = await Promise.all([
setSessions(data); listSessions(),
getTokenStatsSummary(),
]);
setSessions(sessionsResult.data);
// 设置项目统计
if (summaryResult.success && summaryResult.data?.project) {
setProjectStats(summaryResult.data.project);
}
// 加载每个会话的 Token 统计
const statsPromises = sessionsResult.data.map(async (session) => {
try {
const result = await getSessionTokenStats(session.id);
if (result.success && result.data) {
return { id: session.id, stats: result.data };
}
} catch {
// 忽略单个会话统计加载失败
}
return null;
});
const statsResults = await Promise.all(statsPromises);
const newStats: Record<string, SessionTokenStats> = {};
for (const result of statsResults) {
if (result) {
newStats[result.id] = result.stats;
}
}
setSessionStats(newStats);
} catch (error) { } catch (error) {
console.error('Failed to load sessions:', error); console.error('Failed to load sessions:', error);
toast.error('Failed to load sessions'); toast.error('Failed to load sessions');
@@ -109,7 +161,11 @@ export function SessionPanel({
}, [sessionTitleUpdate]); }, [sessionTitleUpdate]);
// 会话列表项 // 会话列表项
const SessionItem = forwardRef<HTMLDivElement, { session: Session }>(({ session }, ref) => ( const SessionItem = forwardRef<HTMLDivElement, { session: Session }>(({ session }, ref) => {
const stats = sessionStats[session.id];
const totalTokens = stats?.total.totalTokens ?? 0;
return (
<motion.div <motion.div
ref={ref} ref={ref}
layout layout
@@ -131,7 +187,21 @@ export function SessionPanel({
<div className="text-sm truncate text-fg"> <div className="text-sm truncate text-fg">
{session.name || `Chat ${session.id.slice(0, 8)}`} {session.name || `Chat ${session.id.slice(0, 8)}`}
</div> </div>
<div className="text-xs text-fg-subtle">{session.messageCount} messages</div> <div className="flex items-center gap-2 text-xs text-fg-subtle">
<span>{session.messageCount} messages</span>
{totalTokens > 0 && (
<>
<span className="text-fg-muted/40"></span>
<span
className="flex items-center gap-0.5"
title={`输入: ${formatTokens(stats.total.inputTokens)} | 输出: ${formatTokens(stats.total.outputTokens)}`}
>
<Coins size={10} />
{formatTokens(totalTokens)}
</span>
</>
)}
</div>
</div> </div>
<motion.button <motion.button
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.1 }}
@@ -143,7 +213,8 @@ export function SessionPanel({
<Trash2 size={14} className="text-red-400" /> <Trash2 size={14} className="text-red-400" />
</motion.button> </motion.button>
</motion.div> </motion.div>
)); );
});
// 空状态 // 空状态
const EmptyState = () => ( const EmptyState = () => (
@@ -189,7 +260,8 @@ export function SessionPanel({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-line"> <div className="border-b border-line">
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MessageSquare size={20} className="text-primary-400" /> <MessageSquare size={20} className="text-primary-400" />
<h2 className="text-lg font-semibold text-fg">Sessions</h2> <h2 className="text-lg font-semibold text-fg">Sessions</h2>
@@ -214,6 +286,23 @@ export function SessionPanel({
</motion.button> </motion.button>
</div> </div>
</div> </div>
{/* 项目 Token 统计 */}
{projectStats && projectStats.totalTokens > 0 && (
<div className="px-4 pb-3 flex items-center gap-4 text-xs text-fg-muted">
<div
className="flex items-center gap-1"
title={`总输入: ${formatTokens(projectStats.totalInputTokens)} | 总输出: ${formatTokens(projectStats.totalOutputTokens)}`}
>
<Coins size={12} className="text-primary-400" />
<span>:</span>
<span className="text-fg-secondary font-medium">{formatTokens(projectStats.totalTokens)}</span>
</div>
<div className="text-fg-subtle">
{projectStats.sessionCount}
</div>
</div>
)}
</div>
{/* Session List */} {/* Session List */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
+57 -7
View File
@@ -1,18 +1,28 @@
/** /**
* StatusBar Component * StatusBar Component
* *
* 底部状态栏,显示 Git 分支、诊断信息、连接状态等 * 底部状态栏,显示 Git 分支、诊断信息、连接状态、Token 统计
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { GitBranch, AlertTriangle, AlertCircle, WifiOff, RefreshCw, CheckCircle } from 'lucide-react'; import { GitBranch, AlertTriangle, AlertCircle, WifiOff, RefreshCw, CheckCircle, Coins } from 'lucide-react';
import { cn } from '../utils/cn.js'; import { cn } from '../utils/cn.js';
import { getLSPDiagnostics, getGitInfo, getHealth, type DiagnosticsSummary, type GitInfo } from '../api/client.js'; import {
getLSPDiagnostics,
getGitInfo,
getHealth,
getSessionTokenStats,
type DiagnosticsSummary,
type GitInfo,
type SessionTokenStats,
} from '../api/client.js';
interface StatusBarProps { interface StatusBarProps {
className?: string; className?: string;
/** 是否连接到服务器 */ /** 是否连接到服务器 */
isConnected?: boolean; isConnected?: boolean;
/** 当前会话 ID */
sessionId?: string | null;
/** 点击诊断信息回调 */ /** 点击诊断信息回调 */
onDiagnosticsClick?: () => void; onDiagnosticsClick?: () => void;
/** 刷新间隔 (ms) */ /** 刷新间隔 (ms) */
@@ -22,25 +32,39 @@ interface StatusBarProps {
export function StatusBar({ export function StatusBar({
className, className,
isConnected: isConnectedProp, isConnected: isConnectedProp,
sessionId,
onDiagnosticsClick, onDiagnosticsClick,
refreshInterval = 30000, refreshInterval = 30000,
}: StatusBarProps) { }: StatusBarProps) {
const [diagnostics, setDiagnostics] = useState<DiagnosticsSummary | null>(null); const [diagnostics, setDiagnostics] = useState<DiagnosticsSummary | null>(null);
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null); const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
const [tokenStats, setTokenStats] = useState<SessionTokenStats | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [connectionStatus, setConnectionStatus] = useState(true); const [connectionStatus, setConnectionStatus] = useState(true);
// 如果外部传入了 isConnected,使用外部值;否则使用内部检测 // 如果外部传入了 isConnected,使用外部值;否则使用内部检测
const isConnected = isConnectedProp ?? connectionStatus; const isConnected = isConnectedProp ?? connectionStatus;
// 加载诊断信息Git 信息 // 加载诊断信息Git 信息和 Token 统计
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const [diagResult, gitResult] = await Promise.all([ const promises: Promise<unknown>[] = [
getLSPDiagnostics(), getLSPDiagnostics(),
getGitInfo(), getGitInfo(),
]); ];
// 如果有 sessionId,加载 Token 统计
if (sessionId) {
promises.push(getSessionTokenStats(sessionId));
}
const results = await Promise.all(promises);
const [diagResult, gitResult, tokenResult] = results as [
Awaited<ReturnType<typeof getLSPDiagnostics>>,
Awaited<ReturnType<typeof getGitInfo>>,
Awaited<ReturnType<typeof getSessionTokenStats>> | undefined,
];
if (diagResult.success && diagResult.data.summary) { if (diagResult.success && diagResult.data.summary) {
setDiagnostics(diagResult.data.summary); setDiagnostics(diagResult.data.summary);
@@ -50,6 +74,10 @@ export function StatusBar({
setGitInfo(gitResult.data); setGitInfo(gitResult.data);
} }
if (tokenResult?.success && tokenResult.data) {
setTokenStats(tokenResult.data);
}
// 如果没有外部传入连接状态,内部检测 // 如果没有外部传入连接状态,内部检测
if (isConnectedProp === undefined) { if (isConnectedProp === undefined) {
setConnectionStatus(true); setConnectionStatus(true);
@@ -62,7 +90,7 @@ export function StatusBar({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [isConnectedProp]); }, [isConnectedProp, sessionId]);
// 检测连接状态(如果没有外部传入) // 检测连接状态(如果没有外部传入)
const checkConnection = useCallback(async () => { const checkConnection = useCallback(async () => {
@@ -92,6 +120,17 @@ export function StatusBar({
const warningCount = diagnostics?.totalWarnings ?? 0; const warningCount = diagnostics?.totalWarnings ?? 0;
const hasIssues = errorCount > 0 || warningCount > 0; const hasIssues = errorCount > 0 || warningCount > 0;
// 格式化 Token 数量
const formatTokens = (tokens: number): string => {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(2)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return `${tokens}`;
};
return ( return (
<div <div
className={cn( className={cn(
@@ -149,6 +188,17 @@ export function StatusBar({
{/* 右侧 */} {/* 右侧 */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Token 统计 */}
{tokenStats && tokenStats.total.totalTokens > 0 && (
<div
className="flex items-center gap-1 text-fg-muted hover:text-fg-secondary cursor-default"
title={`输入: ${formatTokens(tokenStats.total.inputTokens)} | 输出: ${formatTokens(tokenStats.total.outputTokens)} | 消息: ${tokenStats.messageCount}${tokenStats.children.totalTokens > 0 ? ` | 子任务: ${formatTokens(tokenStats.children.totalTokens)}` : ''}`}
>
<Coins size={12} />
<span>{formatTokens(tokenStats.total.totalTokens)}</span>
</div>
)}
{/* 诊断信息 */} {/* 诊断信息 */}
<button <button
onClick={onDiagnosticsClick} onClick={onDiagnosticsClick}
+19 -2
View File
@@ -415,6 +415,12 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
const content = message.payload?.content || streaming?.content || ''; const content = message.payload?.content || streaming?.content || '';
// 从服务器 payload 获取 agentName,或使用当前 agentMode // 从服务器 payload 获取 agentName,或使用当前 agentMode
const agentName = message.payload?.agentName || prev.agentMode; const agentName = message.payload?.agentName || prev.agentMode;
// 获取 token usage 信息
const usage = message.payload?.usage as {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
} | undefined;
const newMessage: Message = streaming const newMessage: Message = streaming
? { ? {
@@ -422,7 +428,13 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
id: message.payload?.id || streaming.id, id: message.payload?.id || streaming.id,
timestamp: message.payload?.timestamp || streaming.timestamp, timestamp: message.payload?.timestamp || streaming.timestamp,
content, content,
metadata: { ...streaming.metadata, agentName }, metadata: {
...streaming.metadata,
agentName,
inputTokens: usage?.inputTokens,
outputTokens: usage?.outputTokens,
totalTokens: usage?.totalTokens,
},
} }
: { : {
id: message.payload?.id || `assistant-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, id: message.payload?.id || `assistant-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
@@ -430,7 +442,12 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
timestamp: message.payload?.timestamp || new Date().toISOString(), timestamp: message.payload?.timestamp || new Date().toISOString(),
parts: [{ type: 'text', id: `text-${Date.now()}`, text: content }], parts: [{ type: 'text', id: `text-${Date.now()}`, text: content }],
content, content,
metadata: { agentName }, metadata: {
agentName,
inputTokens: usage?.inputTokens,
outputTokens: usage?.outputTokens,
totalTokens: usage?.totalTokens,
},
}; };
return { return {
+7
View File
@@ -105,6 +105,12 @@ export {
stopLSPServer, stopLSPServer,
getRunningLSPServers, getRunningLSPServers,
getLSPDiagnostics, getLSPDiagnostics,
// Services API
listServices,
getService,
updateService,
deleteService,
type ServiceListItem,
} from './api/client.js'; } from './api/client.js';
// Types // Types
@@ -235,6 +241,7 @@ export { AgentEditor } from './components/AgentEditor.js';
export { AgentDefaultsEditor } from './components/AgentDefaultsEditor.js'; export { AgentDefaultsEditor } from './components/AgentDefaultsEditor.js';
export { ProvidersPanel } from './components/ProvidersPanel.js'; export { ProvidersPanel } from './components/ProvidersPanel.js';
export { ProviderEditor } from './components/ProviderEditor.js'; export { ProviderEditor } from './components/ProviderEditor.js';
export { ServicesPanel } from './components/ServicesPanel.js';
export { CheckpointPanel } from './components/CheckpointPanel.js'; export { CheckpointPanel } from './components/CheckpointPanel.js';
export { CheckpointDiffViewer } from './components/CheckpointDiffViewer.js'; export { CheckpointDiffViewer } from './components/CheckpointDiffViewer.js';
export { RestoreDialog } from './components/RestoreDialog.js'; export { RestoreDialog } from './components/RestoreDialog.js';
+12 -12
View File
@@ -13,6 +13,7 @@ import {
AgentsPanel, AgentsPanel,
CheckpointPanel, CheckpointPanel,
ProvidersPanel, ProvidersPanel,
ServicesPanel,
LSPPanel, LSPPanel,
DiagnosticsPanel, DiagnosticsPanel,
SessionPanel, SessionPanel,
@@ -37,6 +38,7 @@ export function App() {
const [showAgents, setShowAgents] = useState(false); const [showAgents, setShowAgents] = useState(false);
const [showCheckpoints, setShowCheckpoints] = useState(false); const [showCheckpoints, setShowCheckpoints] = useState(false);
const [showProviders, setShowProviders] = useState(false); const [showProviders, setShowProviders] = useState(false);
const [showServices, setShowServices] = useState(false);
const [showLSP, setShowLSP] = useState(false); const [showLSP, setShowLSP] = useState(false);
const [showDiagnostics, setShowDiagnostics] = useState(false); const [showDiagnostics, setShowDiagnostics] = useState(false);
const [showSessions, setShowSessions] = useState(false); const [showSessions, setShowSessions] = useState(false);
@@ -64,8 +66,6 @@ export function App() {
// 初始化:加载会话 // 初始化:加载会话
useEffect(() => { useEffect(() => {
const HAS_SESSIONS_KEY = 'ai-assistant-has-sessions';
async function init() { async function init() {
try { try {
const sessionsResult = await listSessions(); const sessionsResult = await listSessions();
@@ -74,18 +74,11 @@ export function App() {
if (sessions.length > 0) { if (sessions.length > 0) {
// 有会话,选择最近的 // 有会话,选择最近的
setCurrentSessionId(sessions[0].id); setCurrentSessionId(sessions[0].id);
localStorage.setItem(HAS_SESSIONS_KEY, 'true');
} else { } else {
// 无会话:检查是否是首次启动 // 无会话,自动创建一个新会话
const hasHadSessions = localStorage.getItem(HAS_SESSIONS_KEY); // 这处理了新 project 目录的情况
if (!hasHadSessions) {
// 首次启动,自动创建会话
const { data: newSession } = await createSession(); const { data: newSession } = await createSession();
setCurrentSessionId(newSession.id); setCurrentSessionId(newSession.id);
localStorage.setItem(HAS_SESSIONS_KEY, 'true');
}
// 用户删除了所有会话:不自动创建,显示空状态
} }
} catch (error) { } catch (error) {
console.error('Failed to initialize:', error); console.error('Failed to initialize:', error);
@@ -203,6 +196,7 @@ export function App() {
onOpenAgents={() => setShowAgents(true)} onOpenAgents={() => setShowAgents(true)}
onOpenCheckpoints={() => setShowCheckpoints(true)} onOpenCheckpoints={() => setShowCheckpoints(true)}
onOpenProviders={() => setShowProviders(true)} onOpenProviders={() => setShowProviders(true)}
onOpenServices={() => setShowServices(true)}
onOpenLSP={() => setShowLSP(true)} onOpenLSP={() => setShowLSP(true)}
onOpenDiagnostics={() => setShowDiagnostics(true)} onOpenDiagnostics={() => setShowDiagnostics(true)}
onOpenSessions={() => setShowSessions(true)} onOpenSessions={() => setShowSessions(true)}
@@ -222,7 +216,10 @@ export function App() {
</div> </div>
{/* 底部状态栏 */} {/* 底部状态栏 */}
<StatusBar onDiagnosticsClick={() => setShowDiagnostics(true)} /> <StatusBar
sessionId={currentSessionId}
onDiagnosticsClick={() => setShowDiagnostics(true)}
/>
{/* 命令面板 */} {/* 命令面板 */}
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} responsive />} {showCommands && <CommandPanel onClose={() => setShowCommands(false)} responsive />}
@@ -242,6 +239,9 @@ export function App() {
{/* Providers 面板 */} {/* Providers 面板 */}
{showProviders && <ProvidersPanel onClose={() => setShowProviders(false)} responsive />} {showProviders && <ProvidersPanel onClose={() => setShowProviders(false)} responsive />}
{/* Services 面板 */}
{showServices && <ServicesPanel onClose={() => setShowServices(false)} responsive />}
{/* LSP 面板 */} {/* LSP 面板 */}
{showLSP && ( {showLSP && (
<LSPPanel <LSPPanel
+4 -1
View File
@@ -3,7 +3,7 @@
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { MessageSquare, Terminal, Plug, Zap, Bot, History, Server, MessagesSquare } from 'lucide-react'; import { MessageSquare, Terminal, Plug, Zap, Bot, History, Server, MessagesSquare, Globe } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
@@ -33,6 +33,7 @@ interface ChatPageProps {
onOpenAgents?: () => void; onOpenAgents?: () => void;
onOpenCheckpoints?: () => void; onOpenCheckpoints?: () => void;
onOpenProviders?: () => void; onOpenProviders?: () => void;
onOpenServices?: () => void;
onOpenLSP?: () => void; onOpenLSP?: () => void;
onOpenDiagnostics?: () => void; onOpenDiagnostics?: () => void;
onOpenSessions?: () => void; onOpenSessions?: () => void;
@@ -61,6 +62,7 @@ export function ChatPage({
onOpenAgents, onOpenAgents,
onOpenCheckpoints, onOpenCheckpoints,
onOpenProviders, onOpenProviders,
onOpenServices,
onOpenLSP, onOpenLSP,
onOpenDiagnostics, onOpenDiagnostics,
onOpenSessions, onOpenSessions,
@@ -197,6 +199,7 @@ export function ChatPage({
items={[ items={[
{ icon: History, label: 'Checkpoints', onClick: onOpenCheckpoints }, { icon: History, label: 'Checkpoints', onClick: onOpenCheckpoints },
{ icon: Server, label: 'Model Providers', onClick: onOpenProviders }, { icon: Server, label: 'Model Providers', onClick: onOpenProviders },
{ icon: Globe, label: 'External Services', onClick: onOpenServices },
{ icon: Bot, label: 'Agent Presets', onClick: onOpenAgents }, { icon: Bot, label: 'Agent Presets', onClick: onOpenAgents },
{ icon: Zap, label: 'Hooks', onClick: onOpenHooks }, { icon: Zap, label: 'Hooks', onClick: onOpenHooks },
{ icon: Plug, label: 'MCP Servers', onClick: onOpenMCP }, { icon: Plug, label: 'MCP Servers', onClick: onOpenMCP },
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env bun
/**
* Build Standalone Server with Embedded Web UI and WASM
*
* 构建包含 Web UI 和 WASM 文件的独立服务器可执行文件
*
* Usage:
* bun run scripts/build-standalone.ts
* bun run scripts/build-standalone.ts --output ./dist/ai-assistant
*/
import * as fs from 'fs';
import * as path from 'path';
import { $ } from 'bun';
const ROOT_DIR = path.join(import.meta.dir, '..');
const WEB_DIR = path.join(ROOT_DIR, 'packages/web');
const SERVER_DIR = path.join(ROOT_DIR, 'packages/server');
const NODE_MODULES = path.join(ROOT_DIR, 'node_modules');
const CORE_DIR = path.join(ROOT_DIR, 'packages/core');
// 解析命令行参数
function parseArgs(): { output: string } {
const args = process.argv.slice(2);
let output = path.join(ROOT_DIR, 'dist/ai-assistant');
for (let i = 0; i < args.length; i++) {
if (args[i] === '--output' || args[i] === '-o') {
output = args[i + 1];
i++;
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
Build Standalone Server with Embedded Web UI
Usage:
bun run scripts/build-standalone.ts [options]
Options:
-o, --output <path> Output executable path (default: dist/ai-assistant)
-h, --help Show this help message
`);
process.exit(0);
}
}
return { output };
}
async function main() {
const { output } = parseArgs();
const outputDir = path.dirname(output);
console.log('🚀 Building standalone AI Assistant...\n');
// 1. 构建 Core
console.log('📦 Building @ai-assistant/core...');
await $`pnpm --filter @ai-assistant/core build`.quiet();
console.log(' ✅ Core built\n');
// 2. 构建 Web
console.log('📦 Building @ai-assistant/web...');
await $`pnpm --filter @ai-assistant/web build`.quiet();
console.log(' ✅ Web built\n');
// 3. 生成嵌入式 Web 资源
console.log('📝 Embedding Web UI assets...');
const webDistDir = path.join(WEB_DIR, 'dist');
const embeddedAssetsFile = path.join(SERVER_DIR, 'src/embedded-assets.generated.ts');
await generateEmbeddedAssets(webDistDir, embeddedAssetsFile);
console.log(' ✅ Assets embedded\n');
// 3.5. 生成嵌入式 WASM 资源
console.log('📝 Embedding WASM files...');
const embeddedWasmFile = path.join(SERVER_DIR, 'src/embedded-wasm.generated.ts');
await generateEmbeddedWasm(embeddedWasmFile);
console.log(' ✅ WASM embedded\n');
// 4. 构建 Server(使用嵌入式入口)
console.log('📦 Building standalone server...');
// 确保输出目录存在
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
await $`bun build ${SERVER_DIR}/src/bin/standalone.ts --compile --outfile ${output}`.quiet();
console.log(' ✅ Server built\n');
// 5. 清理生成的文件
if (fs.existsSync(embeddedAssetsFile)) {
fs.unlinkSync(embeddedAssetsFile);
}
if (fs.existsSync(embeddedWasmFile)) {
fs.unlinkSync(embeddedWasmFile);
}
// 6. 显示结果
const stats = fs.statSync(output);
const sizeMB = (stats.size / 1024 / 1024).toFixed(1);
console.log('═══════════════════════════════════════════');
console.log('✅ Build complete!');
console.log('═══════════════════════════════════════════');
console.log(` Output: ${output}`);
console.log(` Size: ${sizeMB} MB`);
console.log('');
console.log('Usage:');
console.log(` ${output} --help`);
console.log(` ${output} --port 3000`);
console.log('═══════════════════════════════════════════');
}
/**
* 将 Web dist 目录的所有文件转换为嵌入式 TypeScript 模块
*/
async function generateEmbeddedAssets(webDistDir: string, outputFile: string): Promise<void> {
const assets: Map<string, { content: string; mimeType: string }> = new Map();
// 递归收集所有文件
function collectFiles(dir: string, basePath: string = '') {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
collectFiles(fullPath, relativePath);
} else {
const content = fs.readFileSync(fullPath);
const base64 = content.toString('base64');
const mimeType = getMimeType(entry.name);
assets.set('/' + relativePath, { content: base64, mimeType });
}
}
}
collectFiles(webDistDir);
// 生成 TypeScript 代码
const lines: string[] = [
'// Auto-generated by scripts/build-standalone.ts',
'// Do not edit manually',
'',
'export interface EmbeddedAsset {',
' content: string; // base64 encoded',
' mimeType: string;',
'}',
'',
'export const EMBEDDED_ASSETS: Map<string, EmbeddedAsset> = new Map([',
];
for (const [path, asset] of assets) {
// 转义特殊字符
const escapedContent = asset.content;
lines.push(` ['${path}', { content: '${escapedContent}', mimeType: '${asset.mimeType}' }],`);
}
lines.push(']);');
lines.push('');
lines.push('export function getAsset(path: string): EmbeddedAsset | undefined {');
lines.push(' return EMBEDDED_ASSETS.get(path);');
lines.push('}');
lines.push('');
lines.push('export function decodeAsset(asset: EmbeddedAsset): Uint8Array {');
lines.push(' const binaryString = atob(asset.content);');
lines.push(' const bytes = new Uint8Array(binaryString.length);');
lines.push(' for (let i = 0; i < binaryString.length; i++) {');
lines.push(' bytes[i] = binaryString.charCodeAt(i);');
lines.push(' }');
lines.push(' return bytes;');
lines.push('}');
lines.push('');
fs.writeFileSync(outputFile, lines.join('\n'), 'utf-8');
console.log(` Generated ${assets.size} embedded assets`);
}
/**
* 根据文件扩展名获取 MIME 类型
*/
function getMimeType(filename: string): string {
const ext = path.extname(filename).toLowerCase();
const mimeTypes: Record<string, string> = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject',
'.webmanifest': 'application/manifest+json',
'.txt': 'text/plain',
'.xml': 'application/xml',
};
return mimeTypes[ext] || 'application/octet-stream';
}
/**
* 生成嵌入式 WASM 模块
* 包含 tree-sitter.wasm 和可用的语言 WASM 文件
*/
async function generateEmbeddedWasm(outputFile: string): Promise<void> {
const wasmFiles: Map<string, string> = new Map();
// 查找 web-tree-sitter 的 tree-sitter.wasm
const treeSitterWasmPaths = [
path.join(NODE_MODULES, '.pnpm/web-tree-sitter@0.25.10/node_modules/web-tree-sitter/tree-sitter.wasm'),
path.join(NODE_MODULES, 'web-tree-sitter/tree-sitter.wasm'),
];
for (const wasmPath of treeSitterWasmPaths) {
if (fs.existsSync(wasmPath)) {
const content = fs.readFileSync(wasmPath);
wasmFiles.set('tree-sitter.wasm', content.toString('base64'));
console.log(` Found tree-sitter.wasm (${(content.length / 1024).toFixed(1)} KB)`);
break;
}
}
// 查找语言特定的 WASM 文件
const languageWasmPatterns = [
// pnpm 格式
{ dir: path.join(NODE_MODULES, '.pnpm'), pattern: /tree-sitter-(\w+)@.*\/tree-sitter-\1\.wasm$/ },
];
// 扫描 node_modules/.pnpm 查找语言 WASM
const pnpmDir = path.join(NODE_MODULES, '.pnpm');
if (fs.existsSync(pnpmDir)) {
const entries = fs.readdirSync(pnpmDir);
for (const entry of entries) {
if (entry.startsWith('tree-sitter-') && !entry.startsWith('tree-sitter-wasms')) {
// 提取语言名称
const match = entry.match(/^tree-sitter-(\w+)@/);
if (match) {
const lang = match[1];
const wasmPath = path.join(pnpmDir, entry, 'node_modules', `tree-sitter-${lang}`, `tree-sitter-${lang}.wasm`);
if (fs.existsSync(wasmPath)) {
const content = fs.readFileSync(wasmPath);
wasmFiles.set(`tree-sitter-${lang}.wasm`, content.toString('base64'));
console.log(` Found tree-sitter-${lang}.wasm (${(content.length / 1024).toFixed(1)} KB)`);
}
}
}
}
}
if (wasmFiles.size === 0) {
console.warn(' ⚠️ No WASM files found');
}
// 生成 TypeScript 代码
const lines: string[] = [
'// Auto-generated by scripts/build-standalone.ts',
'// Do not edit manually',
'',
'/**',
' * Embedded WASM files for tree-sitter',
' * Keys: wasm filename, Values: base64 encoded content',
' */',
'export const EMBEDDED_WASM: Map<string, string> = new Map([',
];
for (const [name, base64] of wasmFiles) {
lines.push(` ['${name}', '${base64}'],`);
}
lines.push(']);');
lines.push('');
fs.writeFileSync(outputFile, lines.join('\n'), 'utf-8');
console.log(` Generated ${wasmFiles.size} embedded WASM files`);
}
main().catch((error) => {
console.error('❌ Build failed:', error);
process.exit(1);
});